feat: Merge upstream/master into b310-digital/excalidraw#master (#3)

This commit is contained in:
Sören Johanson 2024-08-23 15:54:59 +02:00 committed by GitHub
parent aaca099bc3
commit efdae58832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
549 changed files with 79065 additions and 30661 deletions

View File

@ -8,5 +8,12 @@
!package.json !package.json
!public/ !public/
!packages/ !packages/
!scripts/
!tsconfig.json !tsconfig.json
!yarn.lock !yarn.lock
# keep (sub)sub directories at the end to exclude from explicit included
# e.g. ./packages/excalidraw/{dist,node_modules}
**/build
**/dist
**/node_modules

View File

@ -10,9 +10,6 @@ VITE_APP_HTTP_STORAGE_BACKEND_URL=http://localhost:8080/api/v2
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room) # collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
VITE_APP_WS_SERVER_URL=http://localhost:5001 VITE_APP_WS_SERVER_URL=http://localhost:5001
# set this only if using the collaboration workflow we use on excalidraw.com
VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com
@ -28,7 +25,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD= VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_TRACKING=false
FAST_REFRESH=false FAST_REFRESH=false

View File

@ -20,5 +20,5 @@ VITE_APP_WS_SERVER_URL=http://localhost:5012
# VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' # VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_TRACKING=false
VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_SENTRY=true

View File

@ -6,3 +6,5 @@ firebase/
dist/ dist/
public/workbox public/workbox
packages/excalidraw/types packages/excalidraw/types
examples/**/public
dev-dist

View File

@ -2,6 +2,7 @@
"extends": ["@excalidraw/eslint-config", "react-app"], "extends": ["@excalidraw/eslint-config", "react-app"],
"rules": { "rules": {
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",
"no-restricted-globals": "off" "no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
} }
} }

View File

@ -23,5 +23,5 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release - name: Auto release
run: | run: |
yarn add @actions/core yarn add @actions/core -W
yarn autorelease yarn autorelease

View File

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

View File

@ -23,6 +23,6 @@ jobs:
- uses: andresz1/size-limit-action@v1 - uses: andresz1/size-limit-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:umd build_script: build:esm
skip_step: install skip_step: install
directory: packages/excalidraw directory: packages/excalidraw

View File

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

View File

@ -1,17 +1,19 @@
name: Tests name: Tests
on: pull_request on:
push:
branches: master
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Setup Node.js 18.x - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 18.x
- name: Install and test - name: Install and test
run: | run: |
yarn install:deps yarn install
yarn test:app yarn test:app

5
.gitignore vendored
View File

@ -23,9 +23,8 @@ package-lock.json
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
packages/excalidraw/types packages/excalidraw/types
packages/excalidraw/example/public/bundle.js
packages/excalidraw/example/public/excalidraw-assets-dev
packages/excalidraw/example/public/excalidraw.development.js
coverage coverage
dev-dist dev-dist
html html
examples/**/bundle.*
meta*.json

View File

@ -33,10 +33,10 @@ Hint: Collab mode requires a secure context (https). Localhost works as well, bu
docker-compose -f docker-compose-prod.yml up -d docker-compose -f docker-compose-prod.yml up -d
``` ```
## Additional licence ## Additional licence
The excalidraw [logo](https://thenounproject.com/icon/2357486/) in this repo created by [Verry](https://thenounproject.com/verry.dsign.creative) is licenced under [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/). The excalidraw [logo](https://thenounproject.com/icon/2357486/) in this repo created by [Verry](https://thenounproject.com/verry.dsign.creative) is licenced under [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/).
<div align="center" style="display:flex;flex-direction:column;"}> <div align="center" style="display:flex;flex-direction:column;"}>
<a href="https://excalidraw.com"> <a href="https://excalidraw.com">
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams."/> <img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams."/>
@ -169,4 +169,7 @@ Visit our documentation on [https://docs.excalidraw.com](https://docs.excalidraw
## Who's integrating Excalidraw ## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) [Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/)
```
``` ```

View File

@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
```jsx showLineNumbers ```jsx showLineNumbers
export default function App() { export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null); const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />; return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
} }
``` ```
@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage | | API | Signature | Usage |
| --- | --- | --- | | --- | --- | --- |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData | | [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData | | [updateLibrary](#updatelibrary) | `function` | updates the library |
| [addFiles](#addfiles) | `function` | add files data to the appState | | [addFiles](#addfiles) | `function` | add files data to the appState |
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene | | [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
@ -37,7 +37,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| [setActiveTool](#setactivetool) | `function` | This API can be used to set the active tool | | [setActiveTool](#setactivetool) | `function` | This API can be used to set the active tool |
| [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas | | [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas |
| [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas | | [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas |
| [toggleMenu](#togglemenu) | `function` | Toggles specific menus on/off | | [toggleSidebar](#toggleSidebar) | `function` | Toggles specific sidebar on/off |
| [onChange](#onChange) | `function` | Subscribes to change events | | [onChange](#onChange) | `function` | Subscribes to change events |
| [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events | | [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events |
| [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events | | [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events |
@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene | | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. | | `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | | `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
```jsx live ```jsx live
function App() { function App() {
@ -115,7 +115,7 @@ function App() {
<button className="custom-button" onClick={updateScene}> <button className="custom-button" onClick={updateScene}>
Update Scene Update Scene
</button> </button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} /> <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
</div> </div>
); );
} }
@ -188,7 +188,7 @@ function App() {
Update Library Update Library
</button> </button>
<Excalidraw <Excalidraw
ref={(api) => setExcalidrawAPI(api)} excalidrawAPI={(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/packages/excalidraw/initialData.js
initialData={{ initialData={{
libraryItems: initialData.libraryItems, libraryItems: initialData.libraryItems,

View File

@ -9,9 +9,9 @@ All `props` are _optional_.
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode | | [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. | | [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. | | [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets | | [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. | | [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene | | [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. | | [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. | | [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw | | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
@ -23,10 +23,10 @@ All `props` are _optional_.
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | | [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component | | [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component |
| [`name`](#name) | `string` | | Name of the drawing | | [`name`](#name) | `string` | | Name of the drawing |
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](#canvasactions) | | [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load | | [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas | | [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation | | [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |

View File

@ -73,7 +73,7 @@ function App() {
## tools ## tools
This `prop ` controls the visibility of the tools in the editor. This `prop` controls the visibility of the tools in the editor.
Currently you can control the visibility of `image` tool via this prop. Currently you can control the visibility of `image` tool via this prop.
| Prop | Type | Default | Description | | Prop | Type | Default | Description |

View File

@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" /> <img src={canvasUrl} alt="" />
</div> </div>
<div style={{ height: "400px" }}> <div style={{ height: "400px" }}>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
/> />
</div> </div>
</> </>

View File

@ -32,15 +32,9 @@ function App() {
### Next.js ### Next.js
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`. Since Excalidraw doesn't support `server side rendering` so it should be rendered only on `client`. The way to achieve this in next.js is using `next.js dynamic import`.
Here are two ways on how you can render **Excalidraw** on **Next.js**. If you want to only import `Excalidraw` component you can do :point_down:
1. Using **Next.js Dynamic** import [Recommended].
Since Excalidraw doesn't support server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`.
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -55,25 +49,88 @@ export default function App() {
} }
``` ```
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2). However the above component only works for named component exports. If you want to import some util / constant or something else apart from Excalidraw, then this approach will not work. Instead you can write a wrapper over Excalidraw and import the wrapper dynamically.
If you are using `pages router` then importing the wrapper dynamically would work, where as if you are using `app router` then you will have to also add `useClient` directive on top of the file in addition to dynamically importing the wrapper as shown :point_down:
2. Importing Excalidraw once **client** is rendered. <Tabs>
<TabItem value="Excalidraw Wrapper" label="Excalidraw Wrapper" >
```jsx showLineNumbers ```jsx showLineNumbers
import { useState, useEffect } from "react"; "use client";
export default function App() { import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
const [Excalidraw, setExcalidraw] = useState(null);
useEffect(() => { import "@excalidraw/excalidraw/index.css";
import("@excalidraw/excalidraw").then((comp) =>
setExcalidraw(comp.Excalidraw), const ExcalidrawWrapper: React.FC = () => {
console.info(convertToExcalidrawElements([{
type: "rectangle",
id: "rect-1",
width: 186.47265625,
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
); );
}, []); };
return <>{Excalidraw && <Excalidraw />}</>; export default ExcalidrawWrapper;
} ```
```
</TabItem>
<TabItem value="pages" label="Pages router">
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return (
<ExcalidrawWrapper />
);
}
```
</TabItem>
<TabItem value="app" label="App router">
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return (
<ExcalidrawWrapper />
);
}
```
</TabItem>
</Tabs>
Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/excalidraw/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs-gh6smrdnq-excalidraw.vercel.app/).
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d)
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm) The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)

View File

@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
// editor state (canvas config, preferences, ...) // editor state (canvas config, preferences, ...)
"appState": { "appState": {
"gridSize": null, "gridSize": 20,
"viewBackgroundColor": "#ffffff" "viewBackgroundColor": "#ffffff"
}, },

View File

@ -18,13 +18,13 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.17.0", "@excalidraw/excalidraw": "0.17.6",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5", "prism-react-renderer": "^1.3.5",
"react": "^17.0.2", "react": "18.2.0",
"react-dom": "^17.0.2", "react-dom": "18.2.0",
"sass": "1.57.1" "sass": "1.57.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -59,7 +59,7 @@ pre a {
padding: 5px; padding: 5px;
background: #70b1ec; background: #70b1ec;
color: white; color: white;
font-weight: bold; font-weight: 700;
border: none; border: none;
} }

View File

@ -1718,10 +1718,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.17.0": "@excalidraw/excalidraw@0.17.6":
version "0.17.0" version "0.17.6"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg== integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"

View File

@ -23,6 +23,7 @@ services:
context: https://github.com/kitsteam/excalidraw-storage-backend.git#main context: https://github.com/kitsteam/excalidraw-storage-backend.git#main
target: production target: production
stdin_open: true stdin_open: true
container_name: excalidraw-storage-backend
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
@ -30,6 +31,7 @@ services:
excalidraw-room: excalidraw-room:
image: excalidraw/excalidraw-room image: excalidraw/excalidraw-room
container_name: excalidraw-room
ports: ports:
- "5001:80" - "5001:80"
@ -46,8 +48,9 @@ services:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data/pgdata - postgres_data:/var/lib/postgresql/data/pgdata
#ports: container_name: excalidraw-postgres
# - "5432:5432" ports:
- "5432:5432"
volumes: volumes:
postgres_data: postgres_data:
node_modules: node_modules:

View File

@ -15,14 +15,23 @@
border-radius: 50%; border-radius: 50%;
} }
} }
.app-title {
margin-block-start: 0.83em;
margin-block-end: 0.83em;
}
} }
.button-wrapper button { .button-wrapper {
z-index: 1; input[type="checkbox"] {
height: 40px; margin: 5px;
max-width: 200px; }
margin: 10px; button {
padding: 5px; z-index: 1;
height: 40px;
max-width: 200px;
margin: 10px;
padding: 5px;
}
} }
.excalidraw .App-menu_top .buttonList { .excalidraw .App-menu_top .buttonList {

View File

@ -1,23 +1,31 @@
import { useEffect, useState, useRef, useCallback } from "react"; import React, {
useEffect,
useState,
useRef,
useCallback,
Children,
cloneElement,
} from "react";
import ExampleSidebar from "./sidebar/ExampleSidebar"; import ExampleSidebar from "./sidebar/ExampleSidebar";
import type * as TExcalidraw from "../index"; import type * as TExcalidraw from "@excalidraw/excalidraw";
import "./App.scss";
import initialData from "./initialData";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import { import {
resolvablePromise, resolvablePromise,
ResolvablePromise, distance2d,
fileOpen,
withBatchedUpdates, withBatchedUpdates,
withBatchedUpdatesThrottled, withBatchedUpdatesThrottled,
} from "../utils"; } from "../utils";
import { EVENT, ROUNDNESS } from "../constants";
import { distance2d } from "../math"; import CustomFooter from "./CustomFooter";
import { fileOpen } from "../data/filesystem"; import MobileFooter from "./MobileFooter";
import { loadSceneOrLibraryFromBlob } from "../../utils"; import initialData from "../initialData";
import {
import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
@ -25,18 +33,14 @@ import {
Gesture, Gesture,
LibraryItems, LibraryItems,
PointerDownState as ExcalidrawPointerDownState, PointerDownState as ExcalidrawPointerDownState,
} from "../types"; } from "@excalidraw/excalidraw/dist/excalidraw/types";
import { NonDeletedExcalidrawElement, Theme } from "../element/types"; import type {
import { ImportedLibraryData } from "../data/types"; NonDeletedExcalidrawElement,
import CustomFooter from "./CustomFooter"; Theme,
import MobileFooter from "./MobileFooter"; } from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import { KEYS } from "../keys"; import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
declare global { import "./App.scss";
interface Window {
ExcalidrawLib: typeof TExcalidraw;
}
}
type Comment = { type Comment = {
x: number; x: number;
@ -57,29 +61,6 @@ type PointerDownState = {
}; };
}; };
// This is so that we use the bundled excalidraw.development.js file instead
// of the actual source code
const {
exportToCanvas,
exportToSvg,
exportToBlob,
exportToClipboard,
Excalidraw,
useHandleLibrary,
MIME_TYPES,
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
restoreElements,
Sidebar,
Footer,
WelcomeScreen,
MainMenu,
LiveCollaborationTrigger,
convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
} = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32; const COMMENT_ICON_DIMENSION = 32;
const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_HEIGHT = 50;
const COMMENT_INPUT_WIDTH = 150; const COMMENT_INPUT_WIDTH = 150;
@ -88,9 +69,38 @@ export interface AppProps {
appTitle: string; appTitle: string;
useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void;
customArgs?: any[]; customArgs?: any[];
children: React.ReactNode;
excalidrawLib: typeof TExcalidraw;
} }
export default function App({ appTitle, useCustom, customArgs }: AppProps) { export default function App({
appTitle,
useCustom,
customArgs,
children,
excalidrawLib,
}: AppProps) {
const {
exportToCanvas,
exportToSvg,
exportToBlob,
exportToClipboard,
useHandleLibrary,
MIME_TYPES,
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
restoreElements,
Sidebar,
Footer,
WelcomeScreen,
MainMenu,
LiveCollaborationTrigger,
convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
ROUNDNESS,
loadSceneOrLibraryFromBlob,
} = excalidrawLib;
const appRef = useRef<any>(null); const appRef = useRef<any>(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false); const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false);
@ -152,8 +162,105 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
}; };
}; };
fetchData(); fetchData();
}, [excalidrawAPI]); }, [excalidrawAPI, convertToExcalidrawElements, MIME_TYPES]);
const renderExcalidraw = (children: React.ReactNode) => {
const Excalidraw: any = Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child.type.displayName === "Excalidraw",
);
if (!Excalidraw) {
return;
}
const newElement = cloneElement(
Excalidraw,
{
excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api),
initialData: initialStatePromiseRef.current.promise,
onChange: (
elements: NonDeletedExcalidrawElement[],
state: AppState,
) => {
console.info("Elements :", elements, "State : ", state);
},
onPointerUpdate: (payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => setPointerData(payload),
viewModeEnabled,
zenModeEnabled,
gridModeEnabled,
theme,
name: "Custom name of drawing",
UIOptions: {
canvasActions: {
loadScene: false,
},
tools: { image: !disableImageTool },
},
renderTopRightUI,
onLinkOpen,
onPointerDown,
onScrollChange: rerenderCommentIcons,
validateEmbeddable: true,
},
<>
{excalidrawAPI && (
<Footer>
<CustomFooter
excalidrawAPI={excalidrawAPI}
excalidrawLib={excalidrawLib}
/>
</Footer>
)}
<WelcomeScreen />
<Sidebar name="custom">
<Sidebar.Tabs>
<Sidebar.Header />
<Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
<Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
</Sidebar.TabTriggers>
</Sidebar.Tabs>
</Sidebar>
<Sidebar.Trigger
name="custom"
tab="one"
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "20px",
zIndex: 9999999999999999,
}}
>
Toggle Custom Sidebar
</Sidebar.Trigger>
{renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</>,
);
return newElement;
};
const renderTopRightUI = (isMobile: boolean) => { const renderTopRightUI = (isMobile: boolean) => {
return ( return (
<> <>
@ -337,8 +444,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
) => { ) => {
return withBatchedUpdates((event) => { return withBatchedUpdates((event) => {
window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove); window.removeEventListener("pointermove", pointerDownState.onMove);
window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp); window.removeEventListener("pointerup", pointerDownState.onUp);
excalidrawAPI?.setActiveTool({ type: "selection" }); excalidrawAPI?.setActiveTool({ type: "selection" });
const distance = distance2d( const distance = distance2d(
pointerDownState.x, pointerDownState.x,
@ -402,8 +509,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
onPointerMoveFromPointerDownHandler(pointerDownState); onPointerMoveFromPointerDownHandler(pointerDownState);
const onPointerUp = const onPointerUp =
onPointerUpFromPointerDownHandler(pointerDownState); onPointerUpFromPointerDownHandler(pointerDownState);
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); window.addEventListener("pointermove", onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp); window.addEventListener("pointerup", onPointerUp);
pointerDownState.onMove = onPointerMove; pointerDownState.onMove = onPointerMove;
pointerDownState.onUp = onPointerUp; pointerDownState.onUp = onPointerUp;
@ -495,7 +602,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
}} }}
onBlur={saveComment} onBlur={saveComment}
onKeyDown={(event) => { onKeyDown={(event) => {
if (!event.shiftKey && event.key === KEYS.ENTER) { if (!event.shiftKey && event.key === "Enter") {
event.preventDefault(); event.preventDefault();
saveComment(); saveComment();
} }
@ -528,7 +635,12 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
{excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && (
<MobileFooter
excalidrawLib={excalidrawLib}
excalidrawAPI={excalidrawAPI}
/>
)}
</MainMenu> </MainMenu>
); );
}; };
@ -677,83 +789,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
</div> </div>
</div> </div>
<div className="excalidraw-wrapper"> <div className="excalidraw-wrapper">
<Excalidraw {renderExcalidraw(children)}
excalidrawAPI={(api: ExcalidrawImperativeAPI) =>
setExcalidrawAPI(api)
}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => {
// console.info("Elements :", elements, "State : ", state);
}}
onPointerUpdate={(payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => setPointerData(payload)}
viewModeEnabled={viewModeEnabled}
zenModeEnabled={zenModeEnabled}
gridModeEnabled={gridModeEnabled}
theme={theme}
name="Custom name of drawing"
UIOptions={{
canvasActions: {
loadScene: false,
},
tools: { image: !disableImageTool },
}}
renderTopRightUI={renderTopRightUI}
onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown}
onScrollChange={rerenderCommentIcons}
// allow all urls
validateEmbeddable={true}
>
{excalidrawAPI && (
<Footer>
<CustomFooter excalidrawAPI={excalidrawAPI} />
</Footer>
)}
<WelcomeScreen />
<Sidebar name="custom">
<Sidebar.Tabs>
<Sidebar.Header />
<Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
<Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
</Sidebar.TabTriggers>
</Sidebar.Tabs>
</Sidebar>
<Sidebar.Trigger
name="custom"
tab="one"
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "20px",
zIndex: 9999999999999999,
}}
>
Toggle Custom Sidebar
</Sidebar.Trigger>
{renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</Excalidraw>
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()} {comment && renderComment()}
</div> </div>
@ -836,7 +872,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil"; ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
@ -857,7 +893,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil"; ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}

