mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
154 Commits
aakansha/n
...
zsviczian-
Author | SHA1 | Date | |
---|---|---|---|
17477480f3 | |||
f7b3befd0a | |||
7b2bee9746 | |||
88014ace4a | |||
87a9430809 | |||
99b91c46f7 | |||
1ea5b26f25 | |||
d5f4ee7b3f | |||
261304c1a4 | |||
84398a7e5c | |||
54491d13d4 | |||
dd1370381d | |||
72d6ee48fc | |||
232242d2e9 | |||
f19ce30dfe | |||
3cf14c73a3 | |||
8d530cf102 | |||
b87925d253 | |||
a6684b09f2 | |||
b427617f66 | |||
2907fbe34b | |||
c67815f7b0 | |||
c641860cb1 | |||
84d89b9a8a | |||
e63dd025c9 | |||
15e019706d | |||
a133a70e87 | |||
80ea7ca23f | |||
e844580b14 | |||
5a0771ad9c | |||
adcdbe2907 | |||
230d0edc44 | |||
d0a380758e | |||
7b36de0476 | |||
2427e622b0 | |||
62228e0bbb | |||
4c5408263c | |||
bd7b778f41 | |||
43b2476dfe | |||
df8875a497 | |||
6fbc44fd1f | |||
d25a7d365b | |||
e52c2cd0b6 | |||
96eeec5119 | |||
f5221d521b | |||
db2c235cd4 | |||
148b895f46 | |||
d9258a736b | |||
2e1f08c796 | |||
1d5b41dabb | |||
66a2f24296 | |||
04668d8263 | |||
abbeed3d5f | |||
ba8c09d529 | |||
744b3e5d09 | |||
6ba9bd60e8 | |||
a1ffa064df | |||
4dc4590f24 | |||
d2f67e619f | |||
22b39277f5 | |||
63dee03ef0 | |||
08b13f971d | |||
69f4cc70cb | |||
860308eb27 | |||
4eb9463f26 | |||
6ed6131169 | |||
1ed98f9c93 | |||
a71bb63d1f | |||
661d6a4a75 | |||
defd34923a | |||
c540bd68aa | |||
eddbe55f50 | |||
2f9526da24 | |||
1b6e3fe05b | |||
afe52c89a7 | |||
be4e127f6c | |||
ff0b4394b1 | |||
7d8b7fc14d | |||
971b4d4ae6 | |||
cc4c51996c | |||
79257a1923 | |||
dc66261c19 | |||
273ba803d9 | |||
301e83805d | |||
ed5ce8d3de | |||
1ed53b153c | |||
c1926f33bb | |||
6539029d2a | |||
d1f37cc64f | |||
f0d25e34c3 | |||
d9bbf1eda6 | |||
f79fb9aae2 | |||
275f6fbe24 | |||
88812e0386 | |||
6e5aeb112d | |||
4d83d1c91e | |||
a04676d423 | |||
c851aaaf7b | |||
1bd2b1fe55 | |||
015b46ab23 | |||
530617be90 | |||
5211b003b8 | |||
bbcca06b94 | |||
f92f04c13c | |||
890ed9f31f | |||
da2e507298 | |||
f59b4f6af4 | |||
afcde542f9 | |||
4689a6b300 | |||
0ae9b383d6 | |||
f597bd3e01 | |||
4987cc53d0 | |||
d917db438e | |||
a33a400f01 | |||
8a162a4cb4 | |||
c6a045d092 | |||
cd50aa719f | |||
92bc08207c | |||
32df5502ae | |||
bbdcd30a73 | |||
3e334a67ed | |||
1d71f84515 | |||
550a388b2b | |||
6b523563d8 | |||
65bc500598 | |||
7949aa1f1c | |||
15bfa626b4 | |||
068895db0e | |||
b7babe554b | |||
6a385d6663 | |||
2382fad4f6 | |||
480572f893 | |||
68b1fdb20e | |||
a38e82f999 | |||
a07f6e9e3a | |||
7e471b55eb | |||
160440b860 | |||
f207bd0a1c | |||
99601baffc | |||
af1a3d5b76 | |||
36e56267c9 | |||
b09b5cb5f4 | |||
dd8529743a | |||
f639d44a95 | |||
f5ab3e4e12 | |||
361a9449bb | |||
2e719ff671 | |||
79d9dc2f8f | |||
9013c84524 | |||
47f87f4ecb | |||
73bf50e8a8 | |||
48c3465b19 | |||
adc4c9f484 | |||
def1df2c68 |
@ -4,8 +4,16 @@
|
||||
!.eslintrc.json
|
||||
!.npmrc
|
||||
!.prettierrc
|
||||
!excalidraw-app/
|
||||
!package.json
|
||||
!public/
|
||||
!packages/
|
||||
!scripts/
|
||||
!tsconfig.json
|
||||
!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
|
||||
|
@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
VITE_APP_DISABLE_TRACKING=true
|
||||
VITE_APP_ENABLE_TRACKING=true
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
|
@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
VITE_APP_DISABLE_TRACKING=
|
||||
VITE_APP_ENABLE_TRACKING=false
|
||||
|
@ -6,3 +6,5 @@ firebase/
|
||||
dist/
|
||||
public/workbox
|
||||
packages/excalidraw/types
|
||||
examples/**/public
|
||||
dev-dist
|
||||
|
@ -2,6 +2,7 @@
|
||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||
"rules": {
|
||||
"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" }]
|
||||
}
|
||||
}
|
||||
|
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@ -1,14 +1,16 @@
|
||||
name: Tests
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
push:
|
||||
branches: master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,4 +25,5 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
examples/**/bundle.*
|
||||
examples/**/bundle.*
|
||||
meta*.json
|
12
Dockerfile
12
Dockerfile
@ -2,16 +2,18 @@ FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn --ignore-optional --network-timeout 600000
|
||||
COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||
|
@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
|
||||
```jsx showLineNumbers
|
||||
export default function App() {
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@ -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 |
|
||||
| `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. |
|
||||
| `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
|
||||
function App() {
|
||||
@ -115,7 +115,7 @@ function App() {
|
||||
<button className="custom-button" onClick={updateScene}>
|
||||
Update Scene
|
||||
</button>
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
||||
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -188,7 +188,7 @@ function App() {
|
||||
Update Library
|
||||
</button>
|
||||
<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
|
||||
initialData={{
|
||||
libraryItems: initialData.libraryItems,
|
||||
|
@ -9,9 +9,9 @@ All `props` are _optional_.
|
||||
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
|
||||
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
|
||||
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
|
||||
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down 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. |
|
||||
| [`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. |
|
||||
| [`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 |
|
||||
@ -26,7 +26,7 @@ All `props` are _optional_.
|
||||
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
|
||||
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
|
||||
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
|
||||
| [`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 |
|
||||
| [`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>` |
|
||||
|
@ -90,7 +90,7 @@ function App() {
|
||||
<img src={canvasUrl} alt="" />
|
||||
</div>
|
||||
<div style={{ height: "400px" }}>
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
||||
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
||||
|
||||
```jsx showLineNumbers
|
||||
"use client";
|
||||
import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
||||
height: 141.9765625,
|
||||
},]));
|
||||
return (
|
||||
<div style={{height:"500px", width:"500px"}}
|
||||
<div style={{height:"500px", width:"500px"}}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
|
@ -18,13 +18,13 @@
|
||||
"@docusaurus/core": "2.2.0",
|
||||
"@docusaurus/preset-classic": "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",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sass": "1.57.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -59,7 +59,7 @@ pre a {
|
||||
padding: 5px;
|
||||
background: #70b1ec;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
@ -1718,10 +1718,10 @@
|
||||
url-loader "^4.1.1"
|
||||
webpack "^5.73.0"
|
||||
|
||||
"@excalidraw/excalidraw@0.17.0":
|
||||
version "0.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
|
||||
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
|
||||
"@excalidraw/excalidraw@0.17.6":
|
||||
version "0.17.6"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
|
||||
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
|
@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import type { ResolvablePromise } from "../utils";
|
||||
import {
|
||||
resolvablePromise,
|
||||
ResolvablePromise,
|
||||
distance2d,
|
||||
fileOpen,
|
||||
withBatchedUpdates,
|
||||
@ -872,7 +872,7 @@ export default function App({
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Virgil";
|
||||
ctx.font = "30px Excalifont";
|
||||
ctx.strokeText("My custom text", 50, 60);
|
||||
setCanvasUrl(canvas.toDataURL());
|
||||
}}
|
||||
@ -893,7 +893,7 @@ export default function App({
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Virgil";
|
||||
ctx.font = "30px Excalifont";
|
||||
ctx.strokeText("My custom text", 50, 60);
|
||||
setCanvasUrl(canvas.toDataURL());
|
||||
}}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||
import CustomFooter from "./CustomFooter";
|
||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||
|
||||
|
@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
|
||||
];
|
||||
export default {
|
||||
elements,
|
||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
|
||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
|
||||
scrollToContent: true,
|
||||
libraryItems: [
|
||||
[
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { fileOpen as _fileOpen } from "browser-fs-access";
|
||||
import type { MIME_TYPES } from "@excalidraw/excalidraw";
|
||||
import { MIME_TYPES } from "@excalidraw/excalidraw";
|
||||
import { AbortError } from "../../packages/excalidraw/errors";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
3
examples/excalidraw/with-nextjs/.gitignore
vendored
3
examples/excalidraw/with-nextjs/.gitignore
vendored
@ -34,3 +34,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# copied assets
|
||||
public/*.woff2
|
@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
|
||||
"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",
|
||||
@ -12,13 +13,13 @@
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"next": "14.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"path2d-polyfill": "2.0.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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
|
||||
@ -15,7 +16,9 @@ export default function Page() {
|
||||
<>
|
||||
<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 />
|
||||
</>
|
||||
|
@ -7,7 +7,7 @@ a {
|
||||
color: #1c7ed6;
|
||||
font-size: 20px;
|
||||
text-decoration: none;
|
||||
font-weight: 550;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
2
examples/excalidraw/with-script-in-browser/.gitignore
vendored
Normal file
2
examples/excalidraw/with-script-in-browser/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# copied assets
|
||||
public/*.woff2
|
@ -11,6 +11,7 @@
|
||||
<title>React App</title>
|
||||
<script>
|
||||
window.name = "codesandbox";
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
||||
</head>
|
||||
|
@ -12,8 +12,10 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
|
||||
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import polyfill from "../packages/excalidraw/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||
@ -13,46 +12,46 @@ import {
|
||||
VERSION_TIMEOUT,
|
||||
} from "../packages/excalidraw/constants";
|
||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
import type {
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../packages/excalidraw/element/types";
|
||||
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
} from "../packages/excalidraw/index";
|
||||
import {
|
||||
StoreAction,
|
||||
reconcileElements,
|
||||
} from "../packages/excalidraw";
|
||||
import type {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
} from "../packages/excalidraw/types";
|
||||
import type { ResolvablePromise } from "../packages/excalidraw/utils";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
isTestEnv,
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../packages/excalidraw/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
isExcalidrawPlusSignedUser,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
import type { CollabAPI } from "./collab/Collab";
|
||||
import Collab, {
|
||||
CollabAPI,
|
||||
collabAPIAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
@ -64,16 +63,12 @@ import {
|
||||
loadScene,
|
||||
} from "./data";
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
restore,
|
||||
restoreAppState,
|
||||
RestoredDataState,
|
||||
} from "../packages/excalidraw/data/restore";
|
||||
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
|
||||
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
@ -82,10 +77,13 @@ import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import {
|
||||
LibraryIndexedDBAdapter,
|
||||
LibraryLocalStorageMigrationAdapter,
|
||||
LocalData,
|
||||
} from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
useHandleLibrary,
|
||||
@ -93,22 +91,73 @@ import {
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
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 { appJotaiStore } from "./app-jotai";
|
||||
|
||||
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 { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||
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();
|
||||
|
||||
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;
|
||||
|
||||
if (window.self !== window.top) {
|
||||
@ -123,11 +172,6 @@ if (window.self !== window.top) {
|
||||
}
|
||||
}
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
@ -252,7 +296,7 @@ const initializeScene = async (opts: {
|
||||
},
|
||||
elements: reconcileElements(
|
||||
scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
},
|
||||
@ -273,16 +317,15 @@ const initializeScene = async (opts: {
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
||||
export const appLangCodeAtom = atom(
|
||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||
const { editorTheme } = useHandleAppTheme();
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -310,10 +353,13 @@ const ExcalidrawWrapper = () => {
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
||||
adapter: LibraryIndexedDBAdapter,
|
||||
// TODO maybe remove this in several months (shipped: 24-03-11)
|
||||
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -411,7 +457,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -435,16 +481,17 @@ const ExcalidrawWrapper = () => {
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
}
|
||||
setLangCode(langCode);
|
||||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
LibraryIndexedDBAdapter.load().then((data) => {
|
||||
if (data) {
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: data.libraryItems,
|
||||
});
|
||||
}
|
||||
});
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
@ -535,29 +582,8 @@ const ExcalidrawWrapper = () => {
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
(localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
||||
) as Theme | null) ||
|
||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
||||
importFromLocalStorage().appState?.theme ||
|
||||
THEME.LIGHT,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
||||
// currently only used for body styling during init (see public/index.html),
|
||||
// but may change in the future
|
||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
||||
}, [theme]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
@ -565,8 +591,6 @@ const ExcalidrawWrapper = () => {
|
||||
collabAPI.syncElements(elements);
|
||||
}
|
||||
|
||||
setTheme(appState.theme);
|
||||
|
||||
// this check is redundant, but since this is a hot path, it's best
|
||||
// not to evaludate the nested expression every time
|
||||
if (!LocalData.isSavePaused()) {
|
||||
@ -592,6 +616,7 @@ const ExcalidrawWrapper = () => {
|
||||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -656,15 +681,6 @@ 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 onCollabDialogOpen = useCallback(
|
||||
@ -691,6 +707,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 (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
@ -709,27 +764,30 @@ const ExcalidrawWrapper = () => {
|
||||
toggleTheme: true,
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState, files) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
renderCustomUI: excalidrawAPI
|
||||
? (elements, appState, files) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
name={excalidrawAPI.getName()}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
@ -737,20 +795,22 @@ const ExcalidrawWrapper = () => {
|
||||
renderCustomStats={renderCustomStats}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
theme={editorTheme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
/>
|
||||
<div className="top-right-ui">
|
||||
{collabError.message && <CollabError collabError={collabError} />}
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
@ -758,6 +818,8 @@ const ExcalidrawWrapper = () => {
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
theme={appTheme}
|
||||
setTheme={(theme) => setAppTheme(theme)}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
@ -775,6 +837,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
excalidrawAPI.getName(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@ -879,6 +942,196 @@ const ExcalidrawWrapper = () => {
|
||||
{errorMessage}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,8 +7,8 @@ import {
|
||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import { UIAppState } from "../packages/excalidraw/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import type { UIAppState } from "../packages/excalidraw/types";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "../App";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { languages } from "../../packages/excalidraw/i18n";
|
||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||
import { appLangCodeAtom } from "./language-state";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
25
excalidraw-app/app-language/language-detector.ts
Normal file
25
excalidraw-app/app-language/language-detector.ts
Normal 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;
|
||||
};
|
15
excalidraw-app/app-language/language-state.ts
Normal file
15
excalidraw-app/app-language/language-state.ts
Normal 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;
|
||||
};
|
@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
IDB_LIBRARY: "excalidraw-library",
|
||||
|
||||
// do not use apart from migrations
|
||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
} as const;
|
||||
|
||||
export const COOKIES = {
|
||||
|
@ -1,22 +1,25 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import {
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
StoreAction,
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
reconcileElements,
|
||||
} from "../../packages/excalidraw";
|
||||
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
assertNever,
|
||||
preventUnload,
|
||||
@ -33,12 +36,14 @@ import {
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
WS_EVENTS,
|
||||
} from "../app_constants";
|
||||
import type {
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@ -69,18 +74,19 @@ import {
|
||||
isInitializedImageElement,
|
||||
} from "../../packages/excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom } from "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 { 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 isCollaboratingAtom = atom(false);
|
||||
@ -88,6 +94,8 @@ export const isOfflineAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string | null;
|
||||
/** errors related to saving */
|
||||
dialogNotifiedErrors: Record<string, boolean>;
|
||||
username: string;
|
||||
activeRoomLink: string | null;
|
||||
}
|
||||
@ -107,7 +115,7 @@ export interface CollabAPI {
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setErrorMessage: CollabInstance["setErrorMessage"];
|
||||
setCollabError: CollabInstance["setErrorDialog"];
|
||||
}
|
||||
|
||||
interface CollabProps {
|
||||
@ -129,6 +137,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
super(props);
|
||||
this.state = {
|
||||
errorMessage: null,
|
||||
dialogNotifiedErrors: {},
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: null,
|
||||
};
|
||||
@ -197,7 +206,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
setUsername: this.setUsername,
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setErrorMessage: this.setErrorMessage,
|
||||
setCollabError: this.setErrorDialog,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
@ -270,24 +279,39 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
const savedData = await saveToFirebase(
|
||||
const storedElements = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
this.resetErrorIndicator();
|
||||
|
||||
if (this.isCollaborating() && storedElements) {
|
||||
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.setState({
|
||||
// firestore doesn't return a specific error code when size exceeded
|
||||
errorMessage: /is longer than.*?bytes/.test(error.message)
|
||||
? t("errors.collabSaveFailed_sizeExceeded")
|
||||
: t("errors.collabSaveFailed"),
|
||||
});
|
||||
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||
? 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);
|
||||
}
|
||||
};
|
||||
@ -296,6 +320,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
this.resetErrorIndicator(true);
|
||||
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
@ -334,7 +359,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -407,7 +432,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
|
||||
startCollaboration = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
) => {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
@ -433,7 +458,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
);
|
||||
}
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
// TODO: `ImportedDataState` type here seems abused
|
||||
const scenePromise = resolvablePromise<
|
||||
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
|
||||
| null
|
||||
>();
|
||||
|
||||
this.setIsCollaborating(true);
|
||||
LocalData.pauseSave("collaboration");
|
||||
@ -464,7 +493,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
this.setErrorDialog(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -475,14 +504,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
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
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
// to database even if deleted before creating the room.
|
||||
this.excalidrawAPI.history.clear();
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
@ -516,10 +544,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
init: true,
|
||||
});
|
||||
const reconciledElements =
|
||||
this._reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements);
|
||||
// noop if already resolved via init from firebase
|
||||
scenePromise.resolve({
|
||||
elements: reconciledElements,
|
||||
@ -530,7 +557,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
this._reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
@ -678,17 +705,15 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
return null;
|
||||
};
|
||||
|
||||
private reconcileElements = (
|
||||
private _reconcileElements = (
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
): ReconciledElements => {
|
||||
): ReconciledExcalidrawElement[] => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
remoteElements = restoreElements(remoteElements, null);
|
||||
|
||||
const reconciledElements = _reconcileElements(
|
||||
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||
const reconciledElements = reconcileElements(
|
||||
localElements,
|
||||
remoteElements,
|
||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||
appState,
|
||||
);
|
||||
|
||||
@ -719,20 +744,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}, LOAD_IMAGES_TIMEOUT);
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{ init = false }: { init?: boolean } = {},
|
||||
elements: ReconciledExcalidrawElement[],
|
||||
) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
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();
|
||||
};
|
||||
|
||||
@ -865,7 +883,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.portal.broadcastIdleChange(userState);
|
||||
};
|
||||
|
||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||
if (
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
@ -876,7 +894,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
@ -923,8 +941,26 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
|
||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||
|
||||
setErrorMessage = (errorMessage: string | null) => {
|
||||
this.setState({ errorMessage });
|
||||
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() {
|
||||
@ -933,7 +969,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
return (
|
||||
<>
|
||||
{errorMessage != null && (
|
||||
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
|
||||
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
|
35
excalidraw-app/collab/CollabError.scss
Normal file
35
excalidraw-app/collab/CollabError.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
54
excalidraw-app/collab/CollabError.tsx
Normal file
54
excalidraw-app/collab/CollabError.tsx
Normal 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;
|
@ -1,14 +1,15 @@
|
||||
import {
|
||||
isSyncableElement,
|
||||
import type {
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} 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 {
|
||||
import type {
|
||||
OnUserFollowedPayload,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
@ -16,10 +17,9 @@ import {
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { StoreAction } from "../../packages/excalidraw";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
@ -128,12 +128,13 @@ class Portal {
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
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.
|
||||
// periodically we'll resync the whole thing to make sure no one diverges
|
||||
// due to a dropped message (server goes down etc).
|
||||
const syncableElements = allElements.reduce(
|
||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
||||
if (
|
||||
(syncAll ||
|
||||
!this.broadcastedElementVersions.has(element.id) ||
|
||||
element.version >
|
||||
this.broadcastedElementVersions.get(element.id)!) &&
|
||||
isSyncableElement(element)
|
||||
) {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
const syncableElements = elements.reduce((acc, element) => {
|
||||
if (
|
||||
(syncAll ||
|
||||
!this.broadcastedElementVersions.has(element.id) ||
|
||||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
|
||||
isSyncableElement(element)
|
||||
) {
|
||||
acc.push(element);
|
||||
}
|
||||
return acc;
|
||||
}, [] as SyncableExcalidrawElement[]);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
|
@ -65,19 +65,18 @@ export const RoomModal = ({
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
@ -1,154 +0,0 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
||||
|
||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
};
|
||||
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
};
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: ExcalidrawElement | undefined,
|
||||
remote: BroadcastedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (
|
||||
local &&
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.draggingElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
// the lowest versionNonce
|
||||
(local.version === remote.version &&
|
||||
local.versionNonce < remote.versionNonce))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const reconcileElements = (
|
||||
localElements: readonly ExcalidrawElement[],
|
||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
||||
localAppState: AppState,
|
||||
): ReconciledElements => {
|
||||
const localElementsData =
|
||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
||||
|
||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
||||
|
||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
||||
|
||||
let cursor = 0;
|
||||
let offset = 0;
|
||||
|
||||
let remoteElementIdx = -1;
|
||||
for (const remoteElement of remoteElements) {
|
||||
remoteElementIdx++;
|
||||
|
||||
const local = localElementsData.get(remoteElement.id);
|
||||
|
||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// Unless the remote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
// parent may not be defined in case the remote client is running an older
|
||||
// excalidraw version
|
||||
const parent =
|
||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
||||
remoteElements[remoteElementIdx - 1]?.id ||
|
||||
null;
|
||||
|
||||
if (parent != null) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
|
||||
// ^ indicates the element is the first in elements array
|
||||
if (parent === "^") {
|
||||
offset++;
|
||||
if (cursor === 0) {
|
||||
reconciledElements.unshift(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor - offset,
|
||||
]);
|
||||
} else {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
}
|
||||
} else {
|
||||
let idx = localElementsData.has(parent)
|
||||
? localElementsData.get(parent)![1]
|
||||
: null;
|
||||
if (idx != null) {
|
||||
idx += offset;
|
||||
}
|
||||
if (idx != null && idx >= cursor) {
|
||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
idx + 1 - offset,
|
||||
]);
|
||||
cursor = idx + 1;
|
||||
} else if (idx != null) {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
}
|
||||
}
|
||||
// no parent z-index information, local element exists → replace in place
|
||||
} else if (local) {
|
||||
reconciledElements[local[1]] = remoteElement;
|
||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
||||
// otherwise push to the end
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
||||
(element) => !duplicates.has(element),
|
||||
);
|
||||
|
||||
return ret as ReconciledElements;
|
||||
};
|
@ -1,12 +1,19 @@
|
||||
import React from "react";
|
||||
import { 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 { LanguageList } from "./LanguageList";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { LanguageList } from "../app-language/LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
theme: Theme | "system";
|
||||
setTheme: (theme: Theme | "system") => void;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<MainMenu>
|
||||
@ -20,22 +27,35 @@ export const AppMainMenu: React.FC<{
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.ItemLink
|
||||
icon={PlusPromoIcon}
|
||||
icon={ExcalLogo}
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||
className="ExcalidrawPlus"
|
||||
className=""
|
||||
>
|
||||
Excalidraw+
|
||||
</MainMenu.ItemLink>
|
||||
<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.DefaultItems.ToggleTheme />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
theme={props.theme}
|
||||
onSelect={props.setTheme}
|
||||
/>
|
||||
<MainMenu.ItemCustom>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</MainMenu.ItemCustom>
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
||||
shortcut={null}
|
||||
icon={PlusPromoIcon}
|
||||
icon={loginIcon}
|
||||
>
|
||||
Try Excalidraw Plus!
|
||||
Sign up
|
||||
</WelcomeScreen.Center.MenuItemLink>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
|
@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
|
||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||
import {
|
||||
import type {
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
name: string,
|
||||
) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
|
||||
@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
|
||||
.ref(`/migrations/scenes/${id}`)
|
||||
.put(blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 2, name: appState.name }),
|
||||
data: JSON.stringify({ version: 2, name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: Partial<AppState>;
|
||||
files: BinaryFiles;
|
||||
name: string;
|
||||
onError: (error: Error) => void;
|
||||
onSuccess: () => void;
|
||||
}> = ({ elements, appState, files, onError, onSuccess }) => {
|
||||
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card color="primary">
|
||||
@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
onClick={async () => {
|
||||
try {
|
||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||
await exportToExcalidrawPlus(elements, appState, files);
|
||||
await exportToExcalidrawPlus(elements, appState, files, name);
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../packages/excalidraw/constants";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
|
@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
|
||||
|
||||
window.open(
|
||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||
"_blank",
|
||||
"noopener noreferrer",
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { StoreAction } from "../../packages/excalidraw";
|
||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
import type {
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
ExcalidrawImperativeAPI,
|
||||
@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
};
|
||||
|
@ -10,18 +10,29 @@
|
||||
* (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 {
|
||||
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,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||
import { debounce } from "../../packages/excalidraw/utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
import { FileManager } from "./FileManager";
|
||||
@ -183,3 +194,52 @@ export class LocalData {
|
||||
},
|
||||
});
|
||||
}
|
||||
export class LibraryIndexedDBAdapter {
|
||||
/** IndexedDB database and store name */
|
||||
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
|
||||
/** library data store key */
|
||||
private static key = "libraryData";
|
||||
|
||||
private static store = createStore(
|
||||
`${LibraryIndexedDBAdapter.idb_name}-db`,
|
||||
`${LibraryIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async load() {
|
||||
const IDBData = await get<LibraryPersistedData>(
|
||||
LibraryIndexedDBAdapter.key,
|
||||
LibraryIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
return IDBData || null;
|
||||
}
|
||||
|
||||
static save(data: LibraryPersistedData): MaybePromise<void> {
|
||||
return set(
|
||||
LibraryIndexedDBAdapter.key,
|
||||
data,
|
||||
LibraryIndexedDBAdapter.store,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** LS Adapter used only for migrating LS library data
|
||||
* to indexedDB */
|
||||
export class LibraryLocalStorageMigrationAdapter {
|
||||
static load() {
|
||||
const LSData = localStorage.getItem(
|
||||
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
|
||||
);
|
||||
if (LSData != null) {
|
||||
const libraryItems: ImportedDataState["libraryItems"] =
|
||||
JSON.parse(LSData);
|
||||
if (libraryItems) {
|
||||
return { libraryItems };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
static clear() {
|
||||
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import {
|
||||
import { reconcileElements } from "../../packages/excalidraw";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
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 {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
@ -18,10 +20,11 @@ import {
|
||||
decryptData,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { SyncableExcalidrawElement } from ".";
|
||||
import { getSyncableElements } from ".";
|
||||
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -230,7 +233,7 @@ export const saveToFirebase = async (
|
||||
!socket ||
|
||||
isSavedToFirebase(portal, elements)
|
||||
) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const firebase = await loadFirestore();
|
||||
@ -238,56 +241,59 @@ export const saveToFirebase = async (
|
||||
|
||||
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);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
elements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.set(docRef, sceneDocument);
|
||||
transaction.set(docRef, storedScene);
|
||||
|
||||
return {
|
||||
elements,
|
||||
reconciledElements: null,
|
||||
};
|
||||
return storedScene;
|
||||
}
|
||||
|
||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
||||
const prevElements = getSyncableElements(
|
||||
await decryptElements(prevDocData, roomKey),
|
||||
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
||||
const prevStoredElements = getSyncableElements(
|
||||
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
||||
);
|
||||
|
||||
const reconciledElements = getSyncableElements(
|
||||
reconcileElements(elements, prevElements, appState),
|
||||
reconcileElements(
|
||||
elements,
|
||||
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
||||
appState,
|
||||
),
|
||||
);
|
||||
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.update(docRef, sceneDocument);
|
||||
return {
|
||||
elements,
|
||||
reconciledElements,
|
||||
};
|
||||
transaction.update(docRef, storedScene);
|
||||
|
||||
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
|
||||
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 (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
socket: Socket | null,
|
||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
||||
@ -298,14 +304,14 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
await decryptElements(storedScene, roomKey),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
FirebaseSceneVersionCache.set(socket, elements);
|
||||
}
|
||||
|
||||
return restoreElements(elements, null);
|
||||
return elements;
|
||||
};
|
||||
|
||||
export const loadFilesFromFirebase = async (
|
||||
|
@ -9,38 +9,39 @@ import {
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { restore } from "../../packages/excalidraw/data/restore";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||
import type { WS_SUBTYPES } from "../app_constants";
|
||||
import {
|
||||
DELETED_ELEMENT_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
ROOM_ID_BYTES,
|
||||
WS_SUBTYPES,
|
||||
} from "../app_constants";
|
||||
import { encodeFilesForUpload } from "./FileManager";
|
||||
import { saveFilesToFirebase } from "./firebase";
|
||||
|
||||
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
||||
_brand: "SyncableExcalidrawElement";
|
||||
};
|
||||
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"SyncableExcalidrawElement">;
|
||||
|
||||
export const isSyncableElement = (
|
||||
element: ExcalidrawElement,
|
||||
element: OrderedExcalidrawElement,
|
||||
): element is SyncableExcalidrawElement => {
|
||||
if (element.isDeleted) {
|
||||
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
||||
@ -51,7 +52,9 @@ export const isSyncableElement = (
|
||||
return !isInvisiblySmallElement(element);
|
||||
};
|
||||
|
||||
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
export const getSyncableElements = (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
) =>
|
||||
elements.filter((element) =>
|
||||
isSyncableElement(element),
|
||||
) as SyncableExcalidrawElement[];
|
||||
@ -266,7 +269,6 @@ export const loadScene = async (
|
||||
// in the scene database/localStorage, and instead fetch them async
|
||||
// from a different database
|
||||
files: data.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import type { AppState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "../../packages/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
|
||||
try {
|
||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
const librarySize = library?.length || 0;
|
||||
|
||||
return appStateSize + collabSize + librarySize + getElementsStorageSize();
|
||||
return appStateSize + collabSize + getElementsStorageSize();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
@ -20,7 +20,7 @@
|
||||
name="description"
|
||||
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 -->
|
||||
<meta property="og:site_name" content="Excalidraw" />
|
||||
@ -35,7 +35,7 @@
|
||||
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."
|
||||
/>
|
||||
<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 -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
@ -51,7 +51,7 @@
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://excalidraw.com/og-twitter-v2.png"
|
||||
content="https://excalidraw.com/og-image-3.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
@ -64,12 +64,30 @@
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
<script>
|
||||
try {
|
||||
//
|
||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
function setTheme(theme) {
|
||||
if (theme === "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>
|
||||
<style>
|
||||
html.dark {
|
||||
@ -77,8 +95,13 @@
|
||||
color: #fff;
|
||||
}
|
||||
</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>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@ -97,8 +120,55 @@
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</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="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
@ -106,23 +176,8 @@
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
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/fonts.css" type="text/css" />
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
||||
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
@ -139,7 +194,6 @@
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
@ -196,7 +250,6 @@
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.tsx"></script>
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
@ -229,6 +282,5 @@
|
||||
}
|
||||
</script>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -4,6 +4,13 @@
|
||||
&.theme--dark {
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
@ -18,6 +25,7 @@
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0.6em;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
@ -31,8 +39,12 @@
|
||||
background-color: #ecfdf5;
|
||||
color: #064e3c;
|
||||
}
|
||||
&.ExcalidrawPlus {
|
||||
&.highlighted {
|
||||
color: var(--color-promo);
|
||||
font-weight: 700;
|
||||
.dropdown-menu-item__icon g {
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,16 +25,29 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"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"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
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";
|
||||
@ -18,10 +18,12 @@ import {
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
||||
import 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";
|
||||
@ -69,20 +71,20 @@ const ActiveRoomDialog = ({
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
collabAPI.setErrorMessage(error.message);
|
||||
} 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();
|
||||
};
|
||||
|
||||
@ -275,6 +277,14 @@ export const ShareDialog = (props: {
|
||||
}) => {
|
||||
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
|
||||
const { openDialog } = useUIAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (openDialog) {
|
||||
setShareDialogState({ isOpen: false });
|
||||
}
|
||||
}, [openDialog, setShareDialogState]);
|
||||
|
||||
if (!shareDialogState.isOpen) {
|
||||
return null;
|
||||
}
|
||||
@ -285,6 +295,6 @@ export const ShareDialog = (props: {
|
||||
collabAPI={props.collabAPI}
|
||||
onExportToBackend={props.onExportToBackend}
|
||||
type={shareDialogState.type}
|
||||
></ShareDialogInner>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
class="welcome-screen-center"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-center__logo virgil welcome-screen-decor"
|
||||
class="welcome-screen-center__logo excalifont welcome-screen-decor"
|
||||
>
|
||||
<div
|
||||
class="ExcalidrawLogo is-small"
|
||||
@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
</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.
|
||||
</div>
|
||||
@ -224,24 +224,14 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<rect
|
||||
height="4"
|
||||
rx="1"
|
||||
width="18"
|
||||
x="3"
|
||||
y="8"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="8"
|
||||
y2="21"
|
||||
<path
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
|
||||
d="M21 12h-13l3 -3"
|
||||
/>
|
||||
<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>
|
||||
</svg>
|
||||
@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Try Excalidraw Plus!
|
||||
Sign up
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -1,12 +1,18 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
updateSceneData,
|
||||
waitFor,
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
import ExcalidrawApp from "../App";
|
||||
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
||||
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
||||
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
|
||||
import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "../../packages/excalidraw/actions/actionHistory";
|
||||
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
@ -56,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", () => {
|
||||
it("creating room should reset deleted elements", async () => {
|
||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// To update the scene with deleted elements before starting collab
|
||||
updateSceneData({
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", id: "A" }),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "B",
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
window.collab.startCollaboration(null);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
const rect1Props = {
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
height: 200,
|
||||
width: 100,
|
||||
} as const;
|
||||
|
||||
const rect2Props = {
|
||||
type: "rectangle",
|
||||
id: "B",
|
||||
width: 100,
|
||||
height: 200,
|
||||
} as const;
|
||||
|
||||
const rect1 = API.createElement({ ...rect1Props });
|
||||
const rect2 = API.createElement({ ...rect2Props });
|
||||
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1, rect2]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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"]);
|
||||
});
|
||||
});
|
70
excalidraw-app/useHandleAppTheme.ts
Normal file
70
excalidraw-app/useHandleAppTheme.ts
Normal 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 };
|
||||
};
|
@ -4,6 +4,8 @@ import svgrPlugin from "vite-plugin-svgr";
|
||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
@ -21,6 +23,14 @@ export default defineConfig({
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames(chunkInfo) {
|
||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||
// put on root so we are flexible about the CDN path
|
||||
return "[name]-[hash][extname]";
|
||||
}
|
||||
|
||||
return "assets/[name]-[hash][extname]";
|
||||
},
|
||||
// Creating separate chunk for locales except for en and percentages.json so they
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
@ -40,6 +50,7 @@ export default defineConfig({
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
woff2BrowserPlugin(),
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
@ -189,6 +200,9 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
}),
|
||||
createHtmlPlugin({
|
||||
minify: true,
|
||||
}),
|
||||
],
|
||||
publicDir: "../public",
|
||||
});
|
||||
|
34
package.json
34
package.json
@ -1,6 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "excalidraw-monorepo",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"workspaces": [
|
||||
"excalidraw-app",
|
||||
"packages/excalidraw",
|
||||
@ -8,26 +9,15 @@
|
||||
"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": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
@ -50,7 +40,7 @@
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.0.1",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
@ -60,9 +50,9 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
@ -86,6 +76,12 @@
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
"release:excalidraw": "node scripts/release.js"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -15,14 +15,42 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### 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)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `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
|
||||
@ -57,10 +85,12 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
|
||||
## 0.17.1 (2023-11-28)
|
||||
## 0.17.3 (2024-02-09)
|
||||
|
||||
### 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.
|
||||
|
||||
```
|
||||
@ -69,14 +99,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
|
||||
|
||||
### Fixes
|
||||
|
||||
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||
|
||||
---
|
||||
|
||||
## 0.17.0 (2023-11-14)
|
||||
|
||||
### Features
|
||||
|
@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
@ -58,5 +59,5 @@ export const actionAddToLibrary = register({
|
||||
};
|
||||
});
|
||||
},
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
label: "labels.addToLibrary",
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import type { Alignment } from "../align";
|
||||
import { alignElements } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
@ -10,18 +11,19 @@ import {
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
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 { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
@ -59,6 +61,8 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -68,7 +72,7 @@ export const actionAlignTop = register({
|
||||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -90,6 +94,8 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -99,7 +105,7 @@ export const actionAlignBottom = register({
|
||||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -121,6 +127,8 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -130,7 +138,7 @@ export const actionAlignLeft = register({
|
||||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -152,6 +160,8 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -161,7 +171,7 @@ export const actionAlignRight = register({
|
||||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -183,6 +193,8 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -192,7 +204,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
@ -210,6 +222,8 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -219,7 +233,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
@ -23,20 +23,22 @@ import {
|
||||
isTextBindableContainer,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { getFontString } from "../utils";
|
||||
import type { AppState } from "../types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { arrayToMap, getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
label: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -49,7 +51,7 @@ export const actionUnbindText = register({
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
const { width, height } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
@ -58,12 +60,15 @@ export const actionUnbindText = register({
|
||||
element.id,
|
||||
);
|
||||
resetOriginalContainerCache(element.id);
|
||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
x,
|
||||
y,
|
||||
@ -81,14 +86,14 @@ export const actionUnbindText = register({
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
label: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -137,6 +142,7 @@ export const actionBindText = register({
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
@ -145,7 +151,11 @@ export const actionBindText = register({
|
||||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
@ -153,7 +163,7 @@ export const actionBindText = register({
|
||||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -173,6 +183,8 @@ const pushTextAboveContainer = (
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
@ -191,12 +203,14 @@ const pushContainerBelowText = (
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 0, container);
|
||||
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
export const actionWrapTextInContainer = register({
|
||||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
label: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -283,16 +297,22 @@ export const actionWrapTextInContainer = register({
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
@ -303,7 +323,7 @@ export const actionWrapTextInContainer = register({
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -1,15 +1,30 @@
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||
import {
|
||||
handIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
zoomAreaIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
ZoomResetIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
THEME,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import type { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@ -20,11 +35,14 @@ import {
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { SceneBounds } from "../element/bounds";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
label: "labels.canvasBackground",
|
||||
paletteName: "Change canvas background color",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@ -35,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
storeAction: !!value.viewBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
@ -59,6 +79,9 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
label: "labels.clearCanvas",
|
||||
paletteName: "Clear canvas",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@ -81,21 +104,23 @@ export const actionClearCanvas = register({
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
stats: appState.stats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
label: "buttons.zoomIn",
|
||||
viewMode: true,
|
||||
icon: ZoomInIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
@ -111,16 +136,17 @@ export const actionZoomIn = register({
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
disabled={appState.zoom.value >= MAX_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
@ -133,6 +159,8 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
label: "buttons.zoomOut",
|
||||
icon: ZoomOutIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
@ -149,16 +177,17 @@ export const actionZoomOut = register({
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
disabled={appState.zoom.value <= MIN_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
@ -171,6 +200,8 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
label: "buttons.resetZoom",
|
||||
icon: ZoomResetIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
@ -187,7 +218,7 @@ export const actionResetZoom = register({
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
@ -262,8 +293,8 @@ export const zoomToFitBounds = ({
|
||||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, 0.1),
|
||||
30.0,
|
||||
Math.max(newZoomValue, MIN_ZOOM),
|
||||
MAX_ZOOM,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
let appStateWidth = appState.width;
|
||||
@ -308,7 +339,7 @@ export const zoomToFitBounds = ({
|
||||
scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
};
|
||||
|
||||
@ -340,6 +371,8 @@ export const zoomToFit = ({
|
||||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
label: "labels.zoomToFitViewport",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -363,6 +396,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
label: "helpDialog.zoomToSelection",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -385,6 +420,8 @@ export const actionZoomToFitSelection = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
label: "helpDialog.zoomToFit",
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
@ -405,6 +442,13 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
label: (_, appState) => {
|
||||
return appState.theme === THEME.DARK
|
||||
? "buttons.lightMode"
|
||||
: "buttons.darkMode";
|
||||
},
|
||||
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
@ -414,7 +458,7 @@ export const actionToggleTheme = register({
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
@ -425,6 +469,7 @@ export const actionToggleTheme = register({
|
||||
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
@ -451,7 +496,7 @@ export const actionToggleEraserTool = register({
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
@ -459,7 +504,11 @@ export const actionToggleEraserTool = register({
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
label: "toolBar.hand",
|
||||
paletteName: "Toggle hand tool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: handIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
@ -486,7 +535,7 @@ export const actionToggleHandTool = register({
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -13,9 +13,13 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
label: "labels.copy",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
@ -28,7 +32,7 @@ export const actionCopy = register({
|
||||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
@ -37,16 +41,16 @@ export const actionCopy = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
label: "labels.paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, data, app) => {
|
||||
let types;
|
||||
@ -63,7 +67,7 @@ export const actionPaste = register({
|
||||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
@ -72,7 +76,7 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
@ -85,7 +89,7 @@ export const actionPaste = register({
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
@ -94,32 +98,34 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
label: "labels.cut",
|
||||
icon: cutIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
actionCopy.perform(elements, appState, event, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
label: "labels.copyAsSvg",
|
||||
icon: svgIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
@ -138,10 +144,11 @@ export const actionCopyAsSvg = register({
|
||||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
},
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@ -150,23 +157,25 @@ export const actionCopyAsSvg = register({
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
keywords: ["svg", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
label: "labels.copyAsPng",
|
||||
icon: pngIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@ -184,6 +193,7 @@ export const actionCopyAsPng = register({
|
||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
});
|
||||
return {
|
||||
appState: {
|
||||
@ -199,7 +209,7 @@ export const actionCopyAsPng = register({
|
||||
}),
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@ -208,19 +218,20 @@ export const actionCopyAsPng = register({
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
keywords: ["png", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
label: "labels.copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@ -236,9 +247,13 @@ export const copyText = register({
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
try {
|
||||
copyTextToSystemClipboard(text);
|
||||
} catch (e) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
@ -252,5 +267,5 @@ export const copyText = register({
|
||||
.some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
keywords: ["text", "clipboard", "copy"],
|
||||
});
|
||||
|
@ -4,20 +4,28 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => isFrameLikeElement(el)),
|
||||
@ -28,6 +36,26 @@ const deleteSelectedElements = (
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
if (el.boundElements) {
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene
|
||||
.getNonDeletedElementsMap()
|
||||
.get(candidate.id);
|
||||
if (bound && isElbowArrow(bound)) {
|
||||
mutateElement(bound, {
|
||||
startBinding:
|
||||
el.id === bound.startBinding?.elementId
|
||||
? null
|
||||
: bound.startBinding,
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId
|
||||
? null
|
||||
: bound.endBinding,
|
||||
});
|
||||
mutateElbowArrow(bound, elementsMap, bound.points);
|
||||
}
|
||||
});
|
||||
}
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
@ -72,8 +100,10 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
label: "labels.delete",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
@ -81,7 +111,8 @@ export const actionDeleteSelected = register({
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
@ -109,7 +140,7 @@ export const actionDeleteSelected = register({
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
@ -126,7 +157,11 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
selectedPointsIndices,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
@ -141,11 +176,11 @@ export const actionDeleteSelected = register({
|
||||
: [0],
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
deleteSelectedElements(elements, appState, app);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
@ -161,13 +196,14 @@ export const actionDeleteSelected = register({
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
storeAction: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
),
|
||||
)
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
keyTest: (event, appState, elements) =>
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
|
@ -3,15 +3,17 @@ import {
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import type { Distribution } from "../distribute";
|
||||
import { distributeElements } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
@ -49,6 +51,7 @@ const distributeSelectedElements = (
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
label: "labels.distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@ -57,7 +60,7 @@ export const distributeHorizontally = register({
|
||||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -79,6 +82,7 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
label: "labels.distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@ -87,7 +91,7 @@ export const distributeVertically = register({
|
||||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@ -12,9 +12,9 @@ import {
|
||||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import type { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
@ -31,14 +31,21 @@ import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
label: "labels.duplicateSelection",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, formData, app) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
return false;
|
||||
@ -47,16 +54,15 @@ export const actionDuplicateSelection = register({
|
||||
return {
|
||||
elements,
|
||||
appState: ret.appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.duplicateSelection",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
@ -85,6 +91,7 @@ const duplicateElements = (
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
@ -96,6 +103,7 @@ const duplicateElements = (
|
||||
y: element.y + GRID_SIZE / 2,
|
||||
},
|
||||
);
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
@ -233,8 +241,10 @@ const duplicateElements = (
|
||||
}
|
||||
|
||||
// step (3)
|
||||
|
||||
const finalElements = finalElementsReversed.reverse();
|
||||
const finalElements = syncMovedIndices(
|
||||
finalElementsReversed.reverse(),
|
||||
arrayToMap(newElements),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { Excalidraw } from "../index";
|
||||
import { queryByTestId, fireEvent } from "@testing-library/react";
|
||||
import { render } from "../tests/test-utils";
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
@ -10,11 +13,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
|
||||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
label: (elements, appState, app) => {
|
||||
const selected = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
icon: (appState, elements) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
|
||||
},
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
!selectedElements.some((element) => element.locked && element.frameId)
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -44,24 +67,9 @@ export const actionToggleElementLock = register({
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
const selected = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
keyTest: (event, appState, elements, app) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
@ -77,10 +85,16 @@ export const actionToggleElementLock = register({
|
||||
|
||||
export const actionUnlockAllElements = register({
|
||||
name: "unlockAllElements",
|
||||
paletteName: "Unlock all elements",
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
predicate: (elements) => {
|
||||
return elements.some((element) => element.locked);
|
||||
icon: UnlockedIcon,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 0 &&
|
||||
elements.some((element) => element.locked)
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
@ -98,8 +112,8 @@ export const actionUnlockAllElements = register({
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.elementLock.unlockAll",
|
||||
label: "labels.elementLock.unlockAll",
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { questionCircle, saveAs } from "../components/icons";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@ -16,24 +16,26 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
import type { Theme } from "../element/types";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
label: "labels.fileTitle",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
return {
|
||||
appState: { ...appState, name: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
||||
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
value={app.getName()}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
isNameEditable={
|
||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
||||
}
|
||||
ignoreFocus={data?.ignoreFocus ?? false}
|
||||
/>
|
||||
),
|
||||
@ -41,11 +43,12 @@ export const actionChangeProjectName = register({
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
label: "imageExportDialog.scale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
@ -90,11 +93,12 @@ export const actionChangeExportScale = register({
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
label: "imageExportDialog.label.withBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
@ -109,11 +113,12 @@ export const actionChangeExportBackground = register({
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
label: "imageExportDialog.tooltip.embedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
@ -131,6 +136,8 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
icon: ExportIcon,
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@ -144,11 +151,16 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
? await resaveAsImageWithScene(
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
)
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
@ -170,7 +182,7 @@ export const actionSaveToActiveFile = register({
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -179,6 +191,8 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
label: "exportDialog.disk_title",
|
||||
icon: ExportIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
@ -190,9 +204,10 @@ export const actionSaveFileToDisk = register({
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
@ -206,7 +221,7 @@ export const actionSaveFileToDisk = register({
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -227,6 +242,7 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
label: "buttons.load",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@ -244,7 +260,7 @@ export const actionLoadScene = register({
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
@ -255,7 +271,7 @@ export const actionLoadScene = register({
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
@ -264,11 +280,12 @@ export const actionLoadScene = register({
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { arrayToMap, updateActiveTool } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@ -8,28 +8,28 @@ import { register } from "./register";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
_,
|
||||
{ interactiveCanvas, focusContainer, scene },
|
||||
) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (element) {
|
||||
if (isBindingElement(element)) {
|
||||
@ -37,6 +37,8 @@ export const actionFinalize = register({
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@ -48,8 +50,9 @@ export const actionFinalize = register({
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -70,8 +73,8 @@ export const actionFinalize = register({
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.editingElement?.type === "freedraw"
|
||||
? appState.editingElement
|
||||
: appState.newElement?.type === "freedraw"
|
||||
? appState.newElement
|
||||
: null;
|
||||
|
||||
if (multiPointElement) {
|
||||
@ -90,7 +93,9 @@ export const actionFinalize = register({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
@ -125,12 +130,14 @@ export const actionFinalize = register({
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
Scene.getScene(multiPointElement)!,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
elements,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -169,7 +176,8 @@ export const actionFinalize = register({
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
@ -186,17 +194,18 @@ export const actionFinalize = register({
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
? new LinearElementEditor(multiPointElement)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.editingLinearElement !== null ||
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
(!appState.newElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
|
@ -1,26 +1,29 @@
|
||||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import {
|
||||
bindOrUnbindSelectedElements,
|
||||
bindOrUnbindLinearElements,
|
||||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
label: "labels.flipHorizontal",
|
||||
icon: flipHorizontal,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@ -30,20 +33,22 @@ export const actionFlipHorizontal = register({
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
"horizontal",
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
});
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
label: "labels.flipVertical",
|
||||
icon: flipVertical,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@ -53,24 +58,25 @@ export const actionFlipVertical = register({
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
"vertical",
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
});
|
||||
|
||||
const flipSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -86,6 +92,7 @@ const flipSelectedElements = (
|
||||
elementsMap,
|
||||
appState,
|
||||
flipDirection,
|
||||
app,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
@ -97,9 +104,10 @@ const flipSelectedElements = (
|
||||
|
||||
const flipElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
): ExcalidrawElement[] => {
|
||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||
|
||||
@ -109,13 +117,19 @@ const flipElements = (
|
||||
elementsMap,
|
||||
"nw",
|
||||
true,
|
||||
true,
|
||||
flipDirection === "horizontal" ? maxX : minX,
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
);
|
||||
|
||||
(isBindingEnabled(appState)
|
||||
? bindOrUnbindSelectedElements
|
||||
: unbindLinearElements)(selectedElements);
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
elementsMap,
|
||||
app.scene.getNonDeletedElements(),
|
||||
app.scene,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
);
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { frameToolIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
const isSingleFrameSelected = (
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return (
|
||||
@ -19,6 +24,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
label: "labels.selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
@ -39,23 +45,23 @@ export const actionSelectAllElementsInFrame = register({
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
label: "labels.removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
@ -70,23 +76,23 @@ export const actionRemoveAllElementsFromFrame = register({
|
||||
[selectedElement.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
label: "labels.updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
@ -99,16 +105,18 @@ export const actionupdateFrameRendering = register({
|
||||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
name: "setFrameAsActiveTool",
|
||||
label: "toolBar.frame",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: frameToolIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
@ -127,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
|
||||
type: "frame",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -17,8 +17,12 @@ import {
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
@ -27,6 +31,8 @@ import {
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
@ -61,6 +67,8 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
label: "labels.group",
|
||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@ -69,7 +77,7 @@ export const actionGroup = register({
|
||||
});
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
// if everything is already grouped into 1 group, there is nothing to do
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
@ -89,7 +97,7 @@ export const actionGroup = register({
|
||||
]);
|
||||
if (combinedSet.size === elementIdsInGroup.size) {
|
||||
// no incremental ids in the selected ids
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,18 +139,19 @@ export const actionGroup = register({
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(
|
||||
lastElementInGroup as OrderedExcalidrawElement,
|
||||
);
|
||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = nextElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
nextElements = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
];
|
||||
const reorderedElements = syncMovedIndices(
|
||||
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
|
||||
arrayToMap(elementsInGroup),
|
||||
);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
@ -153,11 +162,10 @@ export const actionGroup = register({
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
},
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
elements: reorderedElements,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
enableActionGroup(elements, appState, app),
|
||||
keyTest: (event) =>
|
||||
@ -177,11 +185,15 @@ export const actionGroup = register({
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
label: "labels.ungroup",
|
||||
icon: (appState) => <UngroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
@ -226,7 +238,12 @@ export const actionUngroup = register({
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
getElementsInResizingFrame(
|
||||
nextElements,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
@ -249,14 +266,13 @@ export const actionUngroup = register({
|
||||
return {
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
contextItemLabel: "labels.ungroup",
|
||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -1,105 +1,132 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import type { Action, ActionResult } from "./types";
|
||||
import { UndoIcon, RedoIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import type { History } from "../history";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
import type { SceneElementsMap } from "../element/types";
|
||||
import type { Store } from "../store";
|
||||
import { StoreAction } from "../store";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
updater: () => HistoryEntry | null,
|
||||
const executeHistoryAction = (
|
||||
app: AppClassProperties,
|
||||
appState: Readonly<AppState>,
|
||||
updater: () => [SceneElementsMap, AppState] | void,
|
||||
): ActionResult => {
|
||||
const commitToHistory = false;
|
||||
if (
|
||||
!appState.multiElement &&
|
||||
!appState.resizingElement &&
|
||||
!appState.editingElement &&
|
||||
!appState.draggingElement
|
||||
!appState.newElement &&
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
!appState.selectionElement &&
|
||||
!app.flowChartCreator.isCreatingChart
|
||||
) {
|
||||
const data = updater();
|
||||
if (data === null) {
|
||||
return { commitToHistory };
|
||||
const result = updater();
|
||||
|
||||
if (!result) {
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
const prevElementMap = arrayToMap(prevElements);
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = arrayToMap(nextElements);
|
||||
|
||||
const deletedElements = prevElements.filter(
|
||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap.get(nextElement.id) || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
deletedElements.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
);
|
||||
fixBindingsAfterDeletion(elements, deletedElements);
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
const nextElements = Array.from(nextElementsMap.values());
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, ...data.appState },
|
||||
commitToHistory,
|
||||
syncHistory: true,
|
||||
appState: nextAppState,
|
||||
elements: nextElements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
}
|
||||
return { commitToHistory };
|
||||
|
||||
return { storeAction: StoreAction.NONE };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
type ActionCreator = (history: History, store: Store) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
name: "undo",
|
||||
label: "buttons.undo",
|
||||
icon: UndoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={UndoIcon}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={UndoIcon}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isUndoStackEmpty}
|
||||
data-testid="button-undo"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
name: "redo",
|
||||
label: "buttons.redo",
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={RedoIcon}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={RedoIcon}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isRedoStackEmpty}
|
||||
data-testid="button-redo"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -1,45 +0,0 @@
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, app.scene);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
});
|
77
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
77
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawLinearElement } from "../element/types";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
label: (elements, appState, app) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement | undefined;
|
||||
|
||||
return selectedElement?.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
keywords: ["line"],
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const label = t(
|
||||
selectedElement.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit",
|
||||
);
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={lineEditorIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
55
packages/excalidraw/actions/actionLink.tsx
Normal file
55
packages/excalidraw/actions/actionLink.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||
import { LinkIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { isEmbeddableElement } from "../element/typeChecks";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionLink = register({
|
||||
name: "hyperlink",
|
||||
label: (elements, appState) => getContextMenuLabel(elements, appState),
|
||||
icon: LinkIcon,
|
||||
perform: (elements, appState) => {
|
||||
if (appState.showHyperlinkPopup === "editor") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
showHyperlinkPopup: "editor",
|
||||
openMenu: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.length === 1;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={LinkIcon}
|
||||
aria-label={t(getContextMenuLabel(elements, appState))}
|
||||
title={`${
|
||||
isEmbeddableElement(elements[0])
|
||||
? t("labels.link.labelEmbed")
|
||||
: t("labels.link.label")
|
||||
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
onClick={() => updateData(null)}
|
||||
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
@ -1,19 +1,21 @@
|
||||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
label: "buttons.menu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
@ -28,13 +30,14 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
label: "buttons.edit",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
@ -53,6 +56,8 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
label: "welcomeScreen.defaults.helpHint",
|
||||
icon: HelpIconThin,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
@ -69,7 +74,7 @@ export const actionShortcuts = register({
|
||||
name: "help",
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
|
@ -1,13 +1,20 @@
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import {
|
||||
eyeIcon,
|
||||
microphoneIcon,
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { Collaborator } from "../types";
|
||||
import { StoreAction } from "../store";
|
||||
import type { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
label: "Go to a collaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||
@ -21,7 +28,7 @@ export const actionGoToCollaborator = register({
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
@ -35,18 +42,49 @@ export const actionGoToCollaborator = register({
|
||||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data, appState }) => {
|
||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
||||
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||
data as GoToCollaboratorComponentProps;
|
||||
|
||||
const background = getClientColor(clientId);
|
||||
const background = getClientColor(socketId, collaborator);
|
||||
|
||||
const statusClassNames = clsx({
|
||||
"is-followed": isBeingFollowed,
|
||||
"is-current-user": collaborator.isCurrentUser === true,
|
||||
"is-speaking": collaborator.isSpeaking,
|
||||
"is-in-call": collaborator.isInCall,
|
||||
"is-muted": collaborator.isMuted,
|
||||
});
|
||||
|
||||
const statusIconJSX = collaborator.isInCall ? (
|
||||
collaborator.isSpeaking ? (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-speaking-indicator"
|
||||
title={t("userList.hint.isSpeaking")}
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
) : collaborator.isMuted ? (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-microphone-muted"
|
||||
title={t("userList.hint.micMuted")}
|
||||
>
|
||||
{microphoneMutedIcon}
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return withName ? (
|
||||
<div
|
||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
||||
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||
onClick={() => updateData<Collaborator>(collaborator)}
|
||||
>
|
||||
<Avatar
|
||||
@ -54,32 +92,42 @@ export const actionGoToCollaborator = register({
|
||||
onClick={() => {}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
<div className="UserList__collaborator-name">
|
||||
{collaborator.username}
|
||||
</div>
|
||||
<div
|
||||
className="UserList__collaborator-follow-status-icon"
|
||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
{eyeIcon}
|
||||
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||
{isBeingFollowed && (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-is-followed"
|
||||
title={t("userList.hint.followStatus")}
|
||||
>
|
||||
{eyeIcon}
|
||||
</div>
|
||||
)}
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
/>
|
||||
<div
|
||||
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||
>
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
{statusIconJSX && (
|
||||
<div className="UserList__collaborator-status-icon">
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { Excalidraw } from "../index";
|
||||
import { queryByTestId } from "@testing-library/react";
|
||||
import { render } from "../tests/test-utils";
|
||||
@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
|
||||
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
|
||||
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("element locking", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
@ -22,7 +21,7 @@ describe("element locking", () => {
|
||||
// just in case we change it in the future
|
||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||
|
||||
h.setState({
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: color,
|
||||
});
|
||||
const activeColor = queryByTestId(
|
||||
@ -40,14 +39,14 @@ describe("element locking", () => {
|
||||
// just in case we change it in the future
|
||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||
|
||||
h.setState({
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: color,
|
||||
currentItemFillStyle: "hachure",
|
||||
});
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
|
||||
expect(hachureFillButton).toHaveClass("active");
|
||||
h.setState({
|
||||
API.setAppState({
|
||||
currentItemFillStyle: "solid",
|
||||
});
|
||||
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
||||
@ -57,7 +56,7 @@ describe("element locking", () => {
|
||||
it("should not show fill style when background transparent", () => {
|
||||
UI.clickTool("rectangle");
|
||||
|
||||
h.setState({
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
||||
currentItemFillStyle: "hachure",
|
||||
});
|
||||
@ -69,7 +68,7 @@ describe("element locking", () => {
|
||||
it("should show horizontal text align for text tool", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
h.setState({
|
||||
API.setAppState({
|
||||
currentItemTextAlign: "right",
|
||||
});
|
||||
|
||||
@ -85,7 +84,7 @@ describe("element locking", () => {
|
||||
backgroundColor: "red",
|
||||
fillStyle: "cross-hatch",
|
||||
});
|
||||
h.elements = [rect];
|
||||
API.setElements([rect]);
|
||||
API.setSelectedElements([rect]);
|
||||
|
||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||
@ -98,7 +97,7 @@ describe("element locking", () => {
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "cross-hatch",
|
||||
});
|
||||
h.elements = [rect];
|
||||
API.setElements([rect]);
|
||||
API.setSelectedElements([rect]);
|
||||
|
||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||
@ -114,7 +113,7 @@ describe("element locking", () => {
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.thin,
|
||||
});
|
||||
h.elements = [rect1, rect2];
|
||||
API.setElements([rect1, rect2]);
|
||||
API.setSelectedElements([rect1, rect2]);
|
||||
|
||||
const thinStrokeWidthButton = queryByTestId(
|
||||
@ -133,7 +132,7 @@ describe("element locking", () => {
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.bold,
|
||||
});
|
||||
h.elements = [rect1, rect2];
|
||||
API.setElements([rect1, rect2]);
|
||||
API.setSelectedElements([rect1, rect2]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
|
||||
@ -155,13 +154,15 @@ describe("element locking", () => {
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY.Cascadia,
|
||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||
});
|
||||
h.elements = [rect, text];
|
||||
API.setElements([rect, text]);
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -2,14 +2,19 @@ import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
label: "labels.selectAll",
|
||||
icon: selectAllIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@ -43,12 +48,11 @@ export const actionSelectAll = register({
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
? new LinearElementEditor(elements[0])
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAll",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
});
|
||||
|
@ -12,10 +12,7 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
@ -24,13 +21,18 @@ import {
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
label: "labels.copyStyles",
|
||||
icon: paintIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = [];
|
||||
@ -51,23 +53,24 @@ export const actionCopyStyles = register({
|
||||
...appState,
|
||||
toast: { message: t("toast.copyStyles") },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
});
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
label: "labels.pasteStyles",
|
||||
icon: paintIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = JSON.parse(copiedStyles);
|
||||
const pastedElement = elementsCopied[0];
|
||||
const boundTextElement = elementsCopied[1];
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
return { elements, commitToHistory: false };
|
||||
return { elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
@ -117,7 +120,7 @@ export const actionPasteStyles = register({
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
getLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
@ -128,7 +131,11 @@ export const actionPasteStyles = register({
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -152,10 +159,9 @@ export const actionPasteStyles = register({
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
});
|
||||
|
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { isTextElement } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionTextAutoResize = register({
|
||||
name: "autoResize",
|
||||
label: "labels.autoResize",
|
||||
icon: null,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
text: element.originalText,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,10 +1,15 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
icon: gridIcon,
|
||||
keywords: ["snap"],
|
||||
label: "labels.toggleGrid",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
@ -17,13 +22,12 @@ export const actionToggleGridMode = register({
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
predicate: (element, appState, props) => {
|
||||
return typeof props.gridModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.showGrid",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { magnetIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
label: "buttons.objectsSnapMode",
|
||||
icon: magnetIcon,
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
@ -15,14 +19,13 @@ export const actionToggleObjectsSnapMode = register({
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.objectsSnapMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||
});
|
||||
|
@ -1,21 +1,26 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { abacusIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
label: "stats.fullTitle",
|
||||
icon: abacusIcon,
|
||||
paletteName: "Toggle stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
keywords: ["edit", "attributes", "customize"],
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
checked: (appState) => appState.stats.open,
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
label: "labels.viewMode",
|
||||
paletteName: "Toggle view mode",
|
||||
icon: eyeIcon,
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
@ -14,14 +19,13 @@ export const actionToggleViewMode = register({
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.viewModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.viewMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||
});
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { coffeeIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
label: "buttons.zenMode",
|
||||
icon: coffeeIcon,
|
||||
paletteName: "Toggle zen mode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
@ -14,14 +19,13 @@ export const actionToggleZenMode = register({
|
||||
...appState,
|
||||
zenModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.zenModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.zenMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||
});
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
moveOneLeft,
|
||||
moveOneRight,
|
||||
@ -16,18 +15,21 @@ import {
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
label: "labels.sendBackward",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendBackwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendBackward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@ -47,15 +49,17 @@ export const actionSendBackward = register({
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
label: "labels.bringForward",
|
||||
keywords: ["move up", "zindex", "layer"],
|
||||
icon: BringForwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringForward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@ -75,15 +79,17 @@ export const actionBringForward = register({
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
label: "labels.sendToBack",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendToBackIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendToBack",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
@ -110,16 +116,18 @@ export const actionSendToBack = register({
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
label: "labels.bringToFront",
|
||||
keywords: ["move up", "zindex", "layer"],
|
||||
icon: BringToFrontIcon,
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringToFront",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
|
@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import {
|
||||
import type {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
@ -7,8 +7,11 @@ import {
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
@ -46,13 +49,13 @@ export class ActionManager {
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
|
||||
app: AppClassProperties;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Action } from "./types";
|
||||
import type { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { SubtypeOf } from "../utility-types";
|
||||
import type { SubtypeOf } from "../utility-types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName } from "./types";
|
||||
import type { ActionName } from "./types";
|
||||
|
||||
export type ShortcutName =
|
||||
| SubtypeOf<
|
||||
@ -36,9 +36,22 @@ export type ShortcutName =
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleElementLock"
|
||||
| "resetZoom"
|
||||
| "zoomOut"
|
||||
| "zoomIn"
|
||||
| "zoomToFit"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "zoomToFitSelection"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "setFrameAsActiveTool"
|
||||
| "saveFileToDisk"
|
||||
| "saveToActiveFile"
|
||||
| "toggleShortcuts"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport";
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@ -46,6 +59,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
||||
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
||||
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
||||
commandPalette: [
|
||||
getShortcutKey("CtrlOrCmd+/"),
|
||||
getShortcutKey("CtrlOrCmd+Shift+P"),
|
||||
],
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||
@ -83,10 +100,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
|
||||
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
|
||||
zoomIn: [getShortcutKey("CtrlOrCmd++")],
|
||||
zoomToFitSelection: [getShortcutKey("Shift+3")],
|
||||
zoomToFit: [getShortcutKey("Shift+1")],
|
||||
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
|
||||
toggleEraserTool: [getShortcutKey("E")],
|
||||
toggleHandTool: [getShortcutKey("H")],
|
||||
setFrameAsActiveTool: [getShortcutKey("F")],
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
// if multiple shortcuts available, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
return shortcuts && shortcuts.length > 0
|
||||
? shortcuts[idx] || shortcuts[0]
|
||||
: "";
|
||||
};
|
||||
|
@ -1,14 +1,24 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
import type React from "react";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import type { MarkOptional } from "../utility-types";
|
||||
import type { StoreActionType } from "../store";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
export type ActionSource =
|
||||
| "ui"
|
||||
| "keyboard"
|
||||
| "contextMenu"
|
||||
| "api"
|
||||
| "commandPalette";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
@ -19,14 +29,13 @@ export type ActionResult =
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
storeAction: StoreActionType;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: AppClassProperties,
|
||||
@ -61,6 +70,7 @@ export type ActionName =
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
@ -124,7 +134,10 @@ export type ActionName =
|
||||
| "setFrameAsActiveTool"
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -137,6 +150,20 @@ export type PanelComponentProps = {
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
label:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
keywords?: string[];
|
||||
icon?:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
appState: UIAppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => React.ReactNode);
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
@ -146,13 +173,6 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) => boolean;
|
||||
contextItemLabel?:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
predicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
|
@ -1,6 +1,6 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
@ -9,17 +9,20 @@ export const trackEvent = (
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
typeof window === "undefined" ||
|
||||
import.meta.env.VITE_WORKER_ID ||
|
||||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// comment out to debug in dev
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import { AppState } from "./types";
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import type { AppState } from "./types";
|
||||
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
||||
import type App from "./components/App";
|
||||
import { SVG_NS } from "./constants";
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
EXPORT_SCALES,
|
||||
STATS_PANELS,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
@ -34,12 +34,14 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemRoundness: "round",
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
currentHoveredFontFamily: null,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
newElement: null,
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
@ -65,7 +67,7 @@ export const getDefaultAppState = (): Omit<
|
||||
isRotating: false,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
name: null,
|
||||
contextMenu: null,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
@ -82,7 +84,10 @@ export const getDefaultAppState = (): Omit<
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
stats: {
|
||||
open: false,
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
@ -140,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemArrowType: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemOpacity: { browser: true, export: false, server: false },
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
@ -147,9 +157,10 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
newElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
@ -198,7 +209,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user