View File

@ -1,6 +1,5 @@
import { ExcalidrawImperativeAPI } from "../types"; import type * as TExcalidraw from "@excalidraw/excalidraw";
import { MIME_TYPES } from "../entry"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import { Button } from "../components/Button";
const COMMENT_SVG = ( const COMMENT_SVG = (
<svg <svg
@ -18,24 +17,28 @@ const COMMENT_SVG = (
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg> </svg>
); );
const CustomFooter = ({ const CustomFooter = ({
excalidrawAPI, excalidrawAPI,
excalidrawLib,
}: { }: {
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
excalidrawLib: typeof TExcalidraw;
}) => { }) => {
const { Button, MIME_TYPES } = excalidrawLib;
return ( return (
<> <>
<Button <Button
onSelect={() => alert("General Kenobi!")} onSelect={() => alert("General Kenobi!")}
className="you are a bold one" style={{ marginLeft: "1rem", width: "auto" }}
style={{ marginLeft: "1rem" }}
title="Hello there!" title="Hello there!"
> >
{COMMENT_SVG} Hit me
</Button> </Button>
<button <Button
className="custom-element" className="custom-element"
onClick={() => { onSelect={() => {
excalidrawAPI?.setActiveTool({ excalidrawAPI?.setActiveTool({
type: "custom", type: "custom",
customType: "comment", customType: "comment",
@ -58,15 +61,10 @@ const CustomFooter = ({
)}`; )}`;
excalidrawAPI?.setCursor(`url(${url}), auto`); excalidrawAPI?.setCursor(`url(${url}), auto`);
}} }}
title="Comments!"
> >
{COMMENT_SVG} {COMMENT_SVG}
</button> </Button>
<button
className="custom-footer"
onClick={() => alert("This is dummy footer")}
>
custom footer
</button>
</> </>
); );
}; };

View File

@ -0,0 +1,27 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";
const MobileFooter = ({
excalidrawAPI,
excalidrawLib,
}: {
excalidrawAPI: ExcalidrawImperativeAPI;
excalidrawLib: typeof TExcalidraw;
}) => {
const { useDevice, Footer } = excalidrawLib;
const device = useDevice();
if (device.editor.isMobile) {
return (
<Footer>
<CustomFooter
excalidrawAPI={excalidrawAPI}
excalidrawLib={excalidrawLib}
/>
</Footer>
);
}
return null;
};
export default MobileFooter;

View File

@ -1,5 +1,6 @@
import React, { useState } from "react"; import { useState } from "react";
import "./ExampleSidebar.scss"; import "./ExampleSidebar.scss";
export default function Sidebar({ children }: { children: React.ReactNode }) { export default function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View File

@ -1,5 +1,5 @@
import { ExcalidrawElementSkeleton } from "../data/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import { FileId } from "../element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
{ {
@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
]; ];
export default { export default {
elements, elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
scrollToContent: true, scrollToContent: true,
libraryItems: [ libraryItems: [
[ [

View File

@ -0,0 +1,13 @@
{
"name": "examples",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"@excalidraw/excalidraw": "*"
},
"devDependencies": {
"typescript": "^5"
}
}

View File

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig"
}

View File

@ -0,0 +1,146 @@
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/excalidraw";
import { AbortError } from "../../packages/excalidraw/errors";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void;
};
export const resolvablePromise = <T>() => {
let resolve!: any;
let reject!: any;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
(promise as any).resolve = resolve;
(promise as any).reject = reject;
return promise as ResolvablePromise<T>;
};
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
const xd = x2 - x1;
const yd = y2 - y1;
return Math.hypot(xd, yd);
};
export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[];
description: string;
multiple?: M;
}): Promise<M extends false | undefined ? File : File[]> => {
// an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined ? File : File[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]);
return mimeTypes;
}, [] as string[]);
const extensions = opts.extensions?.reduce((acc, ext) => {
if (ext === "jpg") {
return acc.concat(".jpg", ".jpeg");
}
return acc.concat(`.${ext}`);
}, [] as string[]);
return _fileOpen({
description: opts.description,
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener("keyup", scheduleRejection);
document.addEventListener("pointerup", scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener("focus", focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener("focus", focusHandler);
document.removeEventListener("keyup", scheduleRejection);
document.removeEventListener("pointerup", scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};
},
}) as Promise<RetType>;
};
export const debounce = <T extends any[]>(
fn: (...args: T) => void,
timeout: number,
) => {
let handle = 0;
let lastArgs: T | null = null;
const ret = (...args: T) => {
lastArgs = args;
clearTimeout(handle);
handle = window.setTimeout(() => {
lastArgs = null;
fn(...args);
}, timeout);
};
ret.flush = () => {
clearTimeout(handle);
if (lastArgs) {
const _lastArgs = lastArgs;
lastArgs = null;
fn(..._lastArgs);
}
};
ret.cancel = () => {
lastArgs = null;
clearTimeout(handle);
};
return ret;
};
export const withBatchedUpdates = <
TFunction extends ((event: any) => void) | (() => void),
>(
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
) =>
((event) => {
unstable_batchedUpdates(func as TFunction, event);
}) as TFunction;
/**
* barches React state updates and throttles the calls to a single call per
* animation frame
*/
export const withBatchedUpdatesThrottled = <
TFunction extends ((event: any) => void) | (() => void),
>(
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
) => {
// @ts-ignore
return throttleRAF<Parameters<TFunction>>(((event) => {
unstable_batchedUpdates(func, event);
}) as TFunction);
};

View File

@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# copied assets
public/*.woff2

View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3005) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
distDir: "build",
typescript: {
// The ts config doesn't work with `jsx: preserve" and if updated to `react-jsx` it gets ovewritten by next js throwing ts errors hence I am ignoring build errors until this is fixed.
ignoreBuildErrors: true,
},
// This is needed as in pages router the code for importing types throws error as its outside next js app
transpilePackages: ["../"],
};
module.exports = nextConfig;

View File

@ -0,0 +1,26 @@
{
"name": "with-nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
"lint": "next lint"
},
"dependencies": {
"@excalidraw/excalidraw": "*",
"next": "14.1",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"path2d-polyfill": "2.0.1",
"typescript": "^5"
}
}

View File

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 197 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@ -0,0 +1,26 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
// with ssr false
const ExcalidrawWithClientOnly = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return (
<>
<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly />
</>
);
}

View File

@ -0,0 +1,15 @@
* {
box-sizing: border-box;
font-family: sans-serif;
}
a {
color: #1c7ed6;
font-size: 20px;
text-decoration: none;
font-weight: 500;
}
.page-title {
text-align: center;
}

View File

@ -0,0 +1,22 @@
"use client";
import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../components/App";
import "@excalidraw/excalidraw/index.css";
const ExcalidrawWrapper: React.FC = () => {
return (
<>
<App
appTitle={"Excalidraw with Nextjs Example"}
useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={excalidrawLib}
>
<Excalidraw />
</App>
</>
);
};
export default ExcalidrawWrapper;

View File

@ -0,0 +1,22 @@
import dynamic from "next/dynamic";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
// with ssr false
const Excalidraw = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return (
<>
<a href="/">Switch to App router</a>
<h1 className="page-title">Pages Router</h1>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<Excalidraw />
</>
);
}

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "build/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,3 @@
{
"outputDirectory": "build"
}

View File

@ -0,0 +1,252 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@excalidraw/excalidraw@workspace:^":
version "0.17.2"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.2.tgz#9a636a1e6bb3c88c5883347d3a7e75e9cce8ab96"
integrity sha512-7pqUWD8+mPjDhF4XxG3gw4rvE2JGaLW3Vss5UZfTbITPxAtFaGEc1K081bncitnaYhUwN9ENJE0i87QB3poDwQ==
"@next/env@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.4.tgz#d5cda0c4a862d70ae760e58c0cd96a8899a2e49a"
integrity sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==
"@next/swc-darwin-arm64@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz#27b1854c2cd04eb1d5e75081a1a792ad91526618"
integrity sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==
"@next/swc-darwin-x64@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz#9940c449e757d0ee50bb9e792d2600cc08a3eb3b"
integrity sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==
"@next/swc-linux-arm64-gnu@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz#0eafd27c8587f68ace7b4fa80695711a8434de21"
integrity sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==
"@next/swc-linux-arm64-musl@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz#2b0072adb213f36dada5394ea67d6e82069ae7dd"
integrity sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==
"@next/swc-linux-x64-gnu@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz#68c67d20ebc8e3f6ced6ff23a4ba2a679dbcec32"
integrity sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==
"@next/swc-linux-x64-musl@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz#67cd81b42fb2caf313f7992fcf6d978af55a1247"
integrity sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==
"@next/swc-win32-arm64-msvc@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz#be06585906b195d755ceda28f33c633e1443f1a3"
integrity sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==
"@next/swc-win32-ia32-msvc@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz#e76cabefa9f2d891599c3d85928475bd8d3f6600"
integrity sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==
"@next/swc-win32-x64-msvc@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz#e74892f1a9ccf41d3bf5979ad6d3d77c07b9cba1"
integrity sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==
"@swc/helpers@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d"
integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==
dependencies:
tslib "^2.4.0"
"@types/node@^20":
version "20.11.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f"
integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==
dependencies:
undici-types "~5.26.4"
"@types/prop-types@*":
version "15.7.11"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
"@types/react-dom@^18":
version "18.2.18"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^18":
version "18.2.47"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.47.tgz#85074b27ab563df01fbc3f68dc64bf7050b0af40"
integrity sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.8"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff"
integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==
busboy@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
dependencies:
streamsearch "^1.1.0"
caniuse-lite@^1.0.30001406:
version "1.0.30001576"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz#893be772cf8ee6056d6c1e2d07df365b9ec0a5c4"
integrity sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg==
client-only@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
csstype@^3.0.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
glob-to-regexp@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
graceful-fs@^4.1.2, graceful-fs@^4.2.11:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
nanoid@^3.3.6:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
next@14.0.4:
version "14.0.4"
resolved "https://registry.yarnpkg.com/next/-/next-14.0.4.tgz#bf00b6f835b20d10a5057838fa2dfced1d0d84dc"
integrity sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==
dependencies:
"@next/env" "14.0.4"
"@swc/helpers" "0.5.2"
busboy "1.6.0"
caniuse-lite "^1.0.30001406"
graceful-fs "^4.2.11"
postcss "8.4.31"
styled-jsx "5.1.1"
watchpack "2.4.0"
optionalDependencies:
"@next/swc-darwin-arm64" "14.0.4"
"@next/swc-darwin-x64" "14.0.4"
"@next/swc-linux-arm64-gnu" "14.0.4"
"@next/swc-linux-arm64-musl" "14.0.4"
"@next/swc-linux-x64-gnu" "14.0.4"
"@next/swc-linux-x64-musl" "14.0.4"
"@next/swc-win32-arm64-msvc" "14.0.4"
"@next/swc-win32-ia32-msvc" "14.0.4"
"@next/swc-win32-x64-msvc" "14.0.4"
path2d-polyfill@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz#24c554a738f42700d6961992bf5f1049672f2391"
integrity sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
postcss@8.4.31:
version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
react-dom@^18:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react@^18:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
scheduler@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
dependencies:
loose-envify "^1.1.0"
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
styled-jsx@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f"
integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==
dependencies:
client-only "0.0.1"
tslib@^2.4.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
typescript@^5:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
watchpack@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"

View File

@ -0,0 +1,2 @@
# copied assets
public/*.woff2

View File

@ -11,19 +11,23 @@
<title>React App</title> <title>React App</title>
<script> <script>
window.name = "codesandbox"; window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head> </head>
<body> <body>
<noscript> You need to enable JavaScript to run this app. </noscript> <noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div> <div id="root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.development.js"></script>
<!-- This is so that we use the bundled excalidraw.development.js file instead <!-- This is so that we use the bundled excalidraw.development.js file instead
of the actual source code --> of the actual source code -->
<script src="./excalidraw.development.js"></script> <script type="module">
import * as ExcalidrawLib from "@excalidraw/excalidraw";
<script src="./bundle.js"></script> console.log(ExcalidrawLib);
window.ExcalidrawLib = ExcalidrawLib;
</script>
<script type="module" src="index.tsx"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,28 @@
import App from "../components/App";
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
declare global {
interface Window {
ExcalidrawLib: typeof TExcalidraw;
}
}
const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement);
const { Excalidraw } = window.ExcalidrawLib;
root.render(
<StrictMode>
<App
appTitle={"Excalidraw Example"}
useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={window.ExcalidrawLib}
>
<Excalidraw />
</App>
</StrictMode>,
);

View File

@ -0,0 +1,21 @@
{
"name": "with-script-in-browser",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"@excalidraw/excalidraw": "*"
},
"devDependencies": {
"vite": "5.0.12",
"typescript": "^5"
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,4 +1,4 @@
{ {
"outputDirectory": "example/public", "outputDirectory": "dist",
"installCommand": "yarn install" "installCommand": "yarn install"
} }

View File

@ -0,0 +1,11 @@
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 3001,
// open the browser
open: true,
},
publicDir: "public",
});

View File

@ -0,0 +1,313 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@esbuild/aix-ppc64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz#2acd20be6d4f0458bc8c784103495ff24f13b1d3"
integrity sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==
"@esbuild/android-arm64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz#b45d000017385c9051a4f03e17078abb935be220"
integrity sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==
"@esbuild/android-arm@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.11.tgz#f46f55414e1c3614ac682b29977792131238164c"
integrity sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==
"@esbuild/android-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.11.tgz#bfc01e91740b82011ef503c48f548950824922b2"
integrity sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==
"@esbuild/darwin-arm64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz#533fb7f5a08c37121d82c66198263dcc1bed29bf"
integrity sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==
"@esbuild/darwin-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz#62f3819eff7e4ddc656b7c6815a31cf9a1e7d98e"
integrity sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==
"@esbuild/freebsd-arm64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz#d478b4195aa3ca44160272dab85ef8baf4175b4a"
integrity sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==
"@esbuild/freebsd-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz#7bdcc1917409178257ca6a1a27fe06e797ec18a2"
integrity sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==
"@esbuild/linux-arm64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz#58ad4ff11685fcc735d7ff4ca759ab18fcfe4545"
integrity sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==
"@esbuild/linux-arm@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz#ce82246d873b5534d34de1e5c1b33026f35e60e3"
integrity sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==
"@esbuild/linux-ia32@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz#cbae1f313209affc74b80f4390c4c35c6ab83fa4"
integrity sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==
"@esbuild/linux-loong64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz#5f32aead1c3ec8f4cccdb7ed08b166224d4e9121"
integrity sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==
"@esbuild/linux-mips64el@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz#38eecf1cbb8c36a616261de858b3c10d03419af9"
integrity sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==
"@esbuild/linux-ppc64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz#9c5725a94e6ec15b93195e5a6afb821628afd912"
integrity sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==
"@esbuild/linux-riscv64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz#2dc4486d474a2a62bbe5870522a9a600e2acb916"
integrity sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==
"@esbuild/linux-s390x@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz#4ad8567df48f7dd4c71ec5b1753b6f37561a65a8"
integrity sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==
"@esbuild/linux-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz#b7390c4d5184f203ebe7ddaedf073df82a658766"
integrity sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==
"@esbuild/netbsd-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz#d633c09492a1721377f3bccedb2d821b911e813d"
integrity sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==
"@esbuild/openbsd-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz#17388c76e2f01125bf831a68c03a7ffccb65d1a2"
integrity sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==
"@esbuild/sunos-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz#e320636f00bb9f4fdf3a80e548cb743370d41767"
integrity sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==
"@esbuild/win32-arm64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz#c778b45a496e90b6fc373e2a2bb072f1441fe0ee"
integrity sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==
"@esbuild/win32-ia32@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz#481a65fee2e5cce74ec44823e6b09ecedcc5194c"
integrity sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==
"@esbuild/win32-x64@0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz#a5d300008960bb39677c46bf16f53ec70d8dee04"
integrity sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==
"@rollup/rollup-android-arm-eabi@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.5.tgz#b752b6c88a14ccfcbdf3f48c577ccc3a7f0e66b9"
integrity sha512-idWaG8xeSRCfRq9KpRysDHJ/rEHBEXcHuJ82XY0yYFIWnLMjZv9vF/7DOq8djQ2n3Lk6+3qfSH8AqlmHlmi1MA==
"@rollup/rollup-android-arm64@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.5.tgz#33757c3a448b9ef77b6f6292d8b0ec45c87e9c1a"
integrity sha512-f14d7uhAMtsCGjAYwZGv6TwuS3IFaM4ZnGMUn3aCBgkcHAYErhV1Ad97WzBvS2o0aaDv4mVz+syiN0ElMyfBPg==
"@rollup/rollup-darwin-arm64@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.5.tgz#5234ba62665a3f443143bc8bcea9df2cc58f55fb"
integrity sha512-ndoXeLx455FffL68OIUrVr89Xu1WLzAG4n65R8roDlCoYiQcGGg6MALvs2Ap9zs7AHg8mpHtMpwC8jBBjZrT/w==
"@rollup/rollup-darwin-x64@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.5.tgz#981256c054d3247b83313724938d606798a919d1"
integrity sha512-UmElV1OY2m/1KEEqTlIjieKfVwRg0Zwg4PLgNf0s3glAHXBN99KLpw5A5lrSYCa1Kp63czTpVll2MAqbZYIHoA==
"@rollup/rollup-linux-arm-gnueabihf@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.5.tgz#120678a5a2b3a283a548dbb4d337f9187a793560"
integrity sha512-Q0LcU61v92tQB6ae+udZvOyZ0wfpGojtAKrrpAaIqmJ7+psq4cMIhT/9lfV6UQIpeItnq/2QDROhNLo00lOD1g==
"@rollup/rollup-linux-arm64-gnu@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz#c99d857e2372ece544b6f60b85058ad259f64114"
integrity sha512-dkRscpM+RrR2Ee3eOQmRWFjmV/payHEOrjyq1VZegRUa5OrZJ2MAxBNs05bZuY0YCtpqETDy1Ix4i/hRqX98cA==
"@rollup/rollup-linux-arm64-musl@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.5.tgz#3064060f568a5718c2a06858cd6e6d24f2ff8632"
integrity sha512-QaKFVOzzST2xzY4MAmiDmURagWLFh+zZtttuEnuNn19AiZ0T3fhPyjPPGwLNdiDT82ZE91hnfJsUiDwF9DClIQ==
"@rollup/rollup-linux-riscv64-gnu@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.5.tgz#987d30b5d2b992fff07d055015991a57ff55fbad"
integrity sha512-HeGqmRJuyVg6/X6MpE2ur7GbymBPS8Np0S/vQFHDmocfORT+Zt76qu+69NUoxXzGqVP1pzaY6QIi0FJWLC3OPA==
"@rollup/rollup-linux-x64-gnu@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz#85946ee4d068bd12197aeeec2c6f679c94978a49"
integrity sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==
"@rollup/rollup-linux-x64-musl@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.5.tgz#fe0b20f9749a60eb1df43d20effa96c756ddcbd4"
integrity sha512-ezyFUOwldYpj7AbkwyW9AJ203peub81CaAIVvckdkyH8EvhEIoKzaMFJj0G4qYJ5sw3BpqhFrsCc30t54HV8vg==
"@rollup/rollup-win32-arm64-msvc@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.5.tgz#422661ef0e16699a234465d15b2c1089ef963b2a"
integrity sha512-aHSsMnUw+0UETB0Hlv7B/ZHOGY5bQdwMKJSzGfDfvyhnpmVxLMGnQPGNE9wgqkLUs3+gbG1Qx02S2LLfJ5GaRQ==
"@rollup/rollup-win32-ia32-msvc@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.5.tgz#7b73a145891c202fbcc08759248983667a035d85"
integrity sha512-AiqiLkb9KSf7Lj/o1U3SEP9Zn+5NuVKgFdRIZkvd4N0+bYrTOovVd0+LmYCPQGbocT4kvFyK+LXCDiXPBF3fyA==
"@rollup/rollup-win32-x64-msvc@4.9.5":
version "4.9.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.5.tgz#10491ccf4f63c814d4149e0316541476ea603602"
integrity sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ==
"@types/estree@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
esbuild@^0.19.3:
version "0.19.11"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.11.tgz#4a02dca031e768b5556606e1b468fe72e3325d96"
integrity sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==
optionalDependencies:
"@esbuild/aix-ppc64" "0.19.11"
"@esbuild/android-arm" "0.19.11"
"@esbuild/android-arm64" "0.19.11"
"@esbuild/android-x64" "0.19.11"
"@esbuild/darwin-arm64" "0.19.11"
"@esbuild/darwin-x64" "0.19.11"
"@esbuild/freebsd-arm64" "0.19.11"
"@esbuild/freebsd-x64" "0.19.11"
"@esbuild/linux-arm" "0.19.11"
"@esbuild/linux-arm64" "0.19.11"
"@esbuild/linux-ia32" "0.19.11"
"@esbuild/linux-loong64" "0.19.11"
"@esbuild/linux-mips64el" "0.19.11"
"@esbuild/linux-ppc64" "0.19.11"
"@esbuild/linux-riscv64" "0.19.11"
"@esbuild/linux-s390x" "0.19.11"
"@esbuild/linux-x64" "0.19.11"
"@esbuild/netbsd-x64" "0.19.11"
"@esbuild/openbsd-x64" "0.19.11"
"@esbuild/sunos-x64" "0.19.11"
"@esbuild/win32-arm64" "0.19.11"
"@esbuild/win32-ia32" "0.19.11"
"@esbuild/win32-x64" "0.19.11"
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
postcss@^8.4.32:
version "8.4.33"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742"
integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==
dependencies:
nanoid "^3.3.7"
picocolors "^1.0.0"
source-map-js "^1.0.2"
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
rollup@^4.2.0:
version "4.9.5"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.5.tgz#62999462c90f4c8b5d7c38fc7161e63b29101b05"
integrity sha512-E4vQW0H/mbNMw2yLSqJyjtkHY9dslf/p0zuT1xehNRqUTBOFMqEjguDvqhXr7N7r/4ttb2jr4T41d3dncmIgbQ==
dependencies:
"@types/estree" "1.0.5"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.9.5"
"@rollup/rollup-android-arm64" "4.9.5"
"@rollup/rollup-darwin-arm64" "4.9.5"
"@rollup/rollup-darwin-x64" "4.9.5"
"@rollup/rollup-linux-arm-gnueabihf" "4.9.5"
"@rollup/rollup-linux-arm64-gnu" "4.9.5"
"@rollup/rollup-linux-arm64-musl" "4.9.5"
"@rollup/rollup-linux-riscv64-gnu" "4.9.5"
"@rollup/rollup-linux-x64-gnu" "4.9.5"
"@rollup/rollup-linux-x64-musl" "4.9.5"
"@rollup/rollup-win32-arm64-msvc" "4.9.5"
"@rollup/rollup-win32-ia32-msvc" "4.9.5"
"@rollup/rollup-win32-x64-msvc" "4.9.5"
fsevents "~2.3.2"
scheduler@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
dependencies:
loose-envify "^1.1.0"
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
vite@5.0.6:
version "5.0.6"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.6.tgz#f9e13503a4c5ccd67312c67803dec921f3bdea7c"
integrity sha512-MD3joyAEBtV7QZPl2JVVUai6zHms3YOmLR+BpMzLlX2Yzjfcc4gTgNi09d/Rua3F4EtC8zdwPU8eQYyib4vVMQ==
dependencies:
esbuild "^0.19.3"
postcss "^8.4.32"
rollup "^4.2.0"
optionalDependencies:
fsevents "~2.3.3"

View File

@ -1,6 +1,5 @@
import polyfill from "../packages/excalidraw/polyfill"; import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector"; import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics"; import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState"; import { getDefaultAppState } from "../packages/excalidraw/appState";
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
@ -13,48 +12,47 @@ import {
VERSION_TIMEOUT, VERSION_TIMEOUT,
} from "../packages/excalidraw/constants"; } from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob"; import { loadFromBlob } from "../packages/excalidraw/data/blob";
import { import type {
ExcalidrawElement,
FileId, FileId,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
Theme, OrderedExcalidrawElement,
} from "../packages/excalidraw/element/types"; } from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n"; import { t } from "../packages/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
defaultLang,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog, TTDDialog,
TTDDialogTrigger, TTDDialogTrigger,
} from "../packages/excalidraw/index"; StoreAction,
import { reconcileElements,
} from "../packages/excalidraw";
import type {
AppState, AppState,
LibraryItems,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState, UIAppState,
} from "../packages/excalidraw/types"; } from "../packages/excalidraw/types";
import type { ResolvablePromise } from "../packages/excalidraw/utils";
import { import {
debounce, debounce,
getVersion, getVersion,
getFrame, getFrame,
isTestEnv, isTestEnv,
preventUnload, preventUnload,
ResolvablePromise,
resolvablePromise, resolvablePromise,
isRunningInIframe, isRunningInIframe,
} from "../packages/excalidraw/utils"; } from "../packages/excalidraw/utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS, STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT, SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants"; } from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, { import Collab, {
CollabAPI,
collabAPIAtom, collabAPIAtom,
collabDialogShownAtom,
isCollaboratingAtom, isCollaboratingAtom,
isOfflineAtom, isOfflineAtom,
} from "./collab/Collab"; } from "./collab/Collab";
@ -65,16 +63,12 @@ import {
loadScene, loadScene,
} from "./data"; } from "./data";
import { import {
getLibraryItemsFromStorage,
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { import type { RestoredDataState } from "../packages/excalidraw/data/restore";
restore, import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
import { import {
ExportToExcalidrawPlus, ExportToExcalidrawPlus,
exportToExcalidrawPlus, exportToExcalidrawPlus,
@ -82,11 +76,13 @@ import {
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks"; import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase"; import {
import { LocalData } from "./data/LocalData"; LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx"; import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import { import {
parseLibraryTokensFromUrl, parseLibraryTokensFromUrl,
useHandleLibrary, useHandleLibrary,
@ -94,22 +90,74 @@ import {
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai"; import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai"; import { appJotaiStore } from "./app-jotai";
import { getStorageBackend } from "./data/config"; import { getStorageBackend } from "./data/config";
import "./index.scss"; import "./index.scss";
import { ResolutionType } from "../packages/excalidraw/utility-types"; import type { ResolutionType } from "../packages/excalidraw/utility-types";
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog"; import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../packages/excalidraw/components/Trans"; import Trans from "../packages/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
import {
GithubIcon,
XBrandIcon,
DiscordIcon,
ExcalLogo,
usersIcon,
exportToPlus,
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill(); polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true; window.EXCALIDRAW_THROTTLE_RENDER = true;
declare global {
interface BeforeInstallPromptEventChoiceResult {
outcome: "accepted" | "dismissed";
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
}
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
let pwaEvent: BeforeInstallPromptEvent | null = null;
// Adding a listener outside of the component as it may (?) need to be
// subscribed early to catch the event.
//
// Also note that it will fire only if certain heuristics are met (user has
// used the app for some time, etc.)
window.addEventListener(
"beforeinstallprompt",
(event: BeforeInstallPromptEvent) => {
// prevent Chrome <= 67 from automatically showing the prompt
event.preventDefault();
// cache for later use
pwaEvent = event;
},
);
let isSelfEmbedding = false; let isSelfEmbedding = false;
if (window.self !== window.top) { if (window.self !== window.top) {
@ -124,11 +172,6 @@ if (window.self !== window.top) {
} }
} }
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = { const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"), title: t("overwriteConfirm.modal.shareableLink.title"),
description: ( description: (
@ -252,8 +295,8 @@ const initializeScene = async (opts: {
isLoading: false, isLoading: false,
}, },
elements: reconcileElements( elements: reconcileElements(
scene?.elements || [], (scene?.elements as OrderedExcalidrawElement[]) || [],
excalidrawAPI.getSceneElementsIncludingDeleted(), excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
excalidrawAPI.getAppState(), excalidrawAPI.getAppState(),
), ),
}, },
@ -274,16 +317,15 @@ const initializeScene = async (opts: {
return { scene: null, 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 ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -306,15 +348,18 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] = const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>(); useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href); return isCollaborationLink(window.location.href);
}); });
const collabError = useAtomValue(collabErrorIndicatorAtom);
useHandleLibrary({ useHandleLibrary({
excalidrawAPI, excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage, adapter: LibraryIndexedDBAdapter,
// TODO maybe remove this in several months (shipped: 24-03-11)
migrationAdapter: LibraryLocalStorageMigrationAdapter,
}); });
useEffect(() => { useEffect(() => {
@ -356,21 +401,21 @@ const ExcalidrawWrapper = () => {
if (data.isExternalScene) { if (data.isExternalScene) {
getStorageBackend() getStorageBackend()
.then((storageBackend) => { .then((storageBackend) => {
return storageBackend.loadFilesFromStorageBackend( return storageBackend.loadFilesFromStorageBackend(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`, `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key, data.key,
fileIds, fileIds,
); );
}) })
.then(({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles); excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({ updateStaleImageStatuses({
excalidrawAPI, excalidrawAPI,
erroredFiles, erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(), elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
}); });
});
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
LocalData.fileStorage LocalData.fileStorage
@ -416,7 +461,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...data.scene, ...data.scene,
...restore(data.scene, null, null, { repairBindings: true }), ...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}); });
} }
}); });
@ -440,16 +485,17 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
let langCode = languageDetector.detect() || defaultLang.code; setLangCode(getPreferredLanguage());
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...localDataState, ...localDataState,
storeAction: StoreAction.UPDATE,
}); });
excalidrawAPI.updateLibrary({ LibraryIndexedDBAdapter.load().then((data) => {
libraryItems: getLibraryItemsFromStorage(), if (data) {
excalidrawAPI.updateLibrary({
libraryItems: data.libraryItems,
});
}
}); });
collabAPI?.setUsername(username || ""); collabAPI?.setUsername(username || "");
} }
@ -540,29 +586,8 @@ const ExcalidrawWrapper = () => {
}; };
}, [excalidrawAPI]); }, [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 = ( const onChange = (
elements: readonly ExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
@ -570,8 +595,6 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements); collabAPI.syncElements(elements);
} }
setTheme(appState.theme);
// this check is redundant, but since this is a hot path, it's best // this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time // not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) { if (!LocalData.isSavePaused()) {
@ -597,6 +620,7 @@ const ExcalidrawWrapper = () => {
if (didChange) { if (didChange) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements, elements,
storeAction: StoreAction.UPDATE,
}); });
} }
} }
@ -612,37 +636,38 @@ const ExcalidrawWrapper = () => {
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
canvas: HTMLCanvasElement,
) => { ) => {
if (exportedElements.length === 0) { if (exportedElements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
if (canvas) { try {
try { const { url, errorMessage } = await exportToBackend(
const { url, errorMessage } = await exportToBackend( exportedElements,
exportedElements, {
{ ...appState,
...appState, viewBackgroundColor: appState.exportBackground
viewBackgroundColor: appState.exportBackground ? appState.viewBackgroundColor
? appState.viewBackgroundColor : getDefaultAppState().viewBackgroundColor,
: getDefaultAppState().viewBackgroundColor, },
}, files,
files, );
);
if (errorMessage) { if (errorMessage) {
throw new Error(errorMessage); throw new Error(errorMessage);
} }
if (url) { if (url) {
setLatestShareableLink(url); setLatestShareableLink(url);
} }
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
const { width, height } = canvas; const { width, height } = appState;
console.error(error, { width, height }); console.error(error, {
throw new Error(error.message); width,
} height,
devicePixelRatio: window.devicePixelRatio,
});
throw new Error(error.message);
} }
} }
}; };
@ -660,17 +685,13 @@ const ExcalidrawWrapper = () => {
); );
}; };
const onLibraryChange = async (items: LibraryItems) => {
if (!items.length) {
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
return;
}
const serializedItems = JSON.stringify(items);
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const isOffline = useAtomValue(isOfflineAtom); const isOffline = useAtomValue(isOfflineAtom);
const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState],
);
// browsers generally prevent infinite self-embedding, there are // browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding // cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard // by not whitelisting our own origin, this serves as an additional guard
@ -690,6 +711,45 @@ const ExcalidrawWrapper = () => {
); );
} }
const ExcalidrawPlusCommand = {
label: "Excalidraw+",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: ["plus", "cloud", "server"],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
const ExcalidrawPlusAppCommand = {
label: "Sign up",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: [
"excalidraw",
"plus",
"cloud",
"server",
"signin",
"login",
"signup",
],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
return ( return (
<div <div
style={{ height: "100%" }} style={{ height: "100%" }}
@ -708,27 +768,30 @@ const ExcalidrawWrapper = () => {
toggleTheme: true, toggleTheme: true,
export: { export: {
onExportToBackend, onExportToBackend,
renderCustomUI: (elements, appState, files) => { renderCustomUI: excalidrawAPI
return ( ? (elements, appState, files) => {
<ExportToExcalidrawPlus return (
elements={elements} <ExportToExcalidrawPlus
appState={appState} elements={elements}
files={files} appState={appState}
onError={(error) => { files={files}
excalidrawAPI?.updateScene({ name={excalidrawAPI.getName()}
appState: { onError={(error) => {
errorMessage: error.message, excalidrawAPI?.updateScene({
}, appState: {
}); errorMessage: error.message,
}} },
onSuccess={() => { });
excalidrawAPI?.updateScene({ }}
appState: { openDialog: null }, onSuccess={() => {
}); excalidrawAPI.updateScene({
}} appState: { openDialog: null },
/> });
); }}
}, />
);
}
: undefined,
}, },
}, },
}} }}
@ -736,28 +799,34 @@ const ExcalidrawWrapper = () => {
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
detectScroll={false} detectScroll={false}
handleKeyboardGlobally={true} handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true} autoFocus={true}
theme={theme} theme={editorTheme}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
} }
return ( return (
<LiveCollaborationTrigger <div className="top-right-ui">
isCollaborating={isCollaborating} {collabError.message && <CollabError collabError={collabError} />}
onSelect={() => setCollabDialogShown(true)} <LiveCollaborationTrigger
/> isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
</div>
); );
}} }}
> >
<AppMainMenu <AppMainMenu
setCollabDialogShown={setCollabDialogShown} onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/> />
<AppWelcomeScreen <AppWelcomeScreen
setCollabDialogShown={setCollabDialogShown} onCollabDialogOpen={onCollabDialogOpen}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
/> />
<OverwriteConfirmDialog> <OverwriteConfirmDialog>
@ -772,6 +841,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(), excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(), excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(), excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
); );
}} }}
> >
@ -853,11 +923,219 @@ const ExcalidrawWrapper = () => {
{excalidrawAPI && !isCollabDisabled && ( {excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} /> <Collab excalidrawAPI={excalidrawAPI} />
)} )}
<ShareDialog
collabAPI={collabAPI}
onExportToBackend={async () => {
if (excalidrawAPI) {
try {
await onExportToBackend(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
} catch (error: any) {
setErrorMessage(error.message);
}
}
}}
/>
{errorMessage && ( {errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}> <ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage} {errorMessage}
</ErrorDialog> </ErrorDialog>
)} )}
<CommandPalette
customCommandPaletteItems={[
{
label: t("labels.liveCollaboration"),
category: DEFAULT_CATEGORIES.app,
keywords: [
"team",
"multiplayer",
"share",
"public",
"session",
"invite",
],
icon: usersIcon,
perform: () => {
setShareDialogState({
isOpen: true,
type: "collaborationOnly",
});
},
},
{
label: t("roomDialog.button_stopSession"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!collabAPI?.isCollaborating(),
keywords: [
"stop",
"session",
"end",
"leave",
"close",
"exit",
"collaboration",
],
perform: () => {
if (collabAPI) {
collabAPI.stopCollaboration();
if (!collabAPI.isCollaborating()) {
setShareDialogState({ isOpen: false });
}
}
},
},
{
label: t("labels.share"),
category: DEFAULT_CATEGORIES.app,
predicate: true,
icon: share,
keywords: [
"link",
"shareable",
"readonly",
"export",
"publish",
"snapshot",
"url",
"collaborate",
"invite",
],
perform: async () => {
setShareDialogState({ isOpen: true, type: "share" });
},
},
{
label: "GitHub",
icon: GithubIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: [
"issues",
"bugs",
"requests",
"report",
"features",
"social",
"community",
],
perform: () => {
window.open(
"https://github.com/excalidraw/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.followUs"),
icon: XBrandIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["twitter", "contact", "social", "community"],
perform: () => {
window.open(
"https://x.com/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.discordChat"),
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: DiscordIcon,
keywords: [
"chat",
"talk",
"contact",
"bugs",
"requests",
"report",
"feedback",
"suggestions",
"social",
"community",
],
perform: () => {
window.open(
"https://discord.gg/UexuTaE",
"_blank",
"noopener noreferrer",
);
},
},
{
label: "YouTube",
icon: youtubeIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["features", "tutorials", "howto", "help", "community"],
perform: () => {
window.open(
"https://youtube.com/@excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
...(isExcalidrawPlusSignedUser
? [
{
...ExcalidrawPlusAppCommand,
label: "Sign in / Go to Excalidraw+",
},
]
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
{
label: t("overwriteConfirm.action.excalidrawPlus.button"),
category: DEFAULT_CATEGORIES.export,
icon: exportToPlus,
predicate: true,
keywords: ["plus", "export", "save", "backup"],
perform: () => {
if (excalidrawAPI) {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!pwaEvent,
perform: () => {
if (pwaEvent) {
pwaEvent.prompt();
pwaEvent.userChoice.then(() => {
// event cannot be reused, but we'll hopefully
// grab new one as the event should be fired again
pwaEvent = null;
});
}
},
},
]}
/>
</Excalidraw> </Excalidraw>
</div> </div>
); );

View File

@ -7,8 +7,9 @@ import {
import { DEFAULT_VERSION } from "../packages/excalidraw/constants"; import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
import { t } from "../packages/excalidraw/i18n"; import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import { UIAppState } from "../packages/excalidraw/types"; import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
} }
return ( return (
<> <Stats.StatsRows order={-1}>
<tr> <Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<th colSpan={2}>{t("stats.storage")}</th> <Stats.StatsRow
</tr> style={{ textAlign: "center", cursor: "pointer" }}
<tr> onClick={async () => {
<td>{t("stats.scene")}</td> try {
<td>{nFormatter(storageSizes.scene, 1)}</td> await copyTextToSystemClipboard(getVersion());
</tr> props.setToast(t("toast.copyToClipboard"));
<tr> } catch {}
<td>{t("stats.total")}</td> }}
<td>{nFormatter(storageSizes.total, 1)}</td> title={t("stats.versionCopy")}
</tr> >
<tr> {timestamp}
<th colSpan={2}>{t("stats.version")}</th> <br />
</tr> {hash}
<tr> </Stats.StatsRow>
<td
colSpan={2} <Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
style={{ textAlign: "center", cursor: "pointer" }} <Stats.StatsRow columns={2}>
onClick={async () => { <div>{t("stats.scene")}</div>
try { <div>{nFormatter(storageSizes.scene, 1)}</div>
await copyTextToSystemClipboard(getVersion()); </Stats.StatsRow>
props.setToast(t("toast.copyToClipboard")); <Stats.StatsRow columns={2}>
} catch {} <div>{t("stats.total")}</div>
}} <div>{nFormatter(storageSizes.total, 1)}</div>
title={t("stats.versionCopy")} </Stats.StatsRow>
> </Stats.StatsRows>
{timestamp}
<br />
{hash}
</td>
</tr>
</>
); );
}; };

View File

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

View File

@ -0,0 +1,25 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};

View File

@ -0,0 +1,15 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};

View File

@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
LOCAL_STORAGE_THEME: "excalidraw-theme", LOCAL_STORAGE_THEME: "excalidraw-theme",
VERSION_DATA_STATE: "version-dataState", VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const; } as const;
export const COOKIES = { export const COOKIES = {

View File

@ -1,28 +1,30 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import { import type {
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
SocketId, SocketId,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { import type {
ExcalidrawElement, ExcalidrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { import {
StoreAction,
getSceneVersion, getSceneVersion,
restoreElements, restoreElements,
zoomToFitBounds, zoomToFitBounds,
} from "../../packages/excalidraw/index"; reconcileElements,
import { Collaborator, Gesture } from "../../packages/excalidraw/types"; } from "../../packages/excalidraw";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
import { import {
assertNever, assertNever,
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
throttleRAF, throttleRAF,
withBatchedUpdates,
} from "../../packages/excalidraw/utils"; } from "../../packages/excalidraw/utils";
import { import {
CURSOR_SYNC_TIMEOUT, CURSOR_SYNC_TIMEOUT,
@ -34,27 +36,21 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS, WS_EVENTS,
} from "../app_constants"; } from "../app_constants";
import { import type {
generateCollaborationLinkData,
getCollaborationLink,
getCollabServer,
getSyncableElements,
SocketUpdateDataSource, SocketUpdateDataSource,
SyncableExcalidrawElement, SyncableExcalidrawElement,
} from "../data"; } from "../data";
import { import {
isSavedToFirebase, generateCollaborationLinkData,
loadFilesFromFirebase, getCollaborationLink,
loadFromFirebase, getSyncableElements,
saveFilesToFirebase, } from "../data";
saveToFirebase, import { isSavedToFirebase } from "../data/firebase";
} from "../data/firebase";
import { import {
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
saveUsernameToLocalStorage, saveUsernameToLocalStorage,
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { t } from "../../packages/excalidraw/i18n"; import { t } from "../../packages/excalidraw/i18n";
import { UserIdleState } from "../../packages/excalidraw/types"; import { UserIdleState } from "../../packages/excalidraw/types";
import { import {
@ -72,30 +68,35 @@ import {
isInitializedImageElement, isInitializedImageElement,
} from "../../packages/excalidraw/element/typeChecks"; } from "../../packages/excalidraw/element/typeChecks";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { decryptData } from "../../packages/excalidraw/data/encryption"; import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai"; import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai"; import { appJotaiStore } from "../app-jotai";
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { getStorageBackend } from "../data/config"; import { getStorageBackend } from "../data/config";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "../../packages/excalidraw/data/reconcile";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
export const isCollaboratingAtom = atom(false); export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false); export const isOfflineAtom = atom(false);
interface CollabState { interface CollabState {
errorMessage: string; errorMessage: string | null;
/** errors related to saving */
dialogNotifiedErrors: Record<string, boolean>;
username: string; username: string;
activeRoomLink: string; activeRoomLink: string | null;
} }
export const activeRoomLinkAtom = atom<string | null>(null);
type CollabInstance = InstanceType<typeof Collab>; type CollabInstance = InstanceType<typeof Collab>;
export interface CollabAPI { export interface CollabAPI {
@ -106,19 +107,20 @@ export interface CollabAPI {
stopCollaboration: CollabInstance["stopCollaboration"]; stopCollaboration: CollabInstance["stopCollaboration"];
syncElements: CollabInstance["syncElements"]; syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void; setUsername: CollabInstance["setUsername"];
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setCollabError: CollabInstance["setErrorDialog"];
} }
interface PublicProps { interface CollabProps {
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
} }
type Props = PublicProps & { modalIsShown: boolean }; class Collab extends PureComponent<CollabProps, CollabState> {
class Collab extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
fileManager: FileManager; fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: CollabProps["excalidrawAPI"];
activeIntervalId: number | null; activeIntervalId: number | null;
idleTimeoutId: number | null; idleTimeoutId: number | null;
@ -126,12 +128,13 @@ class Collab extends PureComponent<Props, CollabState> {
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<SocketId, Collaborator>(); private collaborators = new Map<SocketId, Collaborator>();
constructor(props: Props) { constructor(props: CollabProps) {
super(props); super(props);
this.state = { this.state = {
errorMessage: "", errorMessage: null,
dialogNotifiedErrors: {},
username: importUsernameFromLocalStorage() || "", username: importUsernameFromLocalStorage() || "",
activeRoomLink: "", activeRoomLink: null,
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.fileManager = new FileManager({ this.fileManager = new FileManager({
@ -202,6 +205,9 @@ class Collab extends PureComponent<Props, CollabState> {
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
stopCollaboration: this.stopCollaboration, stopCollaboration: this.stopCollaboration,
setUsername: this.setUsername, setUsername: this.setUsername,
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setCollabError: this.setErrorDialog,
}; };
appJotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
@ -275,20 +281,39 @@ class Collab extends PureComponent<Props, CollabState> {
) => { ) => {
try { try {
const storageBackend = await getStorageBackend(); const storageBackend = await getStorageBackend();
const savedData = await storageBackend.saveToStorageBackend(this.portal, syncableElements, this.excalidrawAPI.getAppState()); const storedElements = await storageBackend.saveToStorageBackend(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
if (this.isCollaborating() && savedData && savedData.reconciledElements) { this.resetErrorIndicator();
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements), if (this.isCollaborating() && storedElements) {
); this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
} }
} catch (error: any) { } catch (error: any) {
this.setState({ const errorMessage = /is longer than.*?bytes/.test(error.message)
// firestore doesn't return a specific error code when size exceeded ? t("errors.collabSaveFailed_sizeExceeded")
errorMessage: /is longer than.*?bytes/.test(error.message) : t("errors.collabSaveFailed");
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"), if (
}); !this.state.dialogNotifiedErrors[errorMessage] ||
!this.isCollaborating()
) {
this.setErrorDialog(errorMessage);
this.setState({
dialogNotifiedErrors: {
...this.state.dialogNotifiedErrors,
[errorMessage]: true,
},
});
}
if (this.isCollaborating()) {
this.setErrorIndicator(errorMessage);
}
console.error(error); console.error(error);
} }
}; };
@ -297,6 +322,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.queueBroadcastAllElements.cancel(); this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel(); this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel(); this.loadImageFiles.cancel();
this.resetErrorIndicator(true);
this.saveCollabRoomToFirebase( this.saveCollabRoomToFirebase(
getSyncableElements( getSyncableElements(
@ -335,7 +361,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: false, storeAction: StoreAction.UPDATE,
}); });
} }
}; };
@ -346,9 +372,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.fileManager.reset(); this.fileManager.reset();
if (!opts?.isUnload) { if (!opts?.isUnload) {
this.setIsCollaborating(false); this.setIsCollaborating(false);
this.setState({ this.setActiveRoomLink(null);
activeRoomLink: "",
});
this.collaborators = new Map(); this.collaborators = new Map();
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
collaborators: this.collaborators, collaborators: this.collaborators,
@ -410,11 +434,11 @@ class Collab extends PureComponent<Props, CollabState> {
startCollaboration = async ( startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string }, existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => { ) => {
if (!this.state.username) { if (!this.state.username) {
import("@excalidraw/random-username").then(({ getRandomUsername }) => { import("@excalidraw/random-username").then(({ getRandomUsername }) => {
const username = getRandomUsername(); const username = getRandomUsername();
this.onUsernameChange(username); this.setUsername(username);
}); });
} }
@ -436,7 +460,12 @@ class Collab extends PureComponent<Props, CollabState> {
); );
} }
const scenePromise = resolvablePromise<ImportedDataState | null>(); // TODO: `ImportedDataState` type here seems abused
const scenePromise = resolvablePromise<
| ImportedDataState
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
| null
>();
this.setIsCollaborating(true); this.setIsCollaborating(true);
LocalData.pauseSave("collaboration"); LocalData.pauseSave("collaboration");
@ -456,13 +485,9 @@ class Collab extends PureComponent<Props, CollabState> {
this.fallbackInitializationHandler = fallbackInitializationHandler; this.fallbackInitializationHandler = fallbackInitializationHandler;
try { try {
const socketServerData = await getCollabServer();
this.portal.socket = this.portal.open( this.portal.socket = this.portal.open(
socketIOClient(socketServerData.url, { socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
transports: socketServerData.polling transports: ["websocket", "polling"],
? ["websocket", "polling"]
: ["websocket"],
}), }),
roomId, roomId,
roomKey, roomKey,
@ -471,7 +496,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.portal.socket.once("connect_error", fallbackInitializationHandler); this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
this.setState({ errorMessage: error.message }); this.setErrorDialog(error.message);
return null; return null;
} }
@ -482,14 +507,13 @@ class Collab extends PureComponent<Props, CollabState> {
} }
return element; return element;
}); });
// remove deleted elements from elements array & history to ensure we don't // remove deleted elements from elements array to ensure we don't
// expose potentially sensitive user data in case user manually deletes // expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted // existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room. // to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: true, storeAction: StoreAction.UPDATE,
}); });
this.saveCollabRoomToFirebase(getSyncableElements(elements)); this.saveCollabRoomToFirebase(getSyncableElements(elements));
@ -523,10 +547,9 @@ class Collab extends PureComponent<Props, CollabState> {
if (!this.portal.socketInitialized) { if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false }); this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements; const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements); const reconciledElements =
this.handleRemoteSceneUpdate(reconciledElements, { this._reconcileElements(remoteElements);
init: true, this.handleRemoteSceneUpdate(reconciledElements);
});
// noop if already resolved via init from firebase // noop if already resolved via init from firebase
scenePromise.resolve({ scenePromise.resolve({
elements: reconciledElements, elements: reconciledElements,
@ -537,7 +560,7 @@ class Collab extends PureComponent<Props, CollabState> {
} }
case WS_SUBTYPES.UPDATE: case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate( this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements), this._reconcileElements(decryptedData.payload.elements),
); );
break; break;
case WS_SUBTYPES.MOUSE_LOCATION: { case WS_SUBTYPES.MOUSE_LOCATION: {
@ -633,9 +656,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.initializeIdleDetector(); this.initializeIdleDetector();
this.setState({ this.setActiveRoomLink(window.location.href);
activeRoomLink: window.location.href,
});
return scenePromise; return scenePromise;
}; };
@ -662,10 +683,10 @@ class Collab extends PureComponent<Props, CollabState> {
try { try {
const storageBackend = await getStorageBackend(); const storageBackend = await getStorageBackend();
const elements = await storageBackend.loadFromStorageBackend( const elements = await storageBackend.loadFromStorageBackend(
roomLinkData.roomId, roomLinkData.roomId,
roomLinkData.roomKey, roomLinkData.roomKey,
this.portal.socket, this.portal.socket,
); );
if (elements) { if (elements) {
this.setLastBroadcastedOrReceivedSceneVersion( this.setLastBroadcastedOrReceivedSceneVersion(
getSceneVersion(elements), getSceneVersion(elements),
@ -688,17 +709,15 @@ class Collab extends PureComponent<Props, CollabState> {
return null; return null;
}; };
private reconcileElements = ( private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[], remoteElements: readonly ExcalidrawElement[],
): ReconciledElements => { ): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted(); const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(remoteElements, null);
remoteElements = restoreElements(remoteElements, null); const reconciledElements = reconcileElements(
const reconciledElements = _reconcileElements(
localElements, localElements,
remoteElements, restoredRemoteElements as RemoteExcalidrawElement[],
appState, appState,
); );
@ -729,20 +748,13 @@ class Collab extends PureComponent<Props, CollabState> {
}, LOAD_IMAGES_TIMEOUT); }, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = ( private handleRemoteSceneUpdate = (
elements: ReconciledElements, elements: ReconciledExcalidrawElement[],
{ init = false }: { init?: boolean } = {},
) => { ) => {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: !!init, storeAction: StoreAction.UPDATE,
}); });
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles(); this.loadImageFiles();
}; };
@ -875,7 +887,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.portal.broadcastIdleChange(userState); this.portal.broadcastIdleChange(userState);
}; };
broadcastElements = (elements: readonly ExcalidrawElement[]) => { broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
if ( if (
getSceneVersion(elements) > getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion() this.getLastBroadcastedOrReceivedSceneVersion()
@ -886,7 +898,7 @@ class Collab extends PureComponent<Props, CollabState> {
} }
}; };
syncElements = (elements: readonly ExcalidrawElement[]) => { syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
this.broadcastElements(elements); this.broadcastElements(elements);
this.queueSaveToFirebase(); this.queueSaveToFirebase();
}; };
@ -919,41 +931,49 @@ class Collab extends PureComponent<Props, CollabState> {
{ leading: false }, { leading: false },
); );
handleClose = () => {
appJotaiStore.set(collabDialogShownAtom, false);
};
setUsername = (username: string) => { setUsername = (username: string) => {
this.setState({ username }); this.setState({ username });
};
onUsernameChange = (username: string) => {
this.setUsername(username);
saveUsernameToLocalStorage(username); saveUsernameToLocalStorage(username);
}; };
render() { getUsername = () => this.state.username;
const { username, errorMessage, activeRoomLink } = this.state;
const { modalIsShown } = this.props; setActiveRoomLink = (activeRoomLink: string | null) => {
this.setState({ activeRoomLink });
appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
};
getActiveRoomLink = () => this.state.activeRoomLink;
setErrorIndicator = (errorMessage: string | null) => {
appJotaiStore.set(collabErrorIndicatorAtom, {
message: errorMessage,
nonce: Date.now(),
});
};
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
if (resetDialogNotifiedErrors) {
this.setState({
dialogNotifiedErrors: {},
});
}
};
setErrorDialog = (errorMessage: string | null) => {
this.setState({
errorMessage,
});
};
render() {
const { errorMessage } = this.state;
return ( return (
<> <>
{modalIsShown && ( {errorMessage != null && (
<RoomDialog <ErrorDialog onClose={() => this.setErrorDialog(null)}>
handleClose={this.handleClose}
activeRoomLink={activeRoomLink}
username={username}
onUsernameChange={this.onUsernameChange}
onRoomCreate={() => this.startCollaboration(null)}
onRoomDestroy={this.stopCollaboration}
setErrorMessage={(errorMessage) => {
this.setState({ errorMessage });
}}
/>
)}
{errorMessage && (
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
{errorMessage} {errorMessage}
</ErrorDialog> </ErrorDialog>
)} )}
@ -972,11 +992,6 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.collab = window.collab || ({} as Window["collab"]); window.collab = window.collab || ({} as Window["collab"]);
} }
const _Collab: React.FC<PublicProps> = (props) => { export default Collab;
const [collabDialogShown] = useAtom(collabDialogShownAtom);
return <Collab {...props} modalIsShown={collabDialogShown} />;
};
export default _Collab;
export type TCollabClass = Collab; export type TCollabClass = Collab;

View File

@ -0,0 +1,35 @@
@import "../../packages/excalidraw/css/variables.module.scss";
.excalidraw {
.collab-errors-button {
width: 26px;
height: 26px;
margin-inline-end: 1rem;
color: var(--color-danger);
flex-shrink: 0;
}
.collab-errors-button-shake {
animation: strong-shake 0.15s 6;
}
@keyframes strong-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
}

View File

@ -0,0 +1,54 @@
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = {
message: string | null;
/** used to rerun the useEffect responsible for animation */
nonce: number;
};
export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
message: null,
nonce: 0,
});
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
const [isAnimating, setIsAnimating] = useState(false);
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
useEffect(() => {
setIsAnimating(true);
clearAnimationRef.current = setTimeout(() => {
setIsAnimating(false);
}, 1000);
return () => {
clearTimeout(clearAnimationRef.current);
};
}, [collabError.message, collabError.nonce]);
if (!collabError.message) {
return null;
}
return (
<Tooltip label={collabError.message} long={true}>
<div
className={clsx("collab-errors-button", {
"collab-errors-button-shake": isAnimating,
})}
>
{warning}
</div>
</Tooltip>
);
};
CollabError.displayName = "CollabError";
export default CollabError;

View File

@ -1,14 +1,15 @@
import { import type {
isSyncableElement,
SocketUpdateData, SocketUpdateData,
SocketUpdateDataSource, SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data"; } from "../data";
import { isSyncableElement } from "../data";
import { TCollabClass } from "./Collab"; import type { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { import type {
OnUserFollowedPayload, OnUserFollowedPayload,
SocketId, SocketId,
UserIdleState, UserIdleState,
@ -16,10 +17,9 @@ import {
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../packages/excalidraw/analytics";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../packages/excalidraw/data/encryption"; import { encryptData } from "../../packages/excalidraw/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw";
class Portal { class Portal {
collab: TCollabClass; collab: TCollabClass;
@ -128,12 +128,13 @@ class Portal {
} }
return element; return element;
}), }),
storeAction: StoreAction.UPDATE,
}); });
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);
broadcastScene = async ( broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
allElements: readonly ExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
syncAll: boolean, syncAll: boolean,
) => { ) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) { if (updateType === WS_SUBTYPES.INIT && !syncAll) {
@ -143,25 +144,17 @@ class Portal {
// sync out only the elements we think we need to to save bandwidth. // sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges // periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc). // due to a dropped message (server goes down etc).
const syncableElements = allElements.reduce( const syncableElements = elements.reduce((acc, element) => {
(acc, element: BroadcastedExcalidrawElement, idx, elements) => { if (
if ( (syncAll ||
(syncAll || !this.broadcastedElementVersions.has(element.id) ||
!this.broadcastedElementVersions.has(element.id) || element.version > this.broadcastedElementVersions.get(element.id)!) &&
element.version > isSyncableElement(element)
this.broadcastedElementVersions.get(element.id)!) && ) {
isSyncableElement(element) acc.push(element);
) { }
acc.push({ return acc;
...element, }, [] as SyncableExcalidrawElement[]);
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
[] as BroadcastedExcalidrawElement[],
);
const data: SocketUpdateDataSource[typeof updateType] = { const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType, type: updateType,

View File

@ -65,19 +65,18 @@ export const RoomModal = ({
const copyRoomLink = async () => { const copyRoomLink = async () => {
try { try {
await copyTextToSystemClipboard(activeRoomLink); await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
setJustCopied(true); setErrorMessage(t("errors.copyToSystemClipboardFailed"));
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
} }
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select(); ref.current?.select();
}; };
@ -120,7 +119,7 @@ export const RoomModal = ({
size="large" size="large"
variant="icon" variant="icon"
label="Share" label="Share"
startIcon={getShareIcon()} icon={getShareIcon()}
className="RoomDialog__active__share" className="RoomDialog__active__share"
onClick={shareRoomLink} onClick={shareRoomLink}
/> />
@ -130,7 +129,7 @@ export const RoomModal = ({
<FilledButton <FilledButton
size="large" size="large"
label="Copy link" label="Copy link"
startIcon={copyIcon} icon={copyIcon}
onClick={copyRoomLink} onClick={copyRoomLink}
/> />
</Popover.Trigger> </Popover.Trigger>
@ -166,7 +165,7 @@ export const RoomModal = ({
variant="outlined" variant="outlined"
color="danger" color="danger"
label={t("roomDialog.button_stopSession")} label={t("roomDialog.button_stopSession")}
startIcon={playerStopFilledIcon} icon={playerStopFilledIcon}
onClick={() => { onClick={() => {
trackEvent("share", "room closed"); trackEvent("share", "room closed");
onRoomDestroy(); onRoomDestroy();
@ -195,7 +194,7 @@ export const RoomModal = ({
<FilledButton <FilledButton
size="large" size="large"
label={t("roomDialog.button_startSession")} label={t("roomDialog.button_startSession")}
startIcon={playerPlayIcon} icon={playerPlayIcon}
onClick={() => { onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`); trackEvent("share", "room creation", `ui (${getFrame()})`);
onRoomCreate(); onRoomCreate();

View File

@ -1,154 +0,0 @@
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: ExcalidrawElement | undefined,
remote: BroadcastedExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
}
continue;
}
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) {
// Unless the remote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue;
}
duplicates.set(local[0], true);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement[PRECEDING_ELEMENT_KEY] ||
remoteElements[remoteElementIdx - 1]?.id ||
null;
if (parent != null) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
}
} else {
let idx = localElementsData.has(parent)
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return ret as ReconciledElements;
};

View File

@ -1,12 +1,19 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; import {
loginIcon,
ExcalLogo,
} from "../../packages/excalidraw/components/icons";
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../packages/excalidraw/index";
import { LanguageList } from "./LanguageList"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; onCollabDialogOpen: () => any;
isCollaborating: boolean; isCollaborating: boolean;
isCollabEnabled: boolean; isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
<MainMenu> <MainMenu>
@ -17,25 +24,38 @@ export const AppMainMenu: React.FC<{
{props.isCollabEnabled && ( {props.isCollabEnabled && (
<MainMenu.DefaultItems.LiveCollaborationTrigger <MainMenu.DefaultItems.LiveCollaborationTrigger
isCollaborating={props.isCollaborating} isCollaborating={props.isCollaborating}
onSelect={() => props.setCollabDialogShown(true)} onSelect={() => props.onCollabDialogOpen()}
/> />
)} )}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.ItemLink <MainMenu.ItemLink
icon={PlusPromoIcon} icon={ExcalLogo}
href={`${ href={`${
import.meta.env.VITE_APP_PLUS_LP import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`} }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className="ExcalidrawPlus" className=""
> >
Excalidraw+ Excalidraw+
</MainMenu.ItemLink> </MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
<MainMenu.ItemLink
icon={loginIcon}
href={`${import.meta.env.VITE_APP_PLUS_APP}${
isExcalidrawPlusSignedUser ? "" : "/sign-up"
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
className="highlighted"
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme /> <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} /> <LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom> </MainMenu.ItemCustom>

View File

@ -1,12 +1,12 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; import { loginIcon } from "../../packages/excalidraw/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n"; import { useI18n } from "../../packages/excalidraw/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index"; import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "../../packages/excalidraw/constants"; import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; onCollabDialogOpen: () => any;
isCollabEnabled: boolean; isCollabEnabled: boolean;
}> = React.memo((props) => { }> = React.memo((props) => {
const { t } = useI18n(); const { t } = useI18n();
@ -52,7 +52,7 @@ export const AppWelcomeScreen: React.FC<{
<WelcomeScreen.Center.MenuItemHelp /> <WelcomeScreen.Center.MenuItemHelp />
{props.isCollabEnabled && ( {props.isCollabEnabled && (
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
onSelect={() => props.setCollabDialogShown(true)} onSelect={() => props.onCollabDialogOpen()}
/> />
)} )}
{!isExcalidrawPlusSignedUser && ( {!isExcalidrawPlusSignedUser && (
@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
import.meta.env.VITE_APP_PLUS_LP import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`} }/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
shortcut={null} shortcut={null}
icon={PlusPromoIcon} icon={loginIcon}
> >
Try Excalidraw Plus! Sign up
</WelcomeScreen.Center.MenuItemLink> </WelcomeScreen.Center.MenuItemLink>
)} )}
</WelcomeScreen.Center.Menu> </WelcomeScreen.Center.Menu>

View File

@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
import { ToolButton } from "../../packages/excalidraw/components/ToolButton"; import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { import type {
FileId, FileId,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
name: string,
) => { ) => {
const firebase = await loadFirebaseStorage(); const firebase = await loadFirebaseStorage();
@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
.ref(`/migrations/scenes/${id}`) .ref(`/migrations/scenes/${id}`)
.put(blob, { .put(blob, {
customMetadata: { customMetadata: {
data: JSON.stringify({ version: 2, name: appState.name }), data: JSON.stringify({ version: 2, name }),
created: Date.now().toString(), created: Date.now().toString(),
}, },
}); });
@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: Partial<AppState>; appState: Partial<AppState>;
files: BinaryFiles; files: BinaryFiles;
name: string;
onError: (error: Error) => void; onError: (error: Error) => void;
onSuccess: () => void; onSuccess: () => void;
}> = ({ elements, appState, files, onError, onSuccess }) => { }> = ({ elements, appState, files, name, onError, onSuccess }) => {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Card color="primary"> <Card color="primary">
@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
onClick={async () => { onClick={async () => {
try { try {
trackEvent("export", "eplus", `ui (${getFrame()})`); trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files); await exportToExcalidrawPlus(elements, appState, files, name);
onSuccess(); onSuccess();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);

View File

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

View File

@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
window.open( window.open(
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`, `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
"_blank",
"noopener noreferrer",
); );
} }

View File

@ -1,14 +1,15 @@
import { StoreAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode"; import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
FileId, FileId,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n"; import { t } from "../../packages/excalidraw/i18n";
import { import type {
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
} }
return element; return element;
}), }),
storeAction: StoreAction.UPDATE,
}); });
}; };

View File

@ -10,18 +10,29 @@
* (localStorage, indexedDB). * (localStorage, indexedDB).
*/ */
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import { import {
createStore,
entries,
del,
getMany,
set,
setMany,
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import type {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils"; import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
@ -183,3 +194,52 @@ export class LocalData {
}, },
}); });
} }
export class LibraryIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
/** library data store key */
private static key = "libraryData";
private static store = createStore(
`${LibraryIndexedDBAdapter.idb_name}-db`,
`${LibraryIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<LibraryPersistedData>(
LibraryIndexedDBAdapter.key,
LibraryIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: LibraryPersistedData): MaybePromise<void> {
return set(
LibraryIndexedDBAdapter.key,
data,
LibraryIndexedDBAdapter.store,
);
}
}
/** LS Adapter used only for migrating LS library data
* to indexedDB */
export class LibraryLocalStorageMigrationAdapter {
static load() {
const LSData = localStorage.getItem(
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
);
if (LSData != null) {
const libraryItems: ImportedDataState["libraryItems"] =
JSON.parse(LSData);
if (libraryItems) {
return { libraryItems };
}
}
return null;
}
static clear() {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}

View File

@ -1,7 +1,10 @@
import { SyncableExcalidrawElement } from "."; import type { SyncableExcalidrawElement } from ".";
import { ExcalidrawElement, FileId } from "../../packages/excalidraw/element/types"; import type {
import { AppState, BinaryFileData } from "../../packages/excalidraw/types"; ExcalidrawElement,
import Portal from "../collab/Portal"; FileId,
} from "../../packages/excalidraw/element/types";
import type { AppState, BinaryFileData } from "../../packages/excalidraw/types";
import type Portal from "../collab/Portal";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
export interface StorageBackend { export interface StorageBackend {
@ -10,7 +13,7 @@ export interface StorageBackend {
portal: Portal, portal: Portal,
elements: readonly SyncableExcalidrawElement[], elements: readonly SyncableExcalidrawElement[],
appState: AppState, appState: AppState,
) => Promise<false | { reconciledElements: any }>; ) => Promise<false | readonly SyncableExcalidrawElement[] | null>;
loadFromStorageBackend: ( loadFromStorageBackend: (
roomId: string, roomId: string,
roomKey: string, roomKey: string,

View File

@ -1,54 +1,54 @@
import { import {
isSavedToFirebase, isSavedToFirebase,
loadFilesFromFirebase, loadFilesFromFirebase,
loadFromFirebase, loadFromFirebase,
saveFilesToFirebase, saveFilesToFirebase,
saveToFirebase, saveToFirebase,
} from "./firebase"; } from "./firebase";
import { import {
isSavedToHttpStorage, isSavedToHttpStorage,
loadFilesFromHttpStorage, loadFilesFromHttpStorage,
loadFromHttpStorage, loadFromHttpStorage,
saveFilesToHttpStorage, saveFilesToHttpStorage,
saveToHttpStorage, saveToHttpStorage,
} from "./httpStorage"; } from "./httpStorage";
import { StorageBackend } from "./StorageBackend"; import type { StorageBackend } from "./StorageBackend";
const firebaseStorage: StorageBackend = { const firebaseStorage: StorageBackend = {
isSaved: isSavedToFirebase, isSaved: isSavedToFirebase,
saveToStorageBackend: saveToFirebase, saveToStorageBackend: saveToFirebase,
loadFromStorageBackend: loadFromFirebase, loadFromStorageBackend: loadFromFirebase,
saveFilesToStorageBackend: saveFilesToFirebase, saveFilesToStorageBackend: saveFilesToFirebase,
loadFilesFromStorageBackend: loadFilesFromFirebase, loadFilesFromStorageBackend: loadFilesFromFirebase,
}; };
const httpStorage: StorageBackend = { const httpStorage: StorageBackend = {
isSaved: isSavedToHttpStorage, isSaved: isSavedToHttpStorage,
saveToStorageBackend: saveToHttpStorage, saveToStorageBackend: saveToHttpStorage,
loadFromStorageBackend: loadFromHttpStorage, loadFromStorageBackend: loadFromHttpStorage,
saveFilesToStorageBackend: saveFilesToHttpStorage, saveFilesToStorageBackend: saveFilesToHttpStorage,
loadFilesFromStorageBackend: loadFilesFromHttpStorage, loadFilesFromStorageBackend: loadFilesFromHttpStorage,
}; };
const storageBackends = new Map<string, StorageBackend>() const storageBackends = new Map<string, StorageBackend>()
.set("firebase", firebaseStorage) .set("firebase", firebaseStorage)
.set("http", httpStorage); .set("http", httpStorage);
export let storageBackend: StorageBackend | null = null; export let storageBackend: StorageBackend | null = null;
export async function getStorageBackend() {
if (storageBackend) {
return storageBackend;
}
const storageBackendName = import.meta.env.VITE_APP_STORAGE_BACKEND || '';
if (storageBackends.has(storageBackendName)) {
storageBackend = storageBackends.get(storageBackendName) as StorageBackend;
} else {
console.warn("No storage backend found, default to firebase");
storageBackend = firebaseStorage;
}
export async function getStorageBackend() {
if (storageBackend) {
return storageBackend; return storageBackend;
} }
const storageBackendName = import.meta.env.VITE_APP_STORAGE_BACKEND || "";
if (storageBackends.has(storageBackendName)) {
storageBackend = storageBackends.get(storageBackendName) as StorageBackend;
} else {
console.warn("No storage backend found, default to firebase");
storageBackend = firebaseStorage;
}
return storageBackend;
}

View File

@ -1,11 +1,13 @@
import { import { reconcileElements } from "../../packages/excalidraw";
import type {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element"; import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal"; import type Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore"; import { restoreElements } from "../../packages/excalidraw/data/restore";
import { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
@ -18,10 +20,11 @@ import {
decryptData, decryptData,
} from "../../packages/excalidraw/data/encryption"; } from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation"; import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements, SyncableExcalidrawElement } from "."; import { getSyncableElements } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types"; import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -232,7 +235,7 @@ export const saveToFirebase = async (
!socket || !socket ||
isSavedToFirebase(portal, elements) isSavedToFirebase(portal, elements)
) { ) {
return false; return null;
} }
const firebase = await loadFirestore(); const firebase = await loadFirestore();
@ -240,56 +243,59 @@ export const saveToFirebase = async (
const docRef = firestore.collection("scenes").doc(roomId); const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => { const storedScene = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef); const snapshot = await transaction.get(docRef);
if (!snapshot.exists) { if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument( const storedScene = await createFirebaseSceneDocument(
firebase, firebase,
elements, elements,
roomKey, roomKey,
); );
transaction.set(docRef, sceneDocument); transaction.set(docRef, storedScene);
return { return storedScene;
elements,
reconciledElements: null,
};
} }
const prevDocData = snapshot.data() as FirebaseStoredScene; const prevStoredScene = snapshot.data() as FirebaseStoredScene;
const prevElements = getSyncableElements( const prevStoredElements = getSyncableElements(
await decryptElements(prevDocData, roomKey), restoreElements(await decryptElements(prevStoredScene, roomKey), null),
); );
const reconciledElements = getSyncableElements( const reconciledElements = getSyncableElements(
reconcileElements(elements, prevElements, appState), reconcileElements(
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
); );
const sceneDocument = await createFirebaseSceneDocument( const storedScene = await createFirebaseSceneDocument(
firebase, firebase,
reconciledElements, reconciledElements,
roomKey, roomKey,
); );
transaction.update(docRef, sceneDocument); transaction.update(docRef, storedScene);
return {
elements, // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
reconciledElements, return storedScene;
};
}); });
FirebaseSceneVersionCache.set(socket, savedData.elements); const storedElements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
);
return { reconciledElements: savedData.reconciledElements }; FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
}; };
export const loadFromFirebase = async ( export const loadFromFirebase = async (
roomId: string, roomId: string,
roomKey: string, roomKey: string,
socket: Socket | null, socket: Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => { ): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore(); const firebase = await loadFirestore();
const db = firebase.firestore(); const db = firebase.firestore();
@ -300,14 +306,14 @@ export const loadFromFirebase = async (
} }
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = doc.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
await decryptElements(storedScene, roomKey), restoreElements(await decryptElements(storedScene, roomKey), null),
); );
if (socket) { if (socket) {
FirebaseSceneVersionCache.set(socket, elements); FirebaseSceneVersionCache.set(socket, elements);
} }
return restoreElements(elements, null); return elements;
}; };
export const loadFilesFromFirebase = async ( export const loadFilesFromFirebase = async (

View File

@ -1,34 +1,42 @@
// Inspired and partly copied from https://gitlab.com/kiliandeca/excalidraw-fork // Inspired and partly copied from https://gitlab.com/kiliandeca/excalidraw-fork
// MIT, Kilian Decaderincourt // MIT, Kilian Decaderincourt
import { getSyncableElements, SyncableExcalidrawElement } from "."; import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { decompressData } from "../../packages/excalidraw/data/encode"; import { decompressData } from "../../packages/excalidraw/data/encode";
import { encryptData, decryptData, IV_LENGTH_BYTES } from "../../packages/excalidraw/data/encryption"; import {
encryptData,
decryptData,
IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption";
import { restoreElements } from "../../packages/excalidraw/data/restore"; import { restoreElements } from "../../packages/excalidraw/data/restore";
import { getSceneVersion } from "../../packages/excalidraw/element"; import { getSceneVersion } from "../../packages/excalidraw/element";
import { ExcalidrawElement, FileId } from "../../packages/excalidraw/element/types"; import type {
import { ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
DataURL, DataURL,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
import Portal from "../collab/Portal"; import type Portal from "../collab/Portal";
import { reconcileElements } from "../collab/reconciliation"; import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
import { StoredScene } from "./StorageBackend"; import { reconcileElements } from "../../packages/excalidraw/data/reconcile";
import type { StoredScene } from "./StorageBackend";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL; const HTTP_STORAGE_BACKEND_URL = import.meta.env
const SCENE_VERSION_LENGTH_BYTES = 4 .VITE_APP_HTTP_STORAGE_BACKEND_URL;
const SCENE_VERSION_LENGTH_BYTES = 4;
// There is a lot of intentional duplication with the firebase file // There is a lot of intentional duplication with the firebase file
// to prevent modifying upstream files and ease futur maintenance of this fork // to prevent modifying upstream files and ease futur maintenance of this fork
const httpStorageSceneVersionCache = new WeakMap< const httpStorageSceneVersionCache = new WeakMap<Socket, number>();
Socket,
number
>();
export const isSavedToHttpStorage = ( export const isSavedToHttpStorage = (
portal: Portal, portal: Portal,
@ -68,17 +76,20 @@ export const saveToHttpStorage = async (
if (!getResponse.ok && getResponse.status !== 404) { if (!getResponse.ok && getResponse.status !== 404) {
return false; return false;
}; }
if(getResponse.status === 404) { if (getResponse.status === 404) {
const result: boolean = await saveElementsToBackend(roomKey, roomId, [...elements], sceneVersion) const result: boolean = await saveElementsToBackend(
if(result) { roomKey,
return { roomId,
reconciledElements: null [...elements],
} sceneVersion,
);
if (result) {
return null;
} }
return false return false;
}; }
// If room already exist, we compare scene versions to check // If room already exist, we compare scene versions to check
// if we're up to date before saving our scene // if we're up to date before saving our scene
@ -90,16 +101,23 @@ export const saveToHttpStorage = async (
const existingElements = await getElementsFromBuffer(buffer, roomKey); const existingElements = await getElementsFromBuffer(buffer, roomKey);
const reconciledElements = getSyncableElements( const reconciledElements = getSyncableElements(
reconcileElements(elements, existingElements, appState), reconcileElements(
elements,
existingElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
); );
const result: boolean = await saveElementsToBackend(roomKey, roomId, reconciledElements, sceneVersion) const result: boolean = await saveElementsToBackend(
roomKey,
roomId,
reconciledElements,
sceneVersion,
);
if (result) { if (result) {
httpStorageSceneVersionCache.set(socket, sceneVersion); httpStorageSceneVersionCache.set(socket, sceneVersion);
return { return elements;
reconciledElements: elements
};
} }
return false; return false;
}; };
@ -109,7 +127,8 @@ export const loadFromHttpStorage = async (
roomKey: string, roomKey: string,
socket: Socket | null, socket: Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => { ): Promise<readonly ExcalidrawElement[] | null> => {
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL; const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const getResponse = await fetch( const getResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`, `${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
); );
@ -130,12 +149,20 @@ const getElementsFromBuffer = async (
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
// Buffer should contain both the IV (fixed length) and encrypted data // Buffer should contain both the IV (fixed length) and encrypted data
const sceneVersion = parseSceneVersionFromRequest(buffer); const sceneVersion = parseSceneVersionFromRequest(buffer);
const iv = new Uint8Array(buffer.slice(SCENE_VERSION_LENGTH_BYTES, IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES)); const iv = new Uint8Array(
const encrypted = buffer.slice(IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES, buffer.byteLength); buffer.slice(
SCENE_VERSION_LENGTH_BYTES,
IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES,
),
);
const encrypted = buffer.slice(
IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES,
buffer.byteLength,
);
return await decryptElements( return await decryptElements(
{ sceneVersion: sceneVersion, ciphertext: encrypted, iv }, { sceneVersion, ciphertext: encrypted, iv },
key key,
); );
}; };
@ -149,7 +176,8 @@ export const saveFilesToHttpStorage = async ({
const erroredFiles = new Map<FileId, true>(); const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>(); const savedFiles = new Map<FileId, true>();
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL; const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
await Promise.all( await Promise.all(
files.map(async ({ id, buffer }) => { files.map(async ({ id, buffer }) => {
@ -182,7 +210,8 @@ export const loadFilesFromHttpStorage = async (
await Promise.all( await Promise.all(
[...new Set(filesIds)].map(async (id) => { [...new Set(filesIds)].map(async (id) => {
try { try {
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL; const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const response = await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`); const response = await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`);
if (response.status < 400) { if (response.status < 400) {
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
@ -216,7 +245,12 @@ export const loadFilesFromHttpStorage = async (
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
}; };
const saveElementsToBackend = async (roomKey: string, roomId: string, elements: SyncableExcalidrawElement[], sceneVersion: number) => { const saveElementsToBackend = async (
roomKey: string,
roomId: string,
elements: SyncableExcalidrawElement[],
sceneVersion: number,
) => {
const { ciphertext, iv } = await encryptElements(roomKey, elements); const { ciphertext, iv } = await encryptElements(roomKey, elements);
// Concatenate Scene Version, IV with encrypted data (IV does not have to be secret). // Concatenate Scene Version, IV with encrypted data (IV does not have to be secret).
@ -224,7 +258,9 @@ const saveElementsToBackend = async (roomKey: string, roomId: string, elements:
const numberView = new DataView(numberBuffer); const numberView = new DataView(numberBuffer);
numberView.setUint32(0, sceneVersion, false); numberView.setUint32(0, sceneVersion, false);
const sceneVersionBuffer = numberView.buffer; const sceneVersionBuffer = numberView.buffer;
const payloadBlob = await new Response(new Blob([sceneVersionBuffer, iv.buffer, ciphertext])).arrayBuffer(); const payloadBlob = await new Response(
new Blob([sceneVersionBuffer, iv.buffer, ciphertext]),
).arrayBuffer();
const putResponse = await fetch( const putResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`, `${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
{ {
@ -233,13 +269,13 @@ const saveElementsToBackend = async (roomKey: string, roomId: string, elements:
}, },
); );
return putResponse.ok return putResponse.ok;
} };
const parseSceneVersionFromRequest = (buffer: ArrayBuffer) => { const parseSceneVersionFromRequest = (buffer: ArrayBuffer) => {
const view = new DataView(buffer); const view = new DataView(buffer);
return view.getUint32(0, false); return view.getUint32(0, false);
} };
const decryptElements = async ( const decryptElements = async (
data: StoredScene, data: StoredScene,

View File

@ -4,44 +4,44 @@ import {
} from "../../packages/excalidraw/data/encode"; } from "../../packages/excalidraw/data/encode";
import { import {
decryptData, decryptData,
encryptData,
generateEncryptionKey, generateEncryptionKey,
IV_LENGTH_BYTES, IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption"; } from "../../packages/excalidraw/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore"; import { restore } from "../../packages/excalidraw/data/restore";
import { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds"; import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers"; import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import { import type {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n"; import { t } from "../../packages/excalidraw/i18n";
import { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
SocketId, SocketId,
UserIdleState, UserIdleState,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
import { bytesToHexString } from "../../packages/excalidraw/utils"; import { bytesToHexString } from "../../packages/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import { import {
DELETED_ELEMENT_TIMEOUT, DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES, ROOM_ID_BYTES,
WS_SUBTYPES,
} from "../app_constants"; } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager"; import { encodeFilesForUpload } from "./FileManager";
import { getStorageBackend } from "./config"; import { getStorageBackend } from "./config";
export type SyncableExcalidrawElement = ExcalidrawElement & { export type SyncableExcalidrawElement = OrderedExcalidrawElement &
_brand: "SyncableExcalidrawElement"; MakeBrand<"SyncableExcalidrawElement">;
};
export const isSyncableElement = ( export const isSyncableElement = (
element: ExcalidrawElement, element: OrderedExcalidrawElement,
): element is SyncableExcalidrawElement => { ): element is SyncableExcalidrawElement => {
if (element.isDeleted) { if (element.isDeleted) {
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) { if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
@ -52,7 +52,9 @@ export const isSyncableElement = (
return !isInvisiblySmallElement(element); return !isInvisiblySmallElement(element);
}; };
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) => export const getSyncableElements = (
elements: readonly OrderedExcalidrawElement[],
) =>
elements.filter((element) => elements.filter((element) =>
isSyncableElement(element), isSyncableElement(element),
) as SyncableExcalidrawElement[]; ) as SyncableExcalidrawElement[];
@ -66,35 +68,6 @@ const generateRoomId = async () => {
return bytesToHexString(buffer); return bytesToHexString(buffer);
}; };
/**
* Right now the reason why we resolve connection params (url, polling...)
* from upstream is to allow changing the params immediately when needed without
* having to wait for clients to update the SW.
*
* If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks)
*/
export const getCollabServer = async (): Promise<{
url: string;
polling: boolean;
}> => {
if (import.meta.env.VITE_APP_WS_SERVER_URL) {
return {
url: import.meta.env.VITE_APP_WS_SERVER_URL,
polling: true,
};
}
try {
const resp = await fetch(
`${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
);
return await resp.json();
} catch (error) {
console.error(error);
throw new Error(t("errors.cannotResolveCollabServer"));
}
};
export type EncryptedData = { export type EncryptedData = {
data: ArrayBuffer; data: ArrayBuffer;
iv: Uint8Array; iv: Uint8Array;
@ -296,7 +269,6 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async // in the scene database/localStorage, and instead fetch them async
// from a different database // from a different database
files: data.files, files: data.files,
commitToHistory: false,
}; };
}; };

View File

@ -1,12 +1,11 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types"; import type { AppState } from "../../packages/excalidraw/types";
import { import {
clearAppStateForLocalStorage, clearAppStateForLocalStorage,
getDefaultAppState, getDefaultAppState,
} from "../../packages/excalidraw/appState"; } from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {
@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
try { try {
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
const appStateSize = appState?.length || 0; const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0; const collabSize = collab?.length || 0;
const librarySize = library?.length || 0;
return appStateSize + collabSize + librarySize + getElementsStorageSize(); return appStateSize + collabSize + getElementsStorageSize();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return 0; return 0;
} }
}; };
export const getLibraryItemsFromStorage = () => {
try {
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
);
return libraryItems || [];
} catch (error) {
console.error(error);
return [];
}
};

View File

@ -20,7 +20,7 @@
name="description" name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta name="image" content="https://excalidraw.com/og-image-2.png" /> <meta name="image" content="https://excalidraw.com/og-image-3.png" />
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" /> <meta property="og:site_name" content="Excalidraw" />
@ -35,7 +35,7 @@
property="og:description" property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" /> <meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
@ -51,7 +51,7 @@
/> />
<meta <meta
property="twitter:image" property="twitter:image"
content="https://excalidraw.com/og-twitter-v2.png" content="https://excalidraw.com/og-image-3.png"
/> />
<!-- General tags --> <!-- General tags -->
@ -64,12 +64,30 @@
<!-- to minimize white flash on load when user has dark mode enabled --> <!-- to minimize white flash on load when user has dark mode enabled -->
<script> <script>
try { try {
// function setTheme(theme) {
const theme = window.localStorage.getItem("excalidraw-theme"); if (theme === "dark") {
if (theme === "dark") { document.documentElement.classList.add("dark");
document.documentElement.classList.add("dark"); } else {
document.documentElement.classList.remove("dark");
}
} }
} catch {}
function getTheme() {
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme && theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} else {
return theme || "light";
}
}
setTheme(getTheme());
} catch (e) {
console.error("Error setting dark mode", e);
}
</script> </script>
<style> <style>
html.dark { html.dark {
@ -77,8 +95,13 @@
color: #fff; color: #fff;
} }
</style> </style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if ("%PROD%" === "true") { %> <% if (typeof PROD != 'undefined' && PROD == true) { %>
<script> <script>
// Redirect Excalidraw+ users which have auto-redirect enabled. // Redirect Excalidraw+ users which have auto-redirect enabled.
// //
@ -97,8 +120,55 @@
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
</script> </script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@ -106,23 +176,8 @@
<!-- Excalidraw version --> <!-- Excalidraw version -->
<meta name="version" content="{version}" /> <meta name="version" content="{version}" />
<link <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
rel="preload" VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts.css" type="text/css" />
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
<script> <script>
{ {
const _WebSocket = window.WebSocket; const _WebSocket = window.WebSocket;
@ -139,7 +194,6 @@
</script> </script>
<% } %> <% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
@ -196,7 +250,6 @@
</header> </header>
<div id="root"></div> <div id="root"></div>
<script type="module" src="index.tsx"></script> <script type="module" src="index.tsx"></script>
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
<!-- 100% privacy friendly analytics --> <!-- 100% privacy friendly analytics -->
<script> <script>
// need to load this script dynamically bcs. of iframe embed tracking // need to load this script dynamically bcs. of iframe embed tracking
@ -229,6 +282,5 @@
} }
</script> </script>
<!-- end LEGACY GOOGLE ANALYTICS --> <!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body> </body>
</html> </html>

View File

@ -4,6 +4,13 @@
&.theme--dark { &.theme--dark {
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion --color-primary-contrast-offset: #726dff; // to offset Chubb illusion
} }
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center { .footer-center {
justify-content: flex-end; justify-content: flex-end;
margin-top: auto; margin-top: auto;
@ -18,6 +25,7 @@
margin-bottom: auto; margin-bottom: auto;
margin-inline-start: auto; margin-inline-start: auto;
margin-inline-end: 0.6em; margin-inline-end: 0.6em;
z-index: var(--zIndex-layerUI);
svg { svg {
width: 1.2rem; width: 1.2rem;
@ -31,8 +39,12 @@
background-color: #ecfdf5; background-color: #ecfdf5;
color: #064e3c; color: #064e3c;
} }
&.ExcalidrawPlus { &.highlighted {
color: var(--color-promo); color: var(--color-promo);
font-weight: 700;
.dropdown-menu-item__icon g {
stroke-width: 2;
}
} }
} }
} }

View File

@ -1,4 +1,8 @@
{ {
"name": "excalidraw-app",
"version": "1.0.0",
"private": true,
"homepage": ".",
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",
@ -22,21 +26,28 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"packages/excalidraw": "*" "firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
}, },
"homepage": ".",
"name": "excalidraw-app",
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"private": true,
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_ESLINT=false vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_ESLINT=false vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"install:deps": "yarn install --frozen-lockfile && yarn --cwd ../",
"start": "yarn && vite", "start": "yarn && vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"
} }
} }

View File

@ -1,7 +1,7 @@
@import "../../packages/excalidraw/css/variables.module"; @import "../../packages/excalidraw/css/variables.module.scss";
.excalidraw { .excalidraw {
.RoomDialog { .ShareDialog {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
@ -10,8 +10,25 @@
height: calc(100vh - 5rem); height: calc(100vh - 5rem);
} }
&__separator {
border-top: 1px solid var(--dialog-border-color);
text-align: center;
display: flex;
justify-content: center;
align-items: center;
margin-top: 1em;
span {
background: var(--island-bg-color);
padding: 0px 0.75rem;
transform: translateY(-1ch);
display: inline-flex;
line-height: 1;
}
}
&__popover { &__popover {
@keyframes RoomDialog__popover__scaleIn { @keyframes ShareDialog__popover__scaleIn {
from { from {
opacity: 0; opacity: 0;
} }
@ -50,10 +67,10 @@
} }
transform-origin: var(--radix-popover-content-transform-origin); transform-origin: var(--radix-popover-content-transform-origin);
animation: RoomDialog__popover__scaleIn 150ms ease-out; animation: ShareDialog__popover__scaleIn 150ms ease-out;
} }
&__inactive { &__picker {
font-family: "Assistant"; font-family: "Assistant";
&__illustration { &__illustration {
@ -95,7 +112,7 @@
} }
} }
&__start_session { &__button {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -0,0 +1,300 @@
import { useEffect, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils";
import { useI18n } from "../../packages/excalidraw/i18n";
import { KEYS } from "../../packages/excalidraw/keys";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import {
copyIcon,
LinkIcon,
playerPlayIcon,
playerStopFilledIcon,
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
export const shareDialogStateAtom = atom<
{ isOpen: false } | { isOpen: true; type: ShareDialogType }
>({ isOpen: false });
const getShareIcon = () => {
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
if (isAppleBrowser) {
return shareIOS;
} else if (isWindowsBrowser) {
return shareWindows;
}
return share;
};
export type ShareDialogProps = {
collabAPI: CollabAPI | null;
handleClose: () => void;
onExportToBackend: OnExportToBackend;
type: ShareDialogType;
};
const ActiveRoomDialog = ({
collabAPI,
activeRoomLink,
handleClose,
}: {
collabAPI: CollabAPI;
activeRoomLink: string;
handleClose: () => void;
}) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
const shareRoomLink = async () => {
try {
await navigator.share({
title: t("roomDialog.shareTitle"),
text: t("roomDialog.shareTitle"),
url: activeRoomLink,
});
} catch (error: any) {
// Just ignore.
}
};
return (
<>
<h3 className="ShareDialog__active__header">
{t("labels.liveCollaboration").replace(/\./g, "")}
</h3>
<TextField
defaultValue={collabAPI.getUsername()}
placeholder="Your name"
label="Your name"
onChange={collabAPI.setUsername}
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
/>
<div className="ShareDialog__active__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={activeRoomLink}
/>
{isShareSupported && (
<FilledButton
size="large"
variant="icon"
label="Share"
icon={getShareIcon()}
className="ShareDialog__active__share"
onClick={shareRoomLink}
/>
)}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareDialog__active__description">
<p>
<span
role="img"
aria-hidden="true"
className="ShareDialog__active__description__emoji"
>
🔒{" "}
</span>
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
</div>
<div className="ShareDialog__active__actions">
<FilledButton
size="large"
variant="outlined"
color="danger"
label={t("roomDialog.button_stopSession")}
icon={playerStopFilledIcon}
onClick={() => {
trackEvent("share", "room closed");
collabAPI.stopCollaboration();
if (!collabAPI.isCollaborating()) {
handleClose();
}
}}
/>
</div>
</>
);
};
const ShareDialogPicker = (props: ShareDialogProps) => {
const { t } = useI18n();
const { collabAPI } = props;
const startCollabJSX = collabAPI ? (
<>
<div className="ShareDialog__picker__header">
{t("labels.liveCollaboration").replace(/\./g, "")}
</div>
<div className="ShareDialog__picker__description">
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
{t("roomDialog.desc_privacy")}
</div>
<div className="ShareDialog__picker__button">
<FilledButton
size="large"
label={t("roomDialog.button_startSession")}
icon={playerPlayIcon}
onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
collabAPI.startCollaboration(null);
}}
/>
</div>
{props.type === "share" && (
<div className="ShareDialog__separator">
<span>{t("shareDialog.or")}</span>
</div>
)}
</>
) : null;
return (
<>
{startCollabJSX}
{props.type === "share" && (
<>
<div className="ShareDialog__picker__header">
{t("exportDialog.link_title")}
</div>
<div className="ShareDialog__picker__description">
{t("exportDialog.link_details")}
</div>
<div className="ShareDialog__picker__button">
<FilledButton
size="large"
label={t("exportDialog.link_button")}
icon={LinkIcon}
onClick={async () => {
await props.onExportToBackend();
props.handleClose();
}}
/>
</div>
</>
)}
</>
);
};
const ShareDialogInner = (props: ShareDialogProps) => {
const activeRoomLink = useAtomValue(activeRoomLinkAtom);
return (
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
<div className="ShareDialog">
{props.collabAPI && activeRoomLink ? (
<ActiveRoomDialog
collabAPI={props.collabAPI}
activeRoomLink={activeRoomLink}
handleClose={props.handleClose}
/>
) : (
<ShareDialogPicker {...props} />
)}
</div>
</Dialog>
);
};
export const ShareDialog = (props: {
collabAPI: CollabAPI | null;
onExportToBackend: OnExportToBackend;
}) => {
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
const { openDialog } = useUIAppState();
useEffect(() => {
if (openDialog) {
setShareDialogState({ isOpen: false });
}
}, [openDialog, setShareDialogState]);
if (!shareDialogState.isOpen) {
return null;
}
return (
<ShareDialogInner
handleClose={() => setShareDialogState({ isOpen: false })}
collabAPI={props.collabAPI}
onExportToBackend={props.onExportToBackend}
type={shareDialogState.type}
/>
);
};

View File

@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center" class="welcome-screen-center"
> >
<div <div
class="welcome-screen-center__logo virgil welcome-screen-decor" class="welcome-screen-center__logo excalifont welcome-screen-decor"
> >
<div <div
class="ExcalidrawLogo is-small" class="ExcalidrawLogo is-small"
@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div> </div>
</div> </div>
<div <div
class="welcome-screen-center__heading welcome-screen-decor virgil" class="welcome-screen-center__heading welcome-screen-decor excalifont"
> >
All your data is saved locally in your browser. All your data is saved locally in your browser.
</div> </div>
@ -224,24 +224,14 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
fill="none" fill="none"
stroke="none" stroke="none"
/> />
<rect <path
height="4" d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
rx="1"
width="18"
x="3"
y="8"
/>
<line
x1="12"
x2="12"
y1="8"
y2="21"
/> />
<path <path
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7" d="M21 12h-13l3 -3"
/> />
<path <path
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5" d="M11 15l-3 -3"
/> />
</g> </g>
</svg> </svg>
@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div <div
class="welcome-screen-menu-item__text" class="welcome-screen-menu-item__text"
> >
Try Excalidraw Plus! Sign up
</div> </div>
</a> </a>
</div> </div>

View File

@ -1,12 +1,18 @@
import { vi } from "vitest"; import { vi } from "vitest";
import { import {
act,
render, render,
updateSceneData,
waitFor, waitFor,
} from "../../packages/excalidraw/tests/test-utils"; } from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../App";
import { API } from "../../packages/excalidraw/tests/helpers/api"; import { API } from "../../packages/excalidraw/tests/helpers/api";
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory"; import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { StoreAction, newElementWith } from "../../packages/excalidraw";
const { h } = window; const { h } = window;
Object.defineProperty(window, "crypto", { Object.defineProperty(window, "crypto", {
@ -20,17 +26,6 @@ Object.defineProperty(window, "crypto", {
}, },
}); });
vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => {
const module = (await importActual()) as any;
return {
__esmodule: true,
...module,
getCollabServer: vi.fn(() => ({
url: /* doesn't really matter */ "http://localhost:3002",
})),
};
});
vi.mock("../../excalidraw-app/data/firebase.ts", () => { vi.mock("../../excalidraw-app/data/firebase.ts", () => {
const loadFromFirebase = async () => null; const loadFromFirebase = async () => null;
const saveToFirebase = () => {}; const saveToFirebase = () => {};
@ -67,39 +62,190 @@ vi.mock("socket.io-client", () => {
}; };
}); });
/**
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
* while having access to both scenes, appstates stores, histories and etc.
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => { describe("collaboration", () => {
it("creating room should reset deleted elements", async () => { it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab const rect1Props = {
updateSceneData({ type: "rectangle",
elements: [ id: "A",
API.createElement({ type: "rectangle", id: "A" }), height: 200,
API.createElement({ width: 100,
type: "rectangle", } as const;
id: "B",
isDeleted: true, const rect2Props = {
}), type: "rectangle",
], id: "B",
}); width: 100,
await waitFor(() => { height: 200,
expect(h.elements).toEqual([ } as const;
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }), const rect1 = API.createElement({ ...rect1Props });
]); const rect2 = API.createElement({ ...rect2Props });
expect(API.getStateHistory().length).toBe(1);
}); API.updateScene({
window.collab.startCollaboration(null); elements: syncInvalidIndices([rect1, rect2]),
await waitFor(() => { storeAction: StoreAction.CAPTURE,
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); });
expect(API.getStateHistory().length).toBe(1);
API.updateScene({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
storeAction: StoreAction.CAPTURE,
}); });
const undoAction = createUndoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(API.getUndoStack().length).toBe(2);
expect(API.getStateHistory().length).toBe(1); expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
// we never delete from the local snapshot as it is used for correct diff calculation
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
}); });
}); });
}); });

View File

@ -1,421 +0,0 @@
import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import {
BroadcastedExcalidrawElement,
ReconciledElements,
reconcileElements,
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../packages/excalidraw/random";
import { AppState } from "../../packages/excalidraw/types";
import { cloneJSON } from "../../packages/excalidraw/utils";
type Id = string;
type ElementLike = {
id: string;
version: number;
versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>;
const createElement = (opts: { uid: string } | ElementLike) => {
let uid: string;
let id: string;
let version: number | null;
let parent: string | null = null;
let versionNonce: number | null = null;
if ("uid" in opts) {
const match = opts.uid.match(
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
)!;
parent = match[1];
id = match[2];
version = match[3] ? parseInt(match[3]) : null;
uid = version ? `${id}:${version}` : id;
} else {
({ id, version, versionNonce } = opts);
parent = parent || null;
uid = id;
}
return {
uid,
id,
version,
versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
};
};
const idsToElements = (
ids: (Id | ElementLike)[],
cache: Cache = {},
): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => {
const {
uid,
id,
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const cached = cache[uid];
const elem = {
id,
version: version ?? 0,
versionNonce,
...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement;
// @ts-ignore
cache[uid] = elem;
acc.push(elem);
return acc;
}, [] as ExcalidrawElement[]);
};
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el;
});
};
const cleanElements = (elements: ReconciledElements) => {
return elements.map((el) => {
// @ts-ignore
delete el[PRECEDING_ELEMENT_KEY];
// @ts-ignore
delete el.next;
// @ts-ignore
delete el.prev;
return el;
});
};
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
target: U[],
bidirectional = true,
) => {
const cache: Cache = {};
const _local = idsToElements(local, cache);
const _remote = idsToElements(remote, cache);
const _target = target.map((uid) => {
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
}) as any as ReconciledElements;
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
expect(target.length).equal(remoteReconciled.length);
expect(cleanElements(remoteReconciled)).deep.equal(
cleanElements(_target),
"remote reconciliation",
);
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
} catch (error: any) {
console.error("local original", __local);
console.error("remote reconciled", __remote);
throw error;
}
}
};
export const findIndex = <T>(
array: readonly T[],
cb: (element: T, index: number, array: readonly T[]) => boolean,
fromIndex: number = 0,
) => {
if (fromIndex < 0) {
fromIndex = array.length + fromIndex;
}
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
let index = fromIndex - 1;
while (++index < array.length) {
if (cb(array[index], index, array)) {
return index;
}
}
return -1;
};
// -----------------------------------------------------------------------------
describe("elements reconciliation", () => {
it("reconcileElements()", () => {
// -------------------------------------------------------------------------
//
// in following tests, we pass:
// (1) an array of local elements and their version (:1, :2...)
// (2) an array of remote elements and their version (:1, :2...)
// (3) expected reconciled elements
//
// in the reconciled array:
// :L means local element was resolved
// :R means remote element was resolved
//
// if a remote element is prefixed with parentheses, the enclosed string:
// (^) means the element is the first element in the array
// (<id>) means the element is preceded by <id> element
//
// if versions are missing, it defaults to version 0
// -------------------------------------------------------------------------
// non-annotated elements
// -------------------------------------------------------------------------
// usually when we sync elements they should always be annotated with
// their (preceding elements) parents, but let's test a couple of cases when
// they're not for whatever reason (remote clients are on older version...),
// in which case the first synced element either replaces existing element
// or is pushed at the end of the array
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
test(["A"], ["A", "B"], ["A:L", "B:R"]);
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
test(["A"], ["A:1"], ["A:R"]);
// C isn't added to the end because it follows B (even if B was resolved
// to local version)
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
// some of the following tests are kinda arbitrary and they're less
// likely to happen in real-world cases
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(
["A:2", "B:2", "C"],
["D", "B:1", "A:3"],
["B:L", "A:R", "C:L", "D:R"],
);
test(
["A:2", "B:2", "C"],
["D", "B:2", "A:3", "C"],
["D:R", "B:L", "A:R", "C:L"],
);
test(
["A", "B", "C", "D", "E", "F"],
["A", "B:2", "X", "E:2", "F", "Y"],
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
);
// annotated elements
// -------------------------------------------------------------------------
test(
["A", "B", "C"],
["(B)X", "(A)Y", "(Y)Z"],
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
);
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
test(
["A", "B"],
["(A)C", "(^)D", "F"],
["A:L", "C:R", "D:R", "F:R", "B:L"],
);
test(
["A", "B", "C", "D"],
["(B)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
);
test(
["B", "A", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
);
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
test(
["A", "B", "C", "D", "E"],
["(A)X", "(C)Y", "(D)Z"],
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
);
test(
["X", "Y", "Z"],
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
);
test(
["A", "B", "C", "D", "E"],
["(C)X", "(A)Y", "(D)E:1"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
);
test(
["C:1", "B", "D:1"],
["A", "B", "C:1", "D:1"],
["A:R", "B:L", "C:L", "D:L"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["C:1", "B", "D:1"],
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
["A:R", "B:L", "C:R", "D:L"],
);
test(
["A", "B", "C", "D"],
["(C)X", "(B)Y", "(A)Z"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
);
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
test(
["A", "B", "C", "D"],
["(A)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
});
it("test identical elements reconciliation", () => {
const testIdentical = (
local: ElementLike[],
remote: ElementLike[],
expected: Id[],
) => {
const ret = reconcileElements(
local as any as ExcalidrawElement[],
remote as any as ExcalidrawElement[],
{} as AppState,
);
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
throw new Error("reconcileElements: duplicate elements found");
}
expect(ret.map((x) => x.id)).to.deep.equal(expected);
};
// identical id/version/versionNonce
// -------------------------------------------------------------------------
testIdentical(
[{ id: "A", version: 1, versionNonce: 1 }],
[{ id: "A", version: 1, versionNonce: 1 }],
["A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
// actually identical (arrays and element objects)
// -------------------------------------------------------------------------
const elements1 = [
{
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
];
testIdentical(elements1, elements1, ["A", "B"]);
testIdentical(elements1, elements1.slice(), ["A", "B"]);
testIdentical(elements1.slice(), elements1, ["A", "B"]);
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
const el1 = {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
const el2 = {
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
});
});

View File

@ -0,0 +1,70 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
const mediaQuery = getDarkThemeMediaQuery();
const handleChange = (e: MediaQueryListEvent) => {
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
};
if (appTheme === "system") {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
if (appTheme === "system") {
setEditorTheme(
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
);
} else {
setEditorTheme(appTheme);
}
}, [appTheme]);
return { editorTheme };
};

View File

@ -4,6 +4,8 @@ import svgrPlugin from "vite-plugin-svgr";
import { ViteEjsPlugin } from "vite-plugin-ejs"; import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables // To load .env.local variables
const envVars = loadEnv("", `../`); const envVars = loadEnv("", `../`);
@ -20,6 +22,14 @@ export default defineConfig({
outDir: "build", outDir: "build",
rollupOptions: { rollupOptions: {
output: { output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they // Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with // can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load // app precache. en.json and percentages.json are needed for first load
@ -39,6 +49,7 @@ export default defineConfig({
sourcemap: true, sourcemap: true,
}, },
plugins: [ plugins: [
woff2BrowserPlugin(),
react(), react(),
checker({ checker({
typescript: true, typescript: true,
@ -188,6 +199,9 @@ export default defineConfig({
], ],
}, },
}), }),
createHtmlPlugin({
minify: true,
}),
], ],
publicDir: "../public", publicDir: "../public",
}); });

View File

@ -1,32 +1,23 @@
{ {
"private": true, "private": true,
"name": "excalidraw-monorepo", "name": "excalidraw-monorepo",
"packageManager": "yarn@1.22.22",
"workspaces": [ "workspaces": [
"excalidraw-app", "excalidraw-app",
"packages/excalidraw", "packages/excalidraw",
"packages/utils" "packages/utils",
"examples/excalidraw",
"examples/excalidraw/*"
], ],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@excalidraw/eslint-config": "1.0.3", "@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.7",
"@types/react": "18.0.15", "@types/react": "18.2.0",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0", "@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0", "@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0", "@vitest/coverage-v8": "0.33.0",
@ -44,12 +35,12 @@
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.6", "vite": "5.0.12",
"vite-plugin-checker": "0.6.1", "vite-plugin-checker": "0.6.1",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4", "vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0", "vite-plugin-svgr": "2.4.0",
"vitest": "1.0.1", "vitest": "1.6.0",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.2"
}, },
"engines": { "engines": {
@ -66,7 +57,6 @@
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
"install:deps": "yarn install --frozen-lockfile && yarn --frozen-lockfile --cwd ./excalidraw-app",
"locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install", "prepare": "husky install",
@ -86,6 +76,12 @@
"autorelease": "node scripts/autorelease.js", "autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js", "prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000", "build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js" "release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {
"@types/react": "18.2.0"
} }
} }

View File

@ -1,4 +1,2 @@
node_modules node_modules
dist
example
types types

View File

@ -13,16 +13,86 @@ Please add the latest change on the top under the correct section.
## Unreleased ## Unreleased
### Features
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
### Breaking Changes ### Breaking Changes
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes |
| --- | --- | --- | --- |
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
#### Bundler
- CSS needs to be imported so you will need to import the css along with the excalidraw component
```js
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
```
- The `types` path is updated
Instead of importing from `@excalidraw/excalidraw/types/`, you will need to import from `@excalidraw/excalidraw/dist/excalidraw` or `@excalidraw/excalidraw/dist/utils` depending on the types you are using.
However this we will be fixing before stable release, so in case you want to try it out you will need to update the types for now.
#### Browser
- Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well.
```html
<link
rel="stylesheet"
href="https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.css"
/>
<script type="module">
import * as ExcalidrawLib from "https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.js";
window.ExcalidrawLib = ExcalidrawLib;
</script>
```
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336) - `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.1 (2023-11-28) ## 0.17.3 (2024-02-09)
### Fixes ### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean. - Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
``` ```
@ -31,14 +101,16 @@ define: {
} }
``` ```
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339)
## Excalidraw Library ## Excalidraw Library
### Fixes ### Fixes
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) - Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
## 0.17.0 (2023-11-14) ## 0.17.0 (2023-11-14)
### Features ### Features
@ -233,7 +305,7 @@ define: {
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581). - Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728). - Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) - Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)

View File

@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random"; import { randomId } from "../random";
import { t } from "../i18n"; import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "../store";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) { for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) { if (selectedElements.some((element) => element.type === type)) {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`), errorMessage: t(`errors.libraryElementTypeError.${type}`),
@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
}) })
.then(() => { .then(() => {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.addedToLibrary") }, toast: { message: t("toast.addedToLibrary") },
@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
}) })
.catch((error) => { .catch((error) => {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
@ -58,5 +59,5 @@ export const actionAddToLibrary = register({
}; };
}); });
}, },
contextItemLabel: "labels.addToLibrary", label: "labels.addToLibrary",
}); });

View File

@ -1,4 +1,5 @@
import { alignElements, Alignment } from "../align"; import type { Alignment } from "../align";
import { alignElements } from "../align";
import { import {
AlignBottomIcon, AlignBottomIcon,
AlignLeftIcon, AlignLeftIcon,
@ -10,18 +11,19 @@ import {
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types"; import { StoreAction } from "../store";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const alignActionsPredicate = ( const alignActionsPredicate = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: UIAppState,
_: unknown, _: unknown,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
@ -40,8 +42,13 @@ const alignSelectedElements = (
alignment: Alignment, alignment: Alignment,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const elementsMap = arrayToMap(elements);
const updatedElements = alignElements(selectedElements, alignment); const updatedElements = alignElements(
selectedElements,
elementsMap,
alignment,
);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@ -54,6 +61,8 @@ const alignSelectedElements = (
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
label: "labels.alignTop",
icon: AlignTopIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@ -63,7 +72,7 @@ export const actionAlignTop = register({
position: "start", position: "start",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -85,6 +94,8 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
label: "labels.alignBottom",
icon: AlignBottomIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@ -94,7 +105,7 @@ export const actionAlignBottom = register({
position: "end", position: "end",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -116,6 +127,8 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
label: "labels.alignLeft",
icon: AlignLeftIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@ -125,7 +138,7 @@ export const actionAlignLeft = register({
position: "start", position: "start",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -147,6 +160,8 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
label: "labels.alignRight",
icon: AlignRightIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@ -156,7 +171,7 @@ export const actionAlignRight = register({
position: "end", position: "end",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -178,6 +193,8 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
label: "labels.centerVertically",
icon: CenterVerticallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@ -187,7 +204,7 @@ export const actionAlignVerticallyCentered = register({
position: "center", position: "center",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
@ -205,6 +222,8 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@ -214,7 +233,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center", position: "center",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (

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