mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-28 01:14:25 +01:00
Compare commits
1 Commits
preserve-a
...
aakansha-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5813e9d2 |
@@ -4,10 +4,5 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
|||||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
|
||||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
|
||||||
|
|
||||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
|
||||||
REACT_APP_PORTAL_URL=
|
|
||||||
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||||
|
|||||||
@@ -4,14 +4,8 @@ REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|||||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
|
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
|
||||||
# Fill to set socket server URL used for collaboration.
|
|
||||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
|
||||||
REACT_APP_WS_SERVER_URL=
|
|
||||||
|
|
||||||
REACT_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"}'
|
REACT_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"}'
|
||||||
|
|
||||||
# production-only vars
|
# production-only vars
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|
||||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
|
||||||
|
|||||||
1
.github/workflows/autorelease-excalidraw.yml
vendored
1
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -23,5 +23,4 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core
|
|
||||||
yarn autorelease
|
yarn autorelease
|
||||||
|
|||||||
55
.github/workflows/autorelease-preview.yml
vendored
55
.github/workflows/autorelease-preview.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Auto release preview @excalidraw/excalidraw-preview
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Auto-release-excalidraw-preview:
|
|
||||||
name: Auto release preview
|
|
||||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: React to release comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
reactions: "+1"
|
|
||||||
- name: Get PR SHA
|
|
||||||
id: sha
|
|
||||||
uses: actions/github-script@v4
|
|
||||||
with:
|
|
||||||
result-encoding: string
|
|
||||||
script: |
|
|
||||||
const { owner, repo, number } = context.issue;
|
|
||||||
const pr = await github.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: number,
|
|
||||||
});
|
|
||||||
return pr.data.head.sha
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
ref: ${{ steps.sha.outputs.result }}
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Setup Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 14.x
|
|
||||||
- name: Set up publish access
|
|
||||||
run: |
|
|
||||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
- name: Auto release preview
|
|
||||||
id: "autorelease"
|
|
||||||
run: |
|
|
||||||
yarn add @actions/core
|
|
||||||
yarn autorelease preview ${{ github.event.issue.number }}
|
|
||||||
- name: Post comment post release
|
|
||||||
if: always()
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
issue-number: ${{ github.event.issue.number }}
|
|
||||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
|
||||||
29
.github/workflows/build-packages.yml
vendored
Normal file
29
.github/workflows/build-packages.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Build packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Setup Node.js 14.x
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
yarn --frozen-lockfile
|
||||||
|
yarn --cwd src/packages/excalidraw
|
||||||
|
yarn --cwd src/packages/utils
|
||||||
|
- name: Build @excalidraw/excalidraw
|
||||||
|
run: |
|
||||||
|
yarn --cwd src/packages/excalidraw run pack
|
||||||
|
- name: Build @excalidraw/utils
|
||||||
|
run: |
|
||||||
|
yarn --cwd src/packages/utils run pack
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
- name: Setup Node.js 14.x
|
- name: Setup Node.js 14.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 14.x
|
node-version: 16.x
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
run: |
|
run: |
|
||||||
yarn --frozen-lockfile
|
yarn --frozen-lockfile
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,7 +23,3 @@ static
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
src/packages/excalidraw/types
|
src/packages/excalidraw/types
|
||||||
src/packages/excalidraw/example/public/bundle.js
|
|
||||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
|
||||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
|
||||||
|
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -32,10 +32,6 @@ Last but not least, we're thankful to these companies for offering their service
|
|||||||
|
|
||||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
||||||
|
|
||||||
## Who's integrating Excalidraw
|
|
||||||
|
|
||||||
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
### Shortcuts
|
### Shortcuts
|
||||||
@@ -128,41 +124,14 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
|
|||||||
|
|
||||||
#### Commands
|
#### Commands
|
||||||
|
|
||||||
##### Install the dependencies
|
| Command | Description |
|
||||||
|
| ------------------ | --------------------------------- |
|
||||||
```
|
| `yarn` | Install the dependencies |
|
||||||
yarn
|
| `yarn start` | Run the project |
|
||||||
```
|
| `yarn fix` | Reformat all files with Prettier |
|
||||||
|
| `yarn test` | Run tests |
|
||||||
##### Run the project
|
| `yarn test:update` | Update test snapshots |
|
||||||
|
| `yarn test:code` | Test for formatting with Prettier |
|
||||||
```
|
|
||||||
yarn start
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Reformat all files with Prettier
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn fix
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Run tests
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn test
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Update test snapshots
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn test:update
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Test for formatting with Prettier
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn test:code
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker Compose
|
#### Docker Compose
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
service firebase.storage {
|
service firebase.storage {
|
||||||
match /b/{bucket}/o {
|
match /b/{bucket}/o {
|
||||||
match /{files}/rooms/{room}/{file} {
|
match /{migrations} {
|
||||||
allow get, write: if true;
|
match /{scenes}/{scene} {
|
||||||
}
|
allow get, write: if true;
|
||||||
match /{files}/shareLinks/{shareLink}/{file} {
|
// redundant, but let's be explicit'
|
||||||
allow get, write: if true;
|
allow list: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
package.json
28
package.json
@@ -21,24 +21,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.16.1",
|
||||||
"@testing-library/react": "12.1.5",
|
"@testing-library/react": "12.1.2",
|
||||||
"@tldraw/vec": "1.4.3",
|
"@tldraw/vec": "1.4.3",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "27.4.0",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/react": "17.0.39",
|
"@types/react": "17.0.38",
|
||||||
"@types/react-dom": "17.0.11",
|
"@types/react-dom": "17.0.11",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.29.1",
|
"browser-fs-access": "0.23.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"fake-indexeddb": "3.1.7",
|
"fake-indexeddb": "3.1.7",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.2",
|
"i18next-browser-languagedetector": "6.1.2",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.6.4",
|
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.3.3",
|
"nanoid": "3.1.30",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.16",
|
"perfect-freehand": "1.0.16",
|
||||||
@@ -51,9 +50,9 @@
|
|||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.5.2",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.51.0",
|
"sass": "1.47.0",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.5.5"
|
"typescript": "4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
@@ -62,19 +61,20 @@
|
|||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.3",
|
"@types/pako": "1.0.3",
|
||||||
"@types/resize-observer-browser": "0.1.6",
|
"@types/resize-observer-browser": "0.1.6",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.4",
|
||||||
"dotenv": "10.0.0",
|
"dotenv": "10.0.0",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
|
"firebase-tools": "9.23.0",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "2.4.0",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "12.3.7",
|
"lint-staged": "12.1.7",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.5.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
"@typescript-eslint/typescript-estree": "5.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
@@ -52,25 +52,6 @@
|
|||||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
|
||||||
//
|
|
||||||
// Redirect only the bare root path, so link/room/library urls are not
|
|
||||||
// redirected.
|
|
||||||
//
|
|
||||||
// Putting into index.html for best performance (can't redirect on server
|
|
||||||
// due to location.hash checks).
|
|
||||||
if (
|
|
||||||
window.location.pathname === "/" &&
|
|
||||||
!window.location.hash &&
|
|
||||||
!window.location.search &&
|
|
||||||
// if its present redirect
|
|
||||||
document.cookie.includes("excplus-autoredirect=true")
|
|
||||||
) {
|
|
||||||
window.location.href = "https://app.excalidraw.com";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
@@ -91,6 +72,12 @@
|
|||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<link
|
||||||
|
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
||||||
|
rel="preconnect"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="manifest"
|
rel="manifest"
|
||||||
href="manifest.json"
|
href="manifest.json"
|
||||||
@@ -143,6 +130,26 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.LoadingMessage {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LoadingMessage span {
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.8em 1.2em;
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
@@ -151,10 +158,8 @@
|
|||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1200px) {
|
@media screen and (min-width: 1200px) {
|
||||||
#root {
|
|
||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: default;
|
||||||
-webkit-user-select: auto;
|
-webkit-user-select: auto;
|
||||||
-khtml-user-select: auto;
|
-khtml-user-select: auto;
|
||||||
@@ -171,6 +176,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div class="LoadingMessage">
|
||||||
|
<span>Loading scene...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { exec, execSync } = require("child_process");
|
const { exec, execSync } = require("child_process");
|
||||||
const core = require("@actions/core");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
@@ -16,25 +15,18 @@ const publish = () => {
|
|||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
console.info("Published 🎉");
|
|
||||||
core.setOutput(
|
|
||||||
"result",
|
|
||||||
`**Preview version has been shipped** :rocket:
|
|
||||||
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setOutput("result", "package couldn't be published :warning:!");
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// get files changed between prev and head commit
|
// get files changed between prev and head commit
|
||||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||||
if (error || stderr) {
|
if (error || stderr) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
core.setOutput("result", ":warning: Package couldn't be published!");
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedFiles = stdout.trim().split("\n");
|
const changedFiles = stdout.trim().split("\n");
|
||||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||||
|
|
||||||
@@ -45,33 +37,16 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (!excalidrawPackageFiles.length) {
|
if (!excalidrawPackageFiles.length) {
|
||||||
console.info("Skipping release as no valid diff found");
|
|
||||||
core.setOutput("result", "Skipping release as no valid diff found");
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update package.json
|
// update package.json
|
||||||
|
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
||||||
pkg.name = "@excalidraw/excalidraw-next";
|
pkg.name = "@excalidraw/excalidraw-next";
|
||||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
|
||||||
if (isPreview) {
|
|
||||||
// use pullNumber-commithash as the version for preview
|
|
||||||
const pullRequestNumber = process.argv.slice(3)[0];
|
|
||||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
|
||||||
// replace "excalidraw-next" with "excalidraw-preview"
|
|
||||||
pkg.name = "@excalidraw/excalidraw-preview";
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
|
|
||||||
data = data.trim();
|
|
||||||
}
|
|
||||||
pkg.version = version;
|
|
||||||
|
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
console.info("Publish in progress...");
|
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const crowdinMap = {
|
|||||||
"de-DE": "en-de",
|
"de-DE": "en-de",
|
||||||
"el-GR": "en-el",
|
"el-GR": "en-el",
|
||||||
"es-ES": "en-es",
|
"es-ES": "en-es",
|
||||||
"eu-ES": "en-eu",
|
|
||||||
"fa-IR": "en-fa",
|
"fa-IR": "en-fa",
|
||||||
"fi-FI": "en-fi",
|
"fi-FI": "en-fi",
|
||||||
"fr-FR": "en-fr",
|
"fr-FR": "en-fr",
|
||||||
@@ -43,7 +42,6 @@ const crowdinMap = {
|
|||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
"zh-HK": "en-zhhk",
|
"zh-HK": "en-zhhk",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
"lt-LT": "en-lt",
|
|
||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "en-cs",
|
"cs-CZ": "en-cs",
|
||||||
"kk-KZ": "en-kk",
|
"kk-KZ": "en-kk",
|
||||||
@@ -71,7 +69,6 @@ const flags = {
|
|||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
"kk-KZ": "🇰🇿",
|
"kk-KZ": "🇰🇿",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
"lt-LT": "🇱🇹",
|
|
||||||
"lv-LV": "🇱🇻",
|
"lv-LV": "🇱🇻",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
@@ -105,7 +102,6 @@ const languages = {
|
|||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
"eu-ES": "Euskara",
|
|
||||||
"fa-IR": "فارسی",
|
"fa-IR": "فارسی",
|
||||||
"fi-FI": "Suomi",
|
"fi-FI": "Suomi",
|
||||||
"fr-FR": "Français",
|
"fr-FR": "Français",
|
||||||
@@ -118,7 +114,6 @@ const languages = {
|
|||||||
"kab-KAB": "Taqbaylit",
|
"kab-KAB": "Taqbaylit",
|
||||||
"kk-KZ": "Қазақ тілі",
|
"kk-KZ": "Қазақ тілі",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
"lt-LT": "Lietuvių",
|
|
||||||
"lv-LV": "Latviešu",
|
"lv-LV": "Latviešu",
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const headerForType = {
|
|||||||
perf: "Performance",
|
perf: "Performance",
|
||||||
build: "Build",
|
build: "Build",
|
||||||
};
|
};
|
||||||
const badCommits = [];
|
|
||||||
const getCommitHashForLastVersion = async () => {
|
const getCommitHashForLastVersion = async () => {
|
||||||
try {
|
try {
|
||||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||||
@@ -53,26 +53,19 @@ const getLibraryCommitsSinceLastRelease = async () => {
|
|||||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||||
const messageWithCapitalizeFirst =
|
const messageWithCapitalizeFirst =
|
||||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||||
const prMatch = commit.match(/\(#([0-9]*)\)/);
|
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
||||||
if (prMatch) {
|
|
||||||
const prNumber = prMatch[1];
|
|
||||||
|
|
||||||
// return if the changelog already contains the pr number which would happen for package updates
|
// return if the changelog already contains the pr number which would happen for package updates
|
||||||
if (existingChangeLog.includes(prNumber)) {
|
if (existingChangeLog.includes(prNumber)) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
|
||||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
|
||||||
/\(#[0-9]*\)/,
|
|
||||||
prMarkdown,
|
|
||||||
);
|
|
||||||
commitList[type].push(messageWithPRLink);
|
|
||||||
} else {
|
|
||||||
badCommits.push(commit);
|
|
||||||
commitList[type].push(messageWithCapitalizeFirst);
|
|
||||||
}
|
}
|
||||||
|
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||||
|
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||||
|
/\(#[0-9]*\)/,
|
||||||
|
prMarkdown,
|
||||||
|
);
|
||||||
|
commitList[type].push(messageWithPRLink);
|
||||||
});
|
});
|
||||||
console.info("Bad commits:", badCommits);
|
|
||||||
return commitList;
|
return commitList;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@@ -25,9 +24,9 @@ export const actionAddToLibrary = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return app.library
|
return app.library
|
||||||
.getLatestLibrary()
|
.loadLibrary()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
return app.library.setLibrary([
|
return app.library.saveLibrary([
|
||||||
{
|
{
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
status: "unpublished",
|
status: "unpublished",
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -73,7 +72,6 @@ export const actionAlignTop = register({
|
|||||||
|
|
||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -103,7 +101,6 @@ export const actionAlignBottom = register({
|
|||||||
|
|
||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -133,8 +130,6 @@ export const actionAlignLeft = register({
|
|||||||
|
|
||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -164,8 +159,6 @@ export const actionAlignRight = register({
|
|||||||
|
|
||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -191,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
|
|
||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { VERTICAL_ALIGN } from "../constants";
|
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
|
||||||
getBoundTextElement,
|
|
||||||
measureText,
|
|
||||||
redrawTextBoundingBox,
|
|
||||||
} from "../element/textElement";
|
|
||||||
import {
|
|
||||||
hasBoundTextElement,
|
|
||||||
isTextBindableContainer,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import {
|
|
||||||
ExcalidrawTextContainer,
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
} from "../element/types";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { getFontString } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
|
||||||
name: "unbindText",
|
|
||||||
contextItemLabel: "labels.unbindText",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
contextItemPredicate: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
|
||||||
},
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
selectedElements.forEach((element) => {
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
const { width, height, baseline } = measureText(
|
|
||||||
boundTextElement.originalText,
|
|
||||||
getFontString(boundTextElement),
|
|
||||||
);
|
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
|
||||||
containerId: null,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
baseline,
|
|
||||||
text: boundTextElement.originalText,
|
|
||||||
});
|
|
||||||
mutateElement(element, {
|
|
||||||
boundElements: element.boundElements?.filter(
|
|
||||||
(ele) => ele.id !== boundTextElement.id,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionBindText = register({
|
|
||||||
name: "bindText",
|
|
||||||
contextItemLabel: "labels.bindText",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
contextItemPredicate: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
if (selectedElements.length === 2) {
|
|
||||||
const textElement =
|
|
||||||
isTextElement(selectedElements[0]) ||
|
|
||||||
isTextElement(selectedElements[1]);
|
|
||||||
|
|
||||||
let bindingContainer;
|
|
||||||
if (isTextBindableContainer(selectedElements[0])) {
|
|
||||||
bindingContainer = selectedElements[0];
|
|
||||||
} else if (isTextBindableContainer(selectedElements[1])) {
|
|
||||||
bindingContainer = selectedElements[1];
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
textElement &&
|
|
||||||
bindingContainer &&
|
|
||||||
getBoundTextElement(bindingContainer) === null
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
let textElement: ExcalidrawTextElement;
|
|
||||||
let container: ExcalidrawTextContainer;
|
|
||||||
|
|
||||||
if (
|
|
||||||
isTextElement(selectedElements[0]) &&
|
|
||||||
isTextBindableContainer(selectedElements[1])
|
|
||||||
) {
|
|
||||||
textElement = selectedElements[0];
|
|
||||||
container = selectedElements[1];
|
|
||||||
} else {
|
|
||||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
|
||||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
|
||||||
}
|
|
||||||
mutateElement(textElement, {
|
|
||||||
containerId: container.id,
|
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
|
||||||
});
|
|
||||||
mutateElement(container, {
|
|
||||||
boundElements: (container.boundElements || []).concat({
|
|
||||||
type: "text",
|
|
||||||
id: textElement.id,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(textElement, container);
|
|
||||||
const updatedElements = elements.slice();
|
|
||||||
const textElementIndex = updatedElements.findIndex(
|
|
||||||
(ele) => ele.id === textElement.id,
|
|
||||||
);
|
|
||||||
updatedElements.splice(textElementIndex, 1);
|
|
||||||
const containerIndex = updatedElements.findIndex(
|
|
||||||
(ele) => ele.id === container.id,
|
|
||||||
);
|
|
||||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
|
||||||
return {
|
|
||||||
elements: updatedElements,
|
|
||||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
import { zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { THEME, ZOOM_STEP } from "../constants";
|
import { THEME, ZOOM_STEP } from "../constants";
|
||||||
@@ -9,26 +9,24 @@ import { t } from "../i18n";
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getNewZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import ClearCanvas from "../components/ClearCanvas";
|
import ClearCanvas from "../components/ClearCanvas";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
trackEvent: false,
|
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, ...value },
|
||||||
commitToHistory: !!value.viewBackgroundColor,
|
commitToHistory: !!value.viewBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ appState, updateData }) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@@ -41,8 +39,6 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||||
}
|
}
|
||||||
data-testid="canvas-background-picker"
|
data-testid="canvas-background-picker"
|
||||||
elements={elements}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -51,7 +47,6 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
trackEvent: { category: "canvas" },
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
app.imageCache.clear();
|
app.imageCache.clear();
|
||||||
return {
|
return {
|
||||||
@@ -62,17 +57,12 @@ export const actionClearCanvas = register({
|
|||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
files: {},
|
files: {},
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
penMode: appState.penMode,
|
elementLocked: appState.elementLocked,
|
||||||
penDetected: appState.penDetected,
|
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
|
||||||
appState.activeTool.type === "image"
|
|
||||||
? { ...appState.activeTool, type: "selection" }
|
|
||||||
: appState.activeTool,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -83,19 +73,17 @@ export const actionClearCanvas = register({
|
|||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
trackEvent: { category: "canvas" },
|
perform: (_elements, appState) => {
|
||||||
perform: (_elements, appState, _, app) => {
|
const zoom = getNewZoom(
|
||||||
|
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...getStateForZoom(
|
zoom,
|
||||||
{
|
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
|
||||||
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
|
||||||
},
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -119,19 +107,18 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
trackEvent: { category: "canvas" },
|
perform: (_elements, appState) => {
|
||||||
perform: (_elements, appState, _, app) => {
|
const zoom = getNewZoom(
|
||||||
|
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...getStateForZoom(
|
zoom,
|
||||||
{
|
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
|
||||||
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
|
||||||
},
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -155,18 +142,18 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
trackEvent: { category: "canvas" },
|
perform: (_elements, appState) => {
|
||||||
perform: (_elements, appState, _, app) => {
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...getStateForZoom(
|
zoom: getNewZoom(
|
||||||
|
1 as NormalizedZoomValue,
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
{
|
{
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
x: appState.width / 2,
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
y: appState.height / 2,
|
||||||
nextZoom: getNormalizedZoom(1),
|
|
||||||
},
|
},
|
||||||
appState,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -225,12 +212,14 @@ const zoomToFitElements = (
|
|||||||
? getCommonBounds(selectedElements)
|
? getCommonBounds(selectedElements)
|
||||||
: getCommonBounds(nonDeletedElements);
|
: getCommonBounds(nonDeletedElements);
|
||||||
|
|
||||||
const newZoom = {
|
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
width: appState.width,
|
||||||
width: appState.width,
|
height: appState.height,
|
||||||
height: appState.height,
|
});
|
||||||
}),
|
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
||||||
};
|
left: appState.offsetLeft,
|
||||||
|
top: appState.offsetTop,
|
||||||
|
});
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
@@ -254,7 +243,6 @@ const zoomToFitElements = (
|
|||||||
|
|
||||||
export const actionZoomToSelected = register({
|
export const actionZoomToSelected = register({
|
||||||
name: "zoomToSelection",
|
name: "zoomToSelection",
|
||||||
trackEvent: { category: "canvas" },
|
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.TWO &&
|
event.code === CODES.TWO &&
|
||||||
@@ -265,7 +253,6 @@ export const actionZoomToSelected = register({
|
|||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
trackEvent: { category: "canvas" },
|
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
@@ -276,7 +263,6 @@ export const actionZoomToFit = register({
|
|||||||
|
|
||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
trackEvent: { category: "canvas" },
|
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -299,49 +285,3 @@ export const actionToggleTheme = register({
|
|||||||
),
|
),
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionErase = register({
|
|
||||||
name: "eraser",
|
|
||||||
trackEvent: { category: "toolbar" },
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
let activeTool: AppState["activeTool"];
|
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
|
||||||
activeTool = updateActiveTool(appState, {
|
|
||||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
|
||||||
type: "selection",
|
|
||||||
}),
|
|
||||||
lastActiveToolBeforeEraser: null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
activeTool = updateActiveTool(appState, {
|
|
||||||
type: "eraser",
|
|
||||||
lastActiveToolBeforeEraser: appState.activeTool,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
selectedElementIds: {},
|
|
||||||
selectedGroupIds: {},
|
|
||||||
activeTool,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) => event.key === KEYS.E,
|
|
||||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={eraser}
|
|
||||||
className={clsx("eraser", { active: isEraserActive(appState) })}
|
|
||||||
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
|
||||||
aria-label={t("toolBar.eraser")}
|
|
||||||
onClick={() => {
|
|
||||||
updateData(null);
|
|
||||||
}}
|
|
||||||
size={data?.size || "medium"}
|
|
||||||
></ToolButton>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import {
|
import { copyToClipboard } from "../clipboard";
|
||||||
copyTextToSystemClipboard,
|
|
||||||
copyToClipboard,
|
|
||||||
probablySupportsClipboardWriteText,
|
|
||||||
} from "../clipboard";
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { getSelectedElements } from "../scene/selection";
|
import { getSelectedElements } from "../scene/selection";
|
||||||
import { exportCanvas } from "../data/index";
|
import { exportCanvas } from "../data/index";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||||
|
|
||||||
copyToClipboard(selectedElements, appState, app.files);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -30,10 +23,9 @@ export const actionCopy = register({
|
|||||||
|
|
||||||
export const actionCut = register({
|
export const actionCut = register({
|
||||||
name: "cut",
|
name: "cut",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState, data, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
actionCopy.perform(elements, appState, data, app);
|
actionCopy.perform(elements, appState, data, app);
|
||||||
return actionDeleteSelected.perform(elements, appState);
|
return actionDeleteSelected.perform(elements, appState, data, app);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
contextItemLabel: "labels.cut",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||||
@@ -41,7 +33,6 @@ export const actionCut = register({
|
|||||||
|
|
||||||
export const actionCopyAsSvg = register({
|
export const actionCopyAsSvg = register({
|
||||||
name: "copyAsSvg",
|
name: "copyAsSvg",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
@@ -82,7 +73,6 @@ export const actionCopyAsSvg = register({
|
|||||||
|
|
||||||
export const actionCopyAsPng = register({
|
export const actionCopyAsPng = register({
|
||||||
name: "copyAsPng",
|
name: "copyAsPng",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
@@ -132,35 +122,3 @@ export const actionCopyAsPng = register({
|
|||||||
contextItemLabel: "labels.copyAsPng",
|
contextItemLabel: "labels.copyAsPng",
|
||||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const copyText = register({
|
|
||||||
name: "copyText",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = selectedElements
|
|
||||||
.reduce((acc: string[], element) => {
|
|
||||||
if (isTextElement(element)) {
|
|
||||||
acc.push(element.text);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [])
|
|
||||||
.join("\n\n");
|
|
||||||
copyTextToSystemClipboard(text);
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
contextItemPredicate: (elements, appState) => {
|
|
||||||
return (
|
|
||||||
probablySupportsClipboardWriteText &&
|
|
||||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
contextItemLabel: "labels.copyText",
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { getElementsInGroup } from "../groups";
|
|||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import { updateActiveTool } from "../utils";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -59,7 +58,6 @@ const handleGroupEditingState = (
|
|||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
trackEvent: { category: "element", action: "delete" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
@@ -135,7 +133,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
elementType: "selection",
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: isSomeElementSelected(
|
commitToHistory: isSomeElementSelected(
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {
|
|||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../distribute";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
@@ -39,7 +39,6 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -50,8 +49,7 @@ export const distributeHorizontally = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) => event.altKey && event.code === CODES.H,
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
@@ -69,7 +67,6 @@ export const distributeHorizontally = register({
|
|||||||
|
|
||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -80,8 +77,7 @@ export const distributeVertically = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) => event.altKey && event.code === CODES.V,
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { isBoundToContainer } from "../element/typeChecks";
|
|||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { trackEvent } from "../analytics";
|
||||||
import { load, questionCircle, saveAs } from "../components/icons";
|
import { load, questionCircle, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
@@ -7,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
|||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { resaveAsImageWithScene } from "../data/resave";
|
import { resaveAsImageWithScene } from "../data/resave";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
@@ -22,8 +23,8 @@ import { Theme } from "../element/types";
|
|||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
trackEvent: false,
|
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
|
trackEvent("change", "title");
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||||
@@ -40,7 +41,6 @@ export const actionChangeProjectName = register({
|
|||||||
|
|
||||||
export const actionChangeExportScale = register({
|
export const actionChangeExportScale = register({
|
||||||
name: "changeExportScale",
|
name: "changeExportScale",
|
||||||
trackEvent: { category: "export", action: "scale" },
|
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportScale: value },
|
appState: { ...appState, exportScale: value },
|
||||||
@@ -89,7 +89,6 @@ export const actionChangeExportScale = register({
|
|||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
trackEvent: { category: "export", action: "toggleBackground" },
|
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportBackground: value },
|
appState: { ...appState, exportBackground: value },
|
||||||
@@ -108,7 +107,6 @@ export const actionChangeExportBackground = register({
|
|||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
export const actionChangeExportEmbedScene = register({
|
||||||
name: "changeExportEmbedScene",
|
name: "changeExportEmbedScene",
|
||||||
trackEvent: { category: "export", action: "embedScene" },
|
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
appState: { ...appState, exportEmbedScene: value },
|
||||||
@@ -130,7 +128,6 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
trackEvent: { category: "export" },
|
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
@@ -175,7 +172,6 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
trackEvent: { category: "export" },
|
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(
|
const { fileHandle } = await saveAsJSON(
|
||||||
@@ -204,7 +200,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
icon={saveAs}
|
icon={saveAs}
|
||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useIsMobile()}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
@@ -214,7 +210,6 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
trackEvent: { category: "export" },
|
|
||||||
perform: async (elements, appState, _, app) => {
|
perform: async (elements, appState, _, app) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -248,7 +243,7 @@ export const actionLoadScene = register({
|
|||||||
icon={load}
|
icon={load}
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
data-testid="load-button"
|
data-testid="load-button"
|
||||||
/>
|
/>
|
||||||
@@ -257,7 +252,6 @@ export const actionLoadScene = register({
|
|||||||
|
|
||||||
export const actionExportWithDarkMode = register({
|
export const actionExportWithDarkMode = register({
|
||||||
name: "exportWithDarkMode",
|
name: "exportWithDarkMode",
|
||||||
trackEvent: { category: "export", action: "toggleTheme" },
|
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportWithDarkMode: value },
|
appState: { ...appState, exportWithDarkMode: value },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { updateActiveTool, resetCursor } from "../utils";
|
import { resetCursor } from "../utils";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -14,12 +14,10 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
trackEvent: false,
|
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||||
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
@@ -40,7 +38,6 @@ export const actionFinalize = register({
|
|||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -50,12 +47,8 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
const pendingImageElement =
|
if (appState.pendingImageElement) {
|
||||||
appState.pendingImageElementId &&
|
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||||
scene.getElement(appState.pendingImageElementId);
|
|
||||||
|
|
||||||
if (pendingImageElement) {
|
|
||||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
@@ -126,47 +119,27 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||||
!appState.activeTool.locked &&
|
|
||||||
appState.activeTool.type !== "freedraw"
|
|
||||||
) {
|
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.activeTool.locked &&
|
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||||
appState.activeTool.type !== "freedraw") ||
|
|
||||||
!multiPointElement
|
!multiPointElement
|
||||||
) {
|
) {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeTool: AppState["activeTool"];
|
|
||||||
if (appState.activeTool.type === "eraser") {
|
|
||||||
activeTool = updateActiveTool(appState, {
|
|
||||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
|
||||||
type: "selection",
|
|
||||||
}),
|
|
||||||
lastActiveToolBeforeEraser: null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
activeTool = updateActiveTool(appState, {
|
|
||||||
type: "selection",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
elementType:
|
||||||
activeTool:
|
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||||
(appState.activeTool.locked ||
|
|
||||||
appState.activeTool.type === "freedraw") &&
|
|
||||||
multiPointElement
|
multiPointElement
|
||||||
? appState.activeTool
|
? appState.elementType
|
||||||
: activeTool,
|
: "selection",
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
@@ -174,16 +147,16 @@ export const actionFinalize = register({
|
|||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
multiPointElement &&
|
||||||
!appState.activeTool.locked &&
|
!appState.elementLocked &&
|
||||||
appState.activeTool.type !== "freedraw"
|
appState.elementType !== "freedraw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
pendingImageElementId: null,
|
pendingImageElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
commitToHistory: appState.elementType === "freedraw",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
@@ -192,7 +165,7 @@ export const actionFinalize = register({
|
|||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
PanelComponent: ({ appState, updateData, data }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={done}
|
icon={done}
|
||||||
@@ -200,7 +173,6 @@ export const actionFinalize = register({
|
|||||||
aria-label={t("buttons.done")}
|
aria-label={t("buttons.done")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
visible={appState.multiElement != null}
|
visible={appState.multiElement != null}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const enableActionFlipVertical = (
|
|||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||||
@@ -51,7 +50,6 @@ export const actionFlipHorizontal = register({
|
|||||||
|
|
||||||
export const actionFlipVertical = register({
|
export const actionFlipVertical = register({
|
||||||
name: "flipVertical",
|
name: "flipVertical",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||||
@@ -157,7 +155,7 @@ const flipElement = (
|
|||||||
// calculate new x-coord for transformation
|
// calculate new x-coord for transformation
|
||||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||||
resizeSingleElement(
|
resizeSingleElement(
|
||||||
new Map().set(element.id, element),
|
element,
|
||||||
true,
|
true,
|
||||||
element,
|
element,
|
||||||
usingNWHandle ? "nw" : "ne",
|
usingNWHandle ? "nw" : "ne",
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const enableActionGroup = (
|
|||||||
|
|
||||||
export const actionGroup = register({
|
export const actionGroup = register({
|
||||||
name: "group",
|
name: "group",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@@ -148,7 +147,6 @@ export const actionGroup = register({
|
|||||||
|
|
||||||
export const actionUngroup = register({
|
export const actionUngroup = register({
|
||||||
name: "ungroup",
|
name: "ungroup",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
|
|||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
trackEvent: { category: "history" },
|
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(elements, appState, () => history.undoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history) => ({
|
export const createRedoAction: ActionCreator = (history) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
trackEvent: { category: "history" },
|
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(elements, appState, () => history.redoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { HelpIcon } from "../components/HelpIcon";
|
|||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_, appState) => ({
|
perform: (_, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -30,7 +29,6 @@ export const actionToggleCanvasMenu = register({
|
|||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
export const actionToggleEditMenu = register({
|
||||||
name: "toggleEditMenu",
|
name: "toggleEditMenu",
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_elements, appState) => ({
|
perform: (_elements, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -55,7 +53,6 @@ export const actionToggleEditMenu = register({
|
|||||||
|
|
||||||
export const actionFullScreen = register({
|
export const actionFullScreen = register({
|
||||||
name: "toggleFullScreen",
|
name: "toggleFullScreen",
|
||||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
|
||||||
perform: () => {
|
perform: () => {
|
||||||
if (!isFullScreen()) {
|
if (!isFullScreen()) {
|
||||||
allowFullScreen();
|
allowFullScreen();
|
||||||
@@ -72,7 +69,6 @@ export const actionFullScreen = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
|
||||||
perform: (_elements, appState, _, { focusContainer }) => {
|
perform: (_elements, appState, _, { focusContainer }) => {
|
||||||
if (appState.showHelpDialog) {
|
if (appState.showHelpDialog) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getClientColors } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
@@ -6,7 +6,6 @@ import { register } from "./register";
|
|||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
trackEvent: { category: "collab" },
|
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
const point = value as Collaborator["pointer"];
|
const point = value as Collaborator["pointer"];
|
||||||
if (!point) {
|
if (!point) {
|
||||||
@@ -31,18 +30,28 @@ export const actionGoToCollaborator = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const [clientId, collaborator] = data as [string, Collaborator];
|
const clientId: string | undefined = data?.id;
|
||||||
|
if (!clientId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collaborator = appState.collaborators.get(clientId);
|
||||||
|
|
||||||
|
if (!collaborator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId, appState);
|
const { background, stroke } = getClientColors(clientId, appState);
|
||||||
|
const shortName = getClientInitials(collaborator.username);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
color={background}
|
color={background}
|
||||||
border={stroke}
|
border={stroke}
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
onClick={() => updateData(collaborator.pointer)}
|
||||||
name={collaborator.username || ""}
|
>
|
||||||
src={collaborator.avatarUrl}
|
{shortName}
|
||||||
/>
|
</Avatar>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,15 +30,11 @@ import {
|
|||||||
TextAlignCenterIcon,
|
TextAlignCenterIcon,
|
||||||
TextAlignLeftIcon,
|
TextAlignLeftIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
TextAlignTopIcon,
|
|
||||||
TextAlignBottomIcon,
|
|
||||||
TextAlignMiddleIcon,
|
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
VERTICAL_ALIGN,
|
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
@@ -62,7 +58,6 @@ import {
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
VerticalAlign,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
@@ -150,7 +145,6 @@ const changeFontSize = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
||||||
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
|
||||||
) => {
|
) => {
|
||||||
const newFontSizes = new Set<number>();
|
const newFontSizes = new Set<number>();
|
||||||
|
|
||||||
@@ -166,7 +160,11 @@ const changeFontSize = (
|
|||||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
fontSize: newFontSize,
|
fontSize: newFontSize,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
getContainerElement(oldElement),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
@@ -184,7 +182,7 @@ const changeFontSize = (
|
|||||||
currentItemFontSize:
|
currentItemFontSize:
|
||||||
newFontSizes.size === 1
|
newFontSizes.size === 1
|
||||||
? [...newFontSizes][0]
|
? [...newFontSizes][0]
|
||||||
: fallbackValue ?? appState.currentItemFontSize,
|
: appState.currentItemFontSize,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -194,7 +192,6 @@ const changeFontSize = (
|
|||||||
|
|
||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemStrokeColor && {
|
...(value.currentItemStrokeColor && {
|
||||||
@@ -235,8 +232,6 @@ export const actionChangeStrokeColor = register({
|
|||||||
setActive={(active) =>
|
setActive={(active) =>
|
||||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||||
}
|
}
|
||||||
elements={elements}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -244,7 +239,6 @@ export const actionChangeStrokeColor = register({
|
|||||||
|
|
||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemBackgroundColor && {
|
...(value.currentItemBackgroundColor && {
|
||||||
@@ -278,8 +272,6 @@ export const actionChangeBackgroundColor = register({
|
|||||||
setActive={(active) =>
|
setActive={(active) =>
|
||||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||||
}
|
}
|
||||||
elements={elements}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -287,7 +279,6 @@ export const actionChangeBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -337,7 +328,6 @@ export const actionChangeFillStyle = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -385,7 +375,6 @@ export const actionChangeStrokeWidth = register({
|
|||||||
|
|
||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -434,7 +423,6 @@ export const actionChangeSloppiness = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -482,17 +470,12 @@ export const actionChangeStrokeStyle = register({
|
|||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements,
|
newElementWith(el, {
|
||||||
appState,
|
opacity: value,
|
||||||
(el) =>
|
}),
|
||||||
newElementWith(el, {
|
|
||||||
opacity: value,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -507,6 +490,20 @@ export const actionChangeOpacity = register({
|
|||||||
max="100"
|
max="100"
|
||||||
step="10"
|
step="10"
|
||||||
onChange={(event) => updateData(+event.target.value)}
|
onChange={(event) => updateData(+event.target.value)}
|
||||||
|
onWheel={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const STEP = 10;
|
||||||
|
const MAX = 100;
|
||||||
|
const MIN = 0;
|
||||||
|
const value = +target.value;
|
||||||
|
|
||||||
|
if (event.deltaY < 0 && value < MAX) {
|
||||||
|
updateData(value + STEP);
|
||||||
|
} else if (event.deltaY > 0 && value > MIN) {
|
||||||
|
updateData(value - STEP);
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={
|
value={
|
||||||
getFormValue(
|
getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -522,9 +519,8 @@ export const actionChangeOpacity = register({
|
|||||||
|
|
||||||
export const actionChangeFontSize = register({
|
export const actionChangeFontSize = register({
|
||||||
name: "changeFontSize",
|
name: "changeFontSize",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return changeFontSize(elements, appState, () => value, value);
|
return changeFontSize(elements, appState, () => value);
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -536,25 +532,21 @@ export const actionChangeFontSize = register({
|
|||||||
value: 16,
|
value: 16,
|
||||||
text: t("labels.small"),
|
text: t("labels.small"),
|
||||||
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-small",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 20,
|
value: 20,
|
||||||
text: t("labels.medium"),
|
text: t("labels.medium"),
|
||||||
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-medium",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 28,
|
value: 28,
|
||||||
text: t("labels.large"),
|
text: t("labels.large"),
|
||||||
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-large",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 36,
|
value: 36,
|
||||||
text: t("labels.veryLarge"),
|
text: t("labels.veryLarge"),
|
||||||
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-veryLarge",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
@@ -580,7 +572,6 @@ export const actionChangeFontSize = register({
|
|||||||
|
|
||||||
export const actionDecreaseFontSize = register({
|
export const actionDecreaseFontSize = register({
|
||||||
name: "decreaseFontSize",
|
name: "decreaseFontSize",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return changeFontSize(elements, appState, (element) =>
|
return changeFontSize(elements, appState, (element) =>
|
||||||
Math.round(
|
Math.round(
|
||||||
@@ -602,7 +593,6 @@ export const actionDecreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionIncreaseFontSize = register({
|
export const actionIncreaseFontSize = register({
|
||||||
name: "increaseFontSize",
|
name: "increaseFontSize",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return changeFontSize(elements, appState, (element) =>
|
return changeFontSize(elements, appState, (element) =>
|
||||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||||
@@ -620,7 +610,6 @@ export const actionIncreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(
|
||||||
@@ -634,7 +623,11 @@ export const actionChangeFontFamily = register({
|
|||||||
fontFamily: value,
|
fontFamily: value,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
getContainerElement(oldElement),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,7 +695,6 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
name: "changeTextAlign",
|
name: "changeTextAlign",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(
|
||||||
@@ -712,9 +704,15 @@ export const actionChangeTextAlign = register({
|
|||||||
if (isTextElement(oldElement)) {
|
if (isTextElement(oldElement)) {
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
oldElement,
|
oldElement,
|
||||||
{ textAlign: value },
|
{
|
||||||
|
textAlign: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
getContainerElement(oldElement),
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,121 +727,51 @@ export const actionChangeTextAlign = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
return (
|
<fieldset>
|
||||||
<fieldset>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<ButtonIconSelect<TextAlign | false>
|
||||||
<ButtonIconSelect<TextAlign | false>
|
group="text-align"
|
||||||
group="text-align"
|
options={[
|
||||||
options={[
|
{
|
||||||
{
|
value: "left",
|
||||||
value: "left",
|
text: t("labels.left"),
|
||||||
text: t("labels.left"),
|
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
},
|
||||||
},
|
{
|
||||||
{
|
value: "center",
|
||||||
value: "center",
|
text: t("labels.center"),
|
||||||
text: t("labels.center"),
|
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
},
|
||||||
},
|
{
|
||||||
{
|
value: "right",
|
||||||
value: "right",
|
text: t("labels.right"),
|
||||||
text: t("labels.right"),
|
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
},
|
||||||
},
|
]}
|
||||||
]}
|
value={getFormValue(
|
||||||
value={getFormValue(
|
elements,
|
||||||
elements,
|
appState,
|
||||||
appState,
|
(element) => {
|
||||||
(element) => {
|
if (isTextElement(element)) {
|
||||||
if (isTextElement(element)) {
|
return element.textAlign;
|
||||||
return element.textAlign;
|
|
||||||
}
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
return boundTextElement.textAlign;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
appState.currentItemTextAlign,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const actionChangeVerticalAlign = register({
|
|
||||||
name: "changeVerticalAlign",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
return {
|
|
||||||
elements: changeProperty(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(oldElement) => {
|
|
||||||
if (isTextElement(oldElement)) {
|
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
|
||||||
oldElement,
|
|
||||||
{ verticalAlign: value },
|
|
||||||
);
|
|
||||||
|
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
|
||||||
return newElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return oldElement;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
|
||||||
return (
|
|
||||||
<fieldset>
|
|
||||||
<ButtonIconSelect<VerticalAlign | false>
|
|
||||||
group="text-align"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: VERTICAL_ALIGN.TOP,
|
|
||||||
text: t("labels.alignTop"),
|
|
||||||
icon: <TextAlignTopIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: VERTICAL_ALIGN.MIDDLE,
|
|
||||||
text: t("labels.centerVertically"),
|
|
||||||
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: VERTICAL_ALIGN.BOTTOM,
|
|
||||||
text: t("labels.alignBottom"),
|
|
||||||
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue(elements, appState, (element) => {
|
|
||||||
if (isTextElement(element) && element.containerId) {
|
|
||||||
return element.verticalAlign;
|
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
return boundTextElement.verticalAlign;
|
return boundTextElement.textAlign;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
},
|
||||||
onChange={(value) => updateData(value)}
|
appState.currentItemTextAlign,
|
||||||
/>
|
)}
|
||||||
</fieldset>
|
onChange={(value) => updateData(value)}
|
||||||
);
|
/>
|
||||||
},
|
</fieldset>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeSharpness = register({
|
export const actionChangeSharpness = register({
|
||||||
name: "changeSharpness",
|
name: "changeSharpness",
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@@ -851,10 +779,10 @@ export const actionChangeSharpness = register({
|
|||||||
);
|
);
|
||||||
const shouldUpdateForNonLinearElements = targetElements.length
|
const shouldUpdateForNonLinearElements = targetElements.length
|
||||||
? targetElements.every((el) => !isLinearElement(el))
|
? targetElements.every((el) => !isLinearElement(el))
|
||||||
: !isLinearElementType(appState.activeTool.type);
|
: !isLinearElementType(appState.elementType);
|
||||||
const shouldUpdateForLinearElements = targetElements.length
|
const shouldUpdateForLinearElements = targetElements.length
|
||||||
? targetElements.every(isLinearElement)
|
? targetElements.every(isLinearElement)
|
||||||
: isLinearElementType(appState.activeTool.type);
|
: isLinearElementType(appState.elementType);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -894,8 +822,8 @@ export const actionChangeSharpness = register({
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => element.strokeSharpness,
|
(element) => element.strokeSharpness,
|
||||||
(canChangeSharpness(appState.activeTool.type) &&
|
(canChangeSharpness(appState.elementType) &&
|
||||||
(isLinearElementType(appState.activeTool.type)
|
(isLinearElementType(appState.elementType)
|
||||||
? appState.currentItemLinearStrokeSharpness
|
? appState.currentItemLinearStrokeSharpness
|
||||||
: appState.currentItemStrokeSharpness)) ||
|
: appState.currentItemStrokeSharpness)) ||
|
||||||
null,
|
null,
|
||||||
@@ -908,7 +836,6 @@ export const actionChangeSharpness = register({
|
|||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
export const actionChangeArrowhead = register({
|
||||||
name: "changeArrowhead",
|
name: "changeArrowhead",
|
||||||
trackEvent: false,
|
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
|||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
trackEvent: { category: "canvas" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
@@ -18,8 +17,7 @@ export const actionSelectAll = register({
|
|||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
if (
|
if (
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
!(isTextElement(element) && element.containerId) &&
|
!(isTextElement(element) && element.containerId)
|
||||||
element.locked === false
|
|
||||||
) {
|
) {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import ExcalidrawApp from "../excalidraw-app";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { CODES } from "../keys";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
|
||||||
import { fireEvent, render, screen } from "../tests/test-utils";
|
|
||||||
import { copiedStyles } from "./actionStyles";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
|
||||||
|
|
||||||
describe("actionStyles", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<ExcalidrawApp />);
|
|
||||||
});
|
|
||||||
it("should copy & paste styles via keyboard", () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(10, 10);
|
|
||||||
mouse.up(20, 20);
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(10, 10);
|
|
||||||
mouse.up(20, 20);
|
|
||||||
|
|
||||||
// Change some styles of second rectangle
|
|
||||||
UI.clickLabeledElement("Stroke");
|
|
||||||
UI.clickLabeledElement(t("colors.c92a2a"));
|
|
||||||
UI.clickLabeledElement("Background");
|
|
||||||
UI.clickLabeledElement(t("colors.e64980"));
|
|
||||||
// Fill style
|
|
||||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
|
||||||
// Stroke width
|
|
||||||
fireEvent.click(screen.getByTitle("Bold"));
|
|
||||||
// Stroke style
|
|
||||||
fireEvent.click(screen.getByTitle("Dotted"));
|
|
||||||
// Roughness
|
|
||||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
|
||||||
// Opacity
|
|
||||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
|
||||||
target: { value: "60" },
|
|
||||||
});
|
|
||||||
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
API.setSelectedElements([h.elements[1]]);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
|
||||||
Keyboard.codeDown(CODES.C);
|
|
||||||
});
|
|
||||||
const secondRect = JSON.parse(copiedStyles)[0];
|
|
||||||
expect(secondRect.id).toBe(h.elements[1].id);
|
|
||||||
|
|
||||||
mouse.reset();
|
|
||||||
// Paste styles to first rectangle
|
|
||||||
API.setSelectedElements([h.elements[0]]);
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
|
||||||
Keyboard.codeDown(CODES.V);
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRect = API.getSelectedElement();
|
|
||||||
expect(firstRect.id).toBe(h.elements[0].id);
|
|
||||||
expect(firstRect.strokeColor).toBe("#c92a2a");
|
|
||||||
expect(firstRect.backgroundColor).toBe("#e64980");
|
|
||||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
|
||||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
|
||||||
expect(firstRect.strokeStyle).toBe("dotted");
|
|
||||||
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
|
||||||
expect(firstRect.opacity).toBe(60);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,32 +6,23 @@ import {
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getBoundTextElement } from "../element/textElement";
|
import { getContainerElement } from "../element/textElement";
|
||||||
import { hasBoundTextElement } from "../element/typeChecks";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const elementsCopied = [];
|
|
||||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||||
elementsCopied.push(element);
|
|
||||||
if (element && hasBoundTextElement(element)) {
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
elementsCopied.push(boundTextElement);
|
|
||||||
}
|
|
||||||
if (element) {
|
if (element) {
|
||||||
copiedStyles = JSON.stringify(elementsCopied);
|
copiedStyles = JSON.stringify(element);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -48,64 +39,36 @@ export const actionCopyStyles = register({
|
|||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const elementsCopied = JSON.parse(copiedStyles);
|
const pastedElement = JSON.parse(copiedStyles);
|
||||||
const pastedElement = elementsCopied[0];
|
|
||||||
const boundTextElement = elementsCopied[1];
|
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, commitToHistory: false };
|
return { elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
|
||||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (selectedElementIds.includes(element.id)) {
|
if (appState.selectedElementIds[element.id]) {
|
||||||
let elementStylesToCopyFrom = pastedElement;
|
const newElement = newElementWith(element, {
|
||||||
if (isTextElement(element) && element.containerId) {
|
backgroundColor: pastedElement?.backgroundColor,
|
||||||
elementStylesToCopyFrom = boundTextElement;
|
strokeWidth: pastedElement?.strokeWidth,
|
||||||
}
|
strokeColor: pastedElement?.strokeColor,
|
||||||
if (!elementStylesToCopyFrom) {
|
strokeStyle: pastedElement?.strokeStyle,
|
||||||
return element;
|
fillStyle: pastedElement?.fillStyle,
|
||||||
}
|
opacity: pastedElement?.opacity,
|
||||||
let newElement = newElementWith(element, {
|
roughness: pastedElement?.roughness,
|
||||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
|
||||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
|
||||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
|
||||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
|
||||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
|
||||||
opacity: elementStylesToCopyFrom?.opacity,
|
|
||||||
roughness: elementStylesToCopyFrom?.roughness,
|
|
||||||
});
|
});
|
||||||
|
if (isTextElement(newElement) && isTextElement(element)) {
|
||||||
if (isTextElement(newElement)) {
|
mutateElement(newElement, {
|
||||||
newElement = newElementWith(newElement, {
|
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
fontFamily:
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
textAlign:
|
|
||||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
|
||||||
});
|
});
|
||||||
let container = null;
|
|
||||||
if (newElement.containerId) {
|
|
||||||
container =
|
|
||||||
selectedElements.find(
|
|
||||||
(element) =>
|
|
||||||
isTextElement(newElement) &&
|
|
||||||
element.id === newElement.containerId,
|
|
||||||
) || null;
|
|
||||||
}
|
|
||||||
redrawTextBoundingBox(newElement, container);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newElement.type === "arrow") {
|
redrawTextBoundingBox(
|
||||||
newElement = newElementWith(newElement, {
|
element,
|
||||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
getContainerElement(element),
|
||||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
appState,
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
trackEvent: {
|
|
||||||
category: "canvas",
|
|
||||||
predicate: (appState) => !appState.gridSize,
|
|
||||||
},
|
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
|
trackEvent("view", "mode", "grid");
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { arrayToMap } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionToggleLock = register({
|
|
||||||
name: "toggleLock",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
|
||||||
|
|
||||||
if (!selectedElements.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const operation = getOperation(selectedElements);
|
|
||||||
const selectedElementsMap = arrayToMap(selectedElements);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: elements.map((element) => {
|
|
||||||
if (!selectedElementsMap.has(element.id)) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElementWith(element, { locked: operation === "lock" });
|
|
||||||
}),
|
|
||||||
appState,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
contextItemLabel: (elements, appState) => {
|
|
||||||
const selected = getSelectedElements(elements, appState, false);
|
|
||||||
if (selected.length === 1) {
|
|
||||||
return selected[0].locked
|
|
||||||
? "labels.elementLock.unlock"
|
|
||||||
: "labels.elementLock.lock";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.length > 1) {
|
|
||||||
return getOperation(selected) === "lock"
|
|
||||||
? "labels.elementLock.lockAll"
|
|
||||||
: "labels.elementLock.unlockAll";
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"Unexpected zero elements to lock/unlock. This should never happen.",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
keyTest: (event, appState, elements) => {
|
|
||||||
return (
|
|
||||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
event.shiftKey &&
|
|
||||||
getSelectedElements(elements, appState, false).length > 0
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getOperation = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
|
||||||
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
|
|||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
export const actionToggleViewMode = register({
|
export const actionToggleViewMode = register({
|
||||||
name: "viewMode",
|
name: "viewMode",
|
||||||
trackEvent: {
|
|
||||||
category: "canvas",
|
|
||||||
predicate: (appState) => !appState.viewModeEnabled,
|
|
||||||
},
|
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
|
trackEvent("view", "mode", "view");
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
export const actionToggleZenMode = register({
|
export const actionToggleZenMode = register({
|
||||||
name: "zenMode",
|
name: "zenMode",
|
||||||
trackEvent: {
|
|
||||||
category: "canvas",
|
|
||||||
predicate: (appState) => !appState.zenModeEnabled,
|
|
||||||
},
|
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
|
trackEvent("view", "mode", "zen");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveOneLeft(elements, appState),
|
||||||
@@ -46,7 +45,6 @@ export const actionSendBackward = register({
|
|||||||
|
|
||||||
export const actionBringForward = register({
|
export const actionBringForward = register({
|
||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveOneRight(elements, appState),
|
||||||
@@ -74,7 +72,6 @@ export const actionBringForward = register({
|
|||||||
|
|
||||||
export const actionSendToBack = register({
|
export const actionSendToBack = register({
|
||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveAllLeft(elements, appState),
|
||||||
@@ -109,8 +106,6 @@ export const actionSendToBack = register({
|
|||||||
|
|
||||||
export const actionBringToFront = register({
|
export const actionBringToFront = register({
|
||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
trackEvent: { category: "element" },
|
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveAllRight(elements, appState),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export {
|
|||||||
actionChangeFontSize,
|
actionChangeFontSize,
|
||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -75,13 +74,9 @@ export {
|
|||||||
actionCut,
|
actionCut,
|
||||||
actionCopyAsPng,
|
actionCopyAsPng,
|
||||||
actionCopyAsSvg,
|
actionCopyAsSvg,
|
||||||
copyText,
|
|
||||||
} from "./actionClipboard";
|
} from "./actionClipboard";
|
||||||
|
|
||||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
|
||||||
export { actionLink } from "../element/Hyperlink";
|
|
||||||
export { actionToggleLock } from "./actionToggleLock";
|
|
||||||
|
|||||||
@@ -1,47 +1,18 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
|
ActionsManagerInterface,
|
||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
PanelComponentProps,
|
PanelComponentProps,
|
||||||
ActionSource,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { MODES } from "../constants";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const trackAction = (
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
action: Action,
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
source: ActionSource,
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
app: AppClassProperties,
|
|
||||||
value: any,
|
|
||||||
) => {
|
|
||||||
if (action.trackEvent) {
|
|
||||||
try {
|
|
||||||
if (typeof action.trackEvent === "object") {
|
|
||||||
const shouldTrack = action.trackEvent.predicate
|
|
||||||
? action.trackEvent.predicate(appState, elements, value)
|
|
||||||
: true;
|
|
||||||
if (shouldTrack) {
|
|
||||||
trackEvent(
|
|
||||||
action.trackEvent.category,
|
|
||||||
action.trackEvent.action || action.name,
|
|
||||||
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("error while logging action:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ActionManager {
|
|
||||||
actions = {} as Record<ActionName, Action>;
|
|
||||||
|
|
||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
@@ -94,15 +65,9 @@ export class ActionManager {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.length !== 1) {
|
if (data.length === 0) {
|
||||||
if (data.length > 1) {
|
|
||||||
console.warn("Canceling as multiple actions match this shortcut", data);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = data[0];
|
|
||||||
|
|
||||||
const { viewModeEnabled } = this.getAppState();
|
const { viewModeEnabled } = this.getAppState();
|
||||||
if (viewModeEnabled) {
|
if (viewModeEnabled) {
|
||||||
if (!Object.values(MODES).includes(data[0].name)) {
|
if (!Object.values(MODES).includes(data[0].name)) {
|
||||||
@@ -110,26 +75,27 @@ export class ActionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements = this.getElementsIncludingDeleted();
|
|
||||||
const appState = this.getAppState();
|
|
||||||
const value = null;
|
|
||||||
|
|
||||||
trackAction(action, "keyboard", appState, elements, this.app, null);
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
this.updater(
|
||||||
this.updater(data[0].perform(elements, appState, value, this.app));
|
data[0].perform(
|
||||||
|
this.getElementsIncludingDeleted(),
|
||||||
|
this.getAppState(),
|
||||||
|
null,
|
||||||
|
this.app,
|
||||||
|
),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
executeAction(action: Action, source: ActionSource = "api") {
|
executeAction(action: Action) {
|
||||||
const elements = this.getElementsIncludingDeleted();
|
this.updater(
|
||||||
const appState = this.getAppState();
|
action.perform(
|
||||||
const value = null;
|
this.getElementsIncludingDeleted(),
|
||||||
|
this.getAppState(),
|
||||||
trackAction(action, source, appState, elements, this.app, value);
|
null,
|
||||||
|
this.app,
|
||||||
this.updater(action.perform(elements, appState, value, this.app));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,11 +113,7 @@ export class ActionManager {
|
|||||||
) {
|
) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
const elements = this.getElementsIncludingDeleted();
|
|
||||||
const appState = this.getAppState();
|
|
||||||
const updateData = (formState?: any) => {
|
const updateData = (formState?: any) => {
|
||||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
|
||||||
|
|
||||||
this.updater(
|
this.updater(
|
||||||
action.perform(
|
action.perform(
|
||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = <T extends Action>(action: T) => {
|
export const register = (action: Action): Action => {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action as T & {
|
return action;
|
||||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isDarwin } from "../keys";
|
import { isDarwin } from "../keys";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { ActionName } from "./types";
|
|
||||||
|
|
||||||
export type ShortcutName = SubtypeOf<
|
export type ShortcutName =
|
||||||
ActionName,
|
|
||||||
| "cut"
|
| "cut"
|
||||||
| "copy"
|
| "copy"
|
||||||
| "paste"
|
| "paste"
|
||||||
@@ -27,10 +25,7 @@ export type ShortcutName = SubtypeOf<
|
|||||||
| "addToLibrary"
|
| "addToLibrary"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "flipHorizontal"
|
| "flipHorizontal"
|
||||||
| "flipVertical"
|
| "flipVertical";
|
||||||
| "hyperlink"
|
|
||||||
| "toggleLock"
|
|
||||||
>;
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -67,12 +62,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||||
flipVertical: [getShortcutKey("Shift+V")],
|
flipVertical: [getShortcutKey("Shift+V")],
|
||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
|
||||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||||
const shortcuts = shortcutMap[name];
|
const shortcuts = shortcutMap[name];
|
||||||
// if multiple shortcuts available, take the first one
|
// if multiple shortcuts availiable, take the first one
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { ToolButtonSize } from "../components/ToolButton";
|
||||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@@ -40,7 +39,6 @@ export type ActionName =
|
|||||||
| "paste"
|
| "paste"
|
||||||
| "copyAsPng"
|
| "copyAsPng"
|
||||||
| "copyAsSvg"
|
| "copyAsSvg"
|
||||||
| "copyText"
|
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
@@ -84,7 +82,6 @@ export type ActionName =
|
|||||||
| "zoomToSelection"
|
| "zoomToSelection"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "changeVerticalAlign"
|
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
| "group"
|
| "group"
|
||||||
@@ -106,19 +103,14 @@ export type ActionName =
|
|||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme"
|
| "toggleTheme"
|
||||||
| "increaseFontSize"
|
| "increaseFontSize"
|
||||||
| "decreaseFontSize"
|
| "decreaseFontSize";
|
||||||
| "unbindText"
|
|
||||||
| "hyperlink"
|
|
||||||
| "eraser"
|
|
||||||
| "bindText"
|
|
||||||
| "toggleLock";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
data?: Record<string, any>;
|
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
@@ -131,34 +123,18 @@ export interface Action {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?:
|
contextItemLabel?: string;
|
||||||
| string
|
|
||||||
| ((
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
) => string);
|
|
||||||
contextItemPredicate?: (
|
contextItemPredicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
checked?: (appState: Readonly<AppState>) => boolean;
|
checked?: (appState: Readonly<AppState>) => boolean;
|
||||||
trackEvent:
|
}
|
||||||
| false
|
|
||||||
| {
|
export interface ActionsManagerInterface {
|
||||||
category:
|
actions: Record<ActionName, Action>;
|
||||||
| "toolbar"
|
registerAction: (action: Action) => void;
|
||||||
| "element"
|
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||||
| "canvas"
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
| "export"
|
executeAction: (action: Action) => void;
|
||||||
| "history"
|
|
||||||
| "menu"
|
|
||||||
| "collab"
|
|
||||||
| "hyperlink";
|
|
||||||
action?: string;
|
|
||||||
predicate?: (
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
value: any,
|
|
||||||
) => boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,16 @@ export const trackEvent =
|
|||||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
window.gtag
|
window.gtag
|
||||||
? (category: string, action: string, label?: string, value?: number) => {
|
? (category: string, name: string, label?: string, value?: number) => {
|
||||||
try {
|
window.gtag("event", name, {
|
||||||
window.gtag("event", action, {
|
event_category: category,
|
||||||
event_category: category,
|
event_label: label,
|
||||||
event_label: label,
|
value,
|
||||||
value,
|
});
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("error logging to ga", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||||
? (category: string, action: string, label?: string, value?: number) => {}
|
? (category: string, name: string, label?: string, value?: number) => {}
|
||||||
: (category: string, action: string, label?: string, value?: number) => {
|
: (category: string, name: string, label?: string, value?: number) => {
|
||||||
// Uncomment the next line to track locally
|
// Uncomment the next line to track locally
|
||||||
// console.log("Track Event", { category, action, label, value });
|
// console.info("Track Event", category, name, label, value);
|
||||||
};
|
};
|
||||||
|
|||||||
309
src/appState.ts
309
src/appState.ts
@@ -41,14 +41,8 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
activeTool: {
|
elementLocked: false,
|
||||||
type: "selection",
|
elementType: "selection",
|
||||||
customType: null,
|
|
||||||
locked: false,
|
|
||||||
lastActiveToolBeforeEraser: null,
|
|
||||||
},
|
|
||||||
penMode: false,
|
|
||||||
penDetected: false,
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportScale: defaultExportScale,
|
exportScale: defaultExportScale,
|
||||||
@@ -58,7 +52,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
gridSize: null,
|
gridSize: null,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
isLibraryMenuDocked: false,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
@@ -84,12 +77,9 @@ export const getDefaultAppState = (): Omit<
|
|||||||
toastMessage: null,
|
toastMessage: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: {
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
value: 1 as NormalizedZoomValue,
|
|
||||||
},
|
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
pendingImageElement: null,
|
||||||
showHyperlinkPopup: false,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,228 +91,87 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
Values extends {
|
Values extends {
|
||||||
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
||||||
browser: boolean;
|
browser: boolean;
|
||||||
/** whether to keep when exporting to a text file */
|
/** whether to keep when exporting to file/database */
|
||||||
text: boolean;
|
export: boolean;
|
||||||
/** whether to keep when exporting to an image file */
|
|
||||||
image: boolean;
|
|
||||||
/** server (shareLink/collab/...) */
|
/** server (shareLink/collab/...) */
|
||||||
server: boolean;
|
server: boolean;
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>,
|
T extends Record<keyof AppState, Values>,
|
||||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||||
config)({
|
config)({
|
||||||
theme: { browser: true, text: false, image: false, server: false },
|
theme: { browser: true, export: false, server: false },
|
||||||
collaborators: { browser: false, text: false, image: false, server: false },
|
collaborators: { browser: false, export: false, server: false },
|
||||||
currentChartType: { browser: true, text: false, image: false, server: false },
|
currentChartType: { browser: true, export: false, server: false },
|
||||||
currentItemBackgroundColor: {
|
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||||
browser: true,
|
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||||
text: false,
|
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||||
image: false,
|
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||||
server: false,
|
currentItemFontSize: { browser: true, export: false, server: false },
|
||||||
},
|
|
||||||
currentItemEndArrowhead: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemFillStyle: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemFontFamily: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemFontSize: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemLinearStrokeSharpness: {
|
currentItemLinearStrokeSharpness: {
|
||||||
browser: true,
|
browser: true,
|
||||||
text: false,
|
export: false,
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemOpacity: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemRoughness: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemStartArrowhead: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemStrokeColor: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemStrokeSharpness: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemStrokeStyle: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemStrokeWidth: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
currentItemTextAlign: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
cursorButton: { browser: true, text: false, image: false, server: false },
|
|
||||||
draggingElement: { browser: false, text: false, image: false, server: false },
|
|
||||||
editingElement: { browser: false, text: false, image: false, server: false },
|
|
||||||
editingGroupId: { browser: true, text: false, image: false, server: false },
|
|
||||||
editingLinearElement: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
activeTool: { browser: true, text: false, image: false, server: false },
|
|
||||||
penMode: { browser: true, text: false, image: false, server: false },
|
|
||||||
penDetected: { browser: true, text: false, image: false, server: false },
|
|
||||||
errorMessage: { browser: false, text: false, image: false, server: false },
|
|
||||||
exportBackground: { browser: true, text: false, image: true, server: false },
|
|
||||||
exportEmbedScene: { browser: true, text: false, image: true, server: false },
|
|
||||||
exportScale: { browser: true, text: false, image: true, server: false },
|
|
||||||
exportWithDarkMode: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: true,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
fileHandle: { browser: false, text: false, image: false, server: false },
|
|
||||||
gridSize: { browser: true, text: true, image: true, server: true },
|
|
||||||
height: { browser: false, text: false, image: false, server: false },
|
|
||||||
isBindingEnabled: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
isLibraryOpen: { browser: true, text: false, image: false, server: false },
|
|
||||||
isLibraryMenuDocked: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
isLoading: { browser: false, text: false, image: false, server: false },
|
|
||||||
isResizing: { browser: false, text: false, image: false, server: false },
|
|
||||||
isRotating: { browser: false, text: false, image: false, server: false },
|
|
||||||
lastPointerDownWith: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
multiElement: { browser: false, text: false, image: false, server: false },
|
|
||||||
name: { browser: true, text: false, image: false, server: false },
|
|
||||||
offsetLeft: { browser: false, text: false, image: false, server: false },
|
|
||||||
offsetTop: { browser: false, text: false, image: false, server: false },
|
|
||||||
openMenu: { browser: true, text: false, image: false, server: false },
|
|
||||||
openPopup: { browser: false, text: false, image: false, server: false },
|
|
||||||
pasteDialog: { browser: false, text: false, image: false, server: false },
|
|
||||||
previousSelectedElementIds: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
resizingElement: { browser: false, text: false, image: false, server: false },
|
|
||||||
scrolledOutside: { browser: true, text: false, image: false, server: false },
|
|
||||||
scrollX: { browser: true, text: false, image: false, server: false },
|
|
||||||
scrollY: { browser: true, text: false, image: false, server: false },
|
|
||||||
selectedElementIds: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
selectedGroupIds: { browser: true, text: false, image: false, server: false },
|
|
||||||
selectionElement: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
shouldCacheIgnoreZoom: {
|
|
||||||
browser: true,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
showHelpDialog: { browser: false, text: false, image: false, server: false },
|
|
||||||
showStats: { browser: true, text: false, image: false, server: false },
|
|
||||||
startBoundElement: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
suggestedBindings: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
toastMessage: { browser: false, text: false, image: false, server: false },
|
|
||||||
viewBackgroundColor: {
|
|
||||||
browser: true,
|
|
||||||
text: true,
|
|
||||||
image: true,
|
|
||||||
server: true,
|
|
||||||
},
|
|
||||||
width: { browser: false, text: false, image: false, server: false },
|
|
||||||
zenModeEnabled: { browser: true, text: false, image: false, server: false },
|
|
||||||
zoom: { browser: true, text: false, image: false, server: false },
|
|
||||||
viewModeEnabled: { browser: false, text: false, image: false, server: false },
|
|
||||||
pendingImageElementId: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
|
||||||
},
|
|
||||||
showHyperlinkPopup: {
|
|
||||||
browser: false,
|
|
||||||
text: false,
|
|
||||||
image: false,
|
|
||||||
server: false,
|
server: false,
|
||||||
},
|
},
|
||||||
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
|
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||||
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
|
draggingElement: { 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 },
|
||||||
|
elementLocked: { browser: true, export: false, server: false },
|
||||||
|
elementType: { browser: true, export: false, server: false },
|
||||||
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
|
exportBackground: { browser: true, export: false, server: false },
|
||||||
|
exportEmbedScene: { browser: true, export: false, server: false },
|
||||||
|
exportScale: { browser: true, export: false, server: false },
|
||||||
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
|
gridSize: { browser: true, export: true, server: true },
|
||||||
|
height: { browser: false, export: false, server: false },
|
||||||
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
|
isLibraryOpen: { browser: false, export: false, server: false },
|
||||||
|
isLoading: { browser: false, export: false, server: false },
|
||||||
|
isResizing: { browser: false, export: false, server: false },
|
||||||
|
isRotating: { browser: false, export: false, server: false },
|
||||||
|
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||||
|
multiElement: { browser: false, export: false, server: false },
|
||||||
|
name: { browser: true, export: false, server: false },
|
||||||
|
offsetLeft: { browser: false, export: false, server: false },
|
||||||
|
offsetTop: { browser: false, export: false, server: false },
|
||||||
|
openMenu: { browser: true, export: false, server: false },
|
||||||
|
openPopup: { browser: false, export: false, server: false },
|
||||||
|
pasteDialog: { browser: false, export: false, server: false },
|
||||||
|
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||||
|
resizingElement: { browser: false, export: false, server: false },
|
||||||
|
scrolledOutside: { browser: true, export: false, server: false },
|
||||||
|
scrollX: { browser: true, export: false, server: false },
|
||||||
|
scrollY: { browser: true, export: false, server: false },
|
||||||
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
|
showHelpDialog: { browser: false, export: false, server: false },
|
||||||
|
showStats: { browser: true, export: false, server: false },
|
||||||
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
|
toastMessage: { browser: false, export: false, server: false },
|
||||||
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
|
width: { browser: false, export: false, server: false },
|
||||||
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
|
zoom: { browser: true, export: false, server: false },
|
||||||
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
|
pendingImageElement: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
ExportType extends "image" | "text" | "browser" | "server",
|
ExportType extends "export" | "browser" | "server",
|
||||||
>(
|
>(
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
@@ -349,20 +198,10 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
|||||||
return _clearAppStateForStorage(appState, "browser");
|
return _clearAppStateForStorage(appState, "browser");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cleanAppStateForTextExport = (appState: Partial<AppState>) => {
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "text");
|
return _clearAppStateForStorage(appState, "export");
|
||||||
};
|
|
||||||
|
|
||||||
export const cleanAppStateForImageExport = (appState: Partial<AppState>) => {
|
|
||||||
return _clearAppStateForStorage(appState, "image");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "server");
|
return _clearAppStateForStorage(appState, "server");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isEraserActive = ({
|
|
||||||
activeTool,
|
|
||||||
}: {
|
|
||||||
activeTool: AppState["activeTool"];
|
|
||||||
}) => activeTool.type === "eraser";
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import {
|
|
||||||
Spreadsheet,
|
|
||||||
tryParseCells,
|
|
||||||
tryParseNumber,
|
|
||||||
VALID_SPREADSHEET,
|
|
||||||
} from "./charts";
|
|
||||||
|
|
||||||
describe("charts", () => {
|
|
||||||
describe("tryParseNumber", () => {
|
|
||||||
it.each<[string, number]>([
|
|
||||||
["1", 1],
|
|
||||||
["0", 0],
|
|
||||||
["-1", -1],
|
|
||||||
["0.1", 0.1],
|
|
||||||
[".1", 0.1],
|
|
||||||
["1.", 1],
|
|
||||||
["424.", 424],
|
|
||||||
["$1", 1],
|
|
||||||
["-.1", -0.1],
|
|
||||||
["-$1", -1],
|
|
||||||
["$-1", -1],
|
|
||||||
])("should correctly identify %s as numbers", (given, expected) => {
|
|
||||||
expect(tryParseNumber(given)).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
|
|
||||||
"should correctly identify %s as not a number",
|
|
||||||
(given) => {
|
|
||||||
expect(tryParseNumber(given)).toBeNull();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("tryParseCells", () => {
|
|
||||||
it("Successfully parses a spreadsheet", () => {
|
|
||||||
const spreadsheet = [
|
|
||||||
["time", "value"],
|
|
||||||
["01:00", "61"],
|
|
||||||
["02:00", "-60"],
|
|
||||||
["03:00", "85"],
|
|
||||||
["04:00", "-67"],
|
|
||||||
["05:00", "54"],
|
|
||||||
["06:00", "95"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = tryParseCells(spreadsheet);
|
|
||||||
|
|
||||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
|
||||||
|
|
||||||
const { title, labels, values } = (
|
|
||||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
||||||
).spreadsheet;
|
|
||||||
|
|
||||||
expect(title).toEqual("value");
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"01:00",
|
|
||||||
"02:00",
|
|
||||||
"03:00",
|
|
||||||
"04:00",
|
|
||||||
"05:00",
|
|
||||||
"06:00",
|
|
||||||
]);
|
|
||||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Uses the second column as the label if it is not a number", () => {
|
|
||||||
const spreadsheet = [
|
|
||||||
["time", "value"],
|
|
||||||
["01:00", "61"],
|
|
||||||
["02:00", "-60"],
|
|
||||||
["03:00", "85"],
|
|
||||||
["04:00", "-67"],
|
|
||||||
["05:00", "54"],
|
|
||||||
["06:00", "95"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = tryParseCells(spreadsheet);
|
|
||||||
|
|
||||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
|
||||||
|
|
||||||
const { title, labels, values } = (
|
|
||||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
||||||
).spreadsheet;
|
|
||||||
|
|
||||||
expect(title).toEqual("value");
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"01:00",
|
|
||||||
"02:00",
|
|
||||||
"03:00",
|
|
||||||
"04:00",
|
|
||||||
"05:00",
|
|
||||||
"06:00",
|
|
||||||
]);
|
|
||||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats the first column as labels if both columns are numbers", () => {
|
|
||||||
const spreadsheet = [
|
|
||||||
["time", "value"],
|
|
||||||
["01", "61"],
|
|
||||||
["02", "-60"],
|
|
||||||
["03", "85"],
|
|
||||||
["04", "-67"],
|
|
||||||
["05", "54"],
|
|
||||||
["06", "95"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = tryParseCells(spreadsheet);
|
|
||||||
|
|
||||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
|
||||||
|
|
||||||
const { title, labels, values } = (
|
|
||||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
||||||
).spreadsheet;
|
|
||||||
|
|
||||||
expect(title).toEqual("value");
|
|
||||||
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
|
|
||||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
import {
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
ENV,
|
|
||||||
VERTICAL_ALIGN,
|
|
||||||
} from "./constants";
|
|
||||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
@@ -29,24 +24,18 @@ type ParseSpreadsheetResult =
|
|||||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||||
|
|
||||||
/**
|
const tryParseNumber = (s: string): number | null => {
|
||||||
* @private exported for testing
|
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||||
*/
|
|
||||||
export const tryParseNumber = (s: string): number | null => {
|
|
||||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
return parseFloat(match[1].replace(/,/g, ""));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||||
|
|
||||||
/**
|
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||||
* @private exported for testing
|
|
||||||
*/
|
|
||||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|
||||||
const numCols = cells[0].length;
|
const numCols = cells[0].length;
|
||||||
|
|
||||||
if (numCols > 2) {
|
if (numCols > 2) {
|
||||||
@@ -77,16 +66,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelColumnNumeric = isNumericColumn(cells, 0);
|
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||||
const valueColumnNumeric = isNumericColumn(cells, 1);
|
|
||||||
|
|
||||||
if (!labelColumnNumeric && !valueColumnNumeric) {
|
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||||
? [0, 1]
|
|
||||||
: [1, 0];
|
|
||||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
const rows = hasHeader ? cells.slice(1) : cells;
|
||||||
|
|
||||||
@@ -117,7 +103,7 @@ const transposeCells = (cells: string[][]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||||
// Copy/paste from excel, spreadsheets, tsv, csv.
|
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||||
// For now we only accept 2 columns with an optional header
|
// For now we only accept 2 columns with an optional header
|
||||||
|
|
||||||
// Check for tab separated values
|
// Check for tab separated values
|
||||||
@@ -175,8 +161,7 @@ const commonProps = {
|
|||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: "middle",
|
||||||
locked: false,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState, BinaryFiles } from "./types";
|
import { AppState, BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { isInitializedImageElement } from "./element/typeChecks";
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
import { isPromiseLike } from "./utils";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
files: BinaryFiles | undefined;
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,20 +56,19 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
// select binded text elements when copying
|
// select binded text elements when copying
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements,
|
elements: selectedElements,
|
||||||
files: files
|
files: selectedElements.reduce((acc, element) => {
|
||||||
? elements.reduce((acc, element) => {
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
acc[element.fileId] = files[element.fileId];
|
||||||
acc[element.fileId] = files[element.fileId];
|
}
|
||||||
}
|
return acc;
|
||||||
return acc;
|
}, {} as BinaryFiles),
|
||||||
}, {} as BinaryFiles)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
@@ -125,7 +124,7 @@ const getSystemClipboard = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard. Prefers system clipboard.
|
* Attemps to parse clipboard. Prefers system clipboard.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
@@ -167,35 +166,10 @@ export const parseClipboard = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||||
let promise;
|
await navigator.clipboard.write([
|
||||||
try {
|
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
]);
|
||||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
|
||||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
|
||||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
|
||||||
//
|
|
||||||
// not await so that we can detect whether the thrown error likely relates
|
|
||||||
// to a lack of support for the Promise ClipboardItem constructor
|
|
||||||
promise = navigator.clipboard.write([
|
|
||||||
new window.ClipboardItem({
|
|
||||||
[MIME_TYPES.png]: blob,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch (error: any) {
|
|
||||||
// if we're using a Promise ClipboardItem, let's try constructing
|
|
||||||
// with resolution value instead
|
|
||||||
if (isPromiseLike(blob)) {
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new window.ClipboardItem({
|
|
||||||
[MIME_TYPES.png]: await blob,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await promise;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
@@ -15,59 +15,40 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import {
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
capitalizeString,
|
|
||||||
isTransparent,
|
|
||||||
updateActiveTool,
|
|
||||||
setCursorForShape,
|
|
||||||
} from "../utils";
|
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
renderAction,
|
renderAction,
|
||||||
activeTool,
|
elementType,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
activeTool: AppState["activeTool"]["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
}) => {
|
}) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
let isSingleElementBoundContainer = false;
|
|
||||||
if (
|
|
||||||
targetElements.length === 2 &&
|
|
||||||
(hasBoundTextElement(targetElements[0]) ||
|
|
||||||
hasBoundTextElement(targetElements[1]))
|
|
||||||
) {
|
|
||||||
isSingleElementBoundContainer = true;
|
|
||||||
}
|
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditing = Boolean(appState.editingElement);
|
||||||
const device = useDevice();
|
const isMobile = useIsMobile();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const showFillIcons =
|
const showFillIcons =
|
||||||
hasBackground(activeTool) ||
|
hasBackground(elementType) ||
|
||||||
targetElements.some(
|
targetElements.some(
|
||||||
(element) =>
|
(element) =>
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
);
|
);
|
||||||
const showChangeBackgroundIcons =
|
const showChangeBackgroundIcons =
|
||||||
hasBackground(activeTool) ||
|
hasBackground(elementType) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
const showLinkIcon =
|
|
||||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
|
||||||
|
|
||||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||||
|
|
||||||
for (const element of targetElements) {
|
for (const element of targetElements) {
|
||||||
@@ -79,23 +60,23 @@ export const SelectedShapeActions = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{((hasStrokeColor(activeTool) &&
|
{((hasStrokeColor(elementType) &&
|
||||||
activeTool !== "image" &&
|
elementType !== "image" &&
|
||||||
commonSelectedType !== "image") ||
|
commonSelectedType !== "image") ||
|
||||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
renderAction("changeStrokeColor")}
|
renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
{(hasStrokeWidth(activeTool) ||
|
{(hasStrokeWidth(elementType) ||
|
||||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||||
renderAction("changeStrokeWidth")}
|
renderAction("changeStrokeWidth")}
|
||||||
|
|
||||||
{(activeTool === "freedraw" ||
|
{(elementType === "freedraw" ||
|
||||||
targetElements.some((element) => element.type === "freedraw")) &&
|
targetElements.some((element) => element.type === "freedraw")) &&
|
||||||
renderAction("changeStrokeShape")}
|
renderAction("changeStrokeShape")}
|
||||||
|
|
||||||
{(hasStrokeStyle(activeTool) ||
|
{(hasStrokeStyle(elementType) ||
|
||||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeStrokeStyle")}
|
{renderAction("changeStrokeStyle")}
|
||||||
@@ -103,12 +84,12 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canChangeSharpness(activeTool) ||
|
{(canChangeSharpness(elementType) ||
|
||||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||||
<>{renderAction("changeSharpness")}</>
|
<>{renderAction("changeSharpness")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasText(activeTool) ||
|
{(hasText(elementType) ||
|
||||||
targetElements.some((element) => hasText(element.type))) && (
|
targetElements.some((element) => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeFontSize")}
|
{renderAction("changeFontSize")}
|
||||||
@@ -119,11 +100,7 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{targetElements.some(
|
{(canHaveArrowheads(elementType) ||
|
||||||
(element) =>
|
|
||||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
|
||||||
) && renderAction("changeVerticalAlign")}
|
|
||||||
{(canHaveArrowheads(activeTool) ||
|
|
||||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
@@ -140,7 +117,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
{targetElements.length > 1 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.align")}</legend>
|
<legend>{t("labels.align")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -173,15 +150,14 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isEditing && targetElements.length > 0 && (
|
{!isMobile && !isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{!device.isMobile && renderAction("duplicateSelection")}
|
{renderAction("duplicateSelection")}
|
||||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
{renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
{showLinkIcon && renderAction("hyperlink")}
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
@@ -191,16 +167,14 @@ export const SelectedShapeActions = ({
|
|||||||
|
|
||||||
export const ShapesSwitcher = ({
|
export const ShapesSwitcher = ({
|
||||||
canvas,
|
canvas,
|
||||||
activeTool,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
appState,
|
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
activeTool: AppState["activeTool"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
appState: AppState;
|
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
@@ -215,37 +189,20 @@ export const ShapesSwitcher = ({
|
|||||||
key={value}
|
key={value}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={activeTool.type === value}
|
checked={elementType === value}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
keyBindingLabel={`${index + 1}`}
|
keyBindingLabel={`${index + 1}`}
|
||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onPointerDown={({ pointerType }) => {
|
|
||||||
if (!appState.penDetected && pointerType === "pen") {
|
|
||||||
setAppState({
|
|
||||||
penDetected: true,
|
|
||||||
penMode: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={({ pointerType }) => {
|
onChange={({ pointerType }) => {
|
||||||
if (appState.activeTool.type !== value) {
|
|
||||||
trackEvent("toolbar", value, "ui");
|
|
||||||
}
|
|
||||||
const nextActiveTool = updateActiveTool(appState, {
|
|
||||||
type: value,
|
|
||||||
});
|
|
||||||
setAppState({
|
setAppState({
|
||||||
activeTool: nextActiveTool,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, {
|
setCursorForShape(canvas, value);
|
||||||
...appState,
|
|
||||||
activeTool: nextActiveTool,
|
|
||||||
});
|
|
||||||
if (value === "image") {
|
if (value === "image") {
|
||||||
onImageAction({ pointerType });
|
onImageAction({ pointerType });
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,5 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
import "./Avatar.scss";
|
import "./Avatar.scss";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { getClientInitials } from "../clients";
|
|
||||||
|
|
||||||
type AvatarProps = {
|
type AvatarProps = {
|
||||||
|
children: string;
|
||||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||||
color: string;
|
color: string;
|
||||||
border: string;
|
border: string;
|
||||||
name: string;
|
|
||||||
src?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
||||||
const shortName = getClientInitials(name);
|
<div
|
||||||
const [error, setError] = useState(false);
|
className="Avatar"
|
||||||
const loadImg = !error && src;
|
style={{ background: color, border: `1px solid ${border}` }}
|
||||||
const style = loadImg
|
onClick={onClick}
|
||||||
? undefined
|
>
|
||||||
: { background: color, border: `1px solid ${border}` };
|
{children}
|
||||||
return (
|
</div>
|
||||||
<div className="Avatar" style={style} onClick={onClick}>
|
);
|
||||||
{loadImg ? (
|
|
||||||
<img
|
|
||||||
className="Avatar-img"
|
|
||||||
src={src}
|
|
||||||
alt={shortName}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={() => setError(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
shortName
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
onChange,
|
onChange,
|
||||||
group,
|
group,
|
||||||
}: {
|
}: {
|
||||||
options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
|
options: { value: T; text: string; icon: JSX.Element }[];
|
||||||
value: T | null;
|
value: T | null;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
group: string;
|
group: string;
|
||||||
@@ -24,7 +24,6 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
name={group}
|
name={group}
|
||||||
onChange={() => onChange(option.value)}
|
onChange={() => onChange(option.value)}
|
||||||
checked={value === option.value}
|
checked={value === option.value}
|
||||||
data-testid={option.testId}
|
|
||||||
/>
|
/>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { trash } from "./icons";
|
import { trash } from "./icons";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
|||||||
icon={trash}
|
icon={trash}
|
||||||
title={t("buttons.clearReset")}
|
title={t("buttons.clearReset")}
|
||||||
aria-label={t("buttons.clearReset")}
|
aria-label={t("buttons.clearReset")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={toggleDialog}
|
onClick={toggleDialog}
|
||||||
data-testid="clear-canvas-button"
|
data-testid="clear-canvas-button"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { users } from "./icons";
|
import { users } from "./icons";
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
import "./CollabButton.scss";
|
||||||
@@ -26,7 +26,7 @@ const CollabButton = ({
|
|||||||
type="button"
|
type="button"
|
||||||
title={t("labels.liveCollaboration")}
|
title={t("labels.liveCollaboration")}
|
||||||
aria-label={t("labels.liveCollaboration")}
|
aria-label={t("labels.liveCollaboration")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useIsMobile()}
|
||||||
>
|
>
|
||||||
{collaboratorCount > 0 && (
|
{collaboratorCount > 0 && (
|
||||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
top: -11px;
|
top: -11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-content--default {
|
.color-picker-content {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, auto);
|
grid-template-columns: repeat(5, auto);
|
||||||
@@ -59,26 +59,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-content--canvas {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.25rem;
|
|
||||||
|
|
||||||
&-title {
|
|
||||||
color: $oc-gray-6;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-colors {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
|
|
||||||
.color-picker-swatch {
|
|
||||||
margin: 0 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-content .color-input-container {
|
.color-picker-content .color-input-container {
|
||||||
grid-column: 1 / span 5;
|
grid-column: 1 / span 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,53 +7,6 @@ import { isArrowKey, KEYS } from "../keys";
|
|||||||
import { t, getLanguage } from "../i18n";
|
import { t, getLanguage } from "../i18n";
|
||||||
import { isWritableElement } from "../utils";
|
import { isWritableElement } from "../utils";
|
||||||
import colors from "../colors";
|
import colors from "../colors";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
const MAX_CUSTOM_COLORS = 5;
|
|
||||||
const MAX_DEFAULT_COLORS = 15;
|
|
||||||
|
|
||||||
export const getCustomColors = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
type: "elementBackground" | "elementStroke",
|
|
||||||
) => {
|
|
||||||
const customColors: string[] = [];
|
|
||||||
const updatedElements = elements
|
|
||||||
.filter((element) => !element.isDeleted)
|
|
||||||
.sort((ele1, ele2) => ele2.updated - ele1.updated);
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
const elementColorTypeMap = {
|
|
||||||
elementBackground: "backgroundColor",
|
|
||||||
elementStroke: "strokeColor",
|
|
||||||
};
|
|
||||||
const colorType = elementColorTypeMap[type] as
|
|
||||||
| "backgroundColor"
|
|
||||||
| "strokeColor";
|
|
||||||
while (
|
|
||||||
index < updatedElements.length &&
|
|
||||||
customColors.length < MAX_CUSTOM_COLORS
|
|
||||||
) {
|
|
||||||
const element = updatedElements[index];
|
|
||||||
|
|
||||||
if (
|
|
||||||
customColors.length < MAX_CUSTOM_COLORS &&
|
|
||||||
isCustomColor(element[colorType], type) &&
|
|
||||||
!customColors.includes(element[colorType])
|
|
||||||
) {
|
|
||||||
customColors.push(element[colorType]);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
return customColors;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCustomColor = (
|
|
||||||
color: string,
|
|
||||||
type: "elementBackground" | "elementStroke",
|
|
||||||
) => {
|
|
||||||
return !colors[type].includes(color);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidColor = (color: string) => {
|
const isValidColor = (color: string) => {
|
||||||
const style = new Option().style;
|
const style = new Option().style;
|
||||||
@@ -82,7 +35,6 @@ const keyBindings = [
|
|||||||
["1", "2", "3", "4", "5"],
|
["1", "2", "3", "4", "5"],
|
||||||
["q", "w", "e", "r", "t"],
|
["q", "w", "e", "r", "t"],
|
||||||
["a", "s", "d", "f", "g"],
|
["a", "s", "d", "f", "g"],
|
||||||
["z", "x", "c", "v", "b"],
|
|
||||||
].flat();
|
].flat();
|
||||||
|
|
||||||
const Picker = ({
|
const Picker = ({
|
||||||
@@ -93,7 +45,6 @@ const Picker = ({
|
|||||||
label,
|
label,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
type,
|
type,
|
||||||
elements,
|
|
||||||
}: {
|
}: {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -102,20 +53,12 @@ const Picker = ({
|
|||||||
label: string;
|
label: string;
|
||||||
showInput: boolean;
|
showInput: boolean;
|
||||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
}) => {
|
}) => {
|
||||||
const firstItem = React.useRef<HTMLButtonElement>();
|
const firstItem = React.useRef<HTMLButtonElement>();
|
||||||
const activeItem = React.useRef<HTMLButtonElement>();
|
const activeItem = React.useRef<HTMLButtonElement>();
|
||||||
const gallery = React.useRef<HTMLDivElement>();
|
const gallery = React.useRef<HTMLDivElement>();
|
||||||
const colorInput = React.useRef<HTMLInputElement>();
|
const colorInput = React.useRef<HTMLInputElement>();
|
||||||
|
|
||||||
const [customColors] = React.useState(() => {
|
|
||||||
if (type === "canvasBackground") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return getCustomColors(elements, type);
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// After the component is first mounted focus on first input
|
// After the component is first mounted focus on first input
|
||||||
if (activeItem.current) {
|
if (activeItem.current) {
|
||||||
@@ -128,119 +71,52 @@ const Picker = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
let handled = false;
|
if (event.key === KEYS.TAB) {
|
||||||
if (isArrowKey(event.key)) {
|
const { activeElement } = document;
|
||||||
handled = true;
|
if (event.shiftKey) {
|
||||||
|
if (activeElement === firstItem.current) {
|
||||||
|
colorInput.current?.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else if (activeElement === colorInput.current) {
|
||||||
|
firstItem.current?.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else if (isArrowKey(event.key)) {
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
let isCustom = false;
|
const index = Array.prototype.indexOf.call(
|
||||||
let index = Array.prototype.indexOf.call(
|
gallery!.current!.children,
|
||||||
gallery.current!.querySelector(".color-picker-content--default")
|
|
||||||
?.children,
|
|
||||||
activeElement,
|
activeElement,
|
||||||
);
|
);
|
||||||
if (index === -1) {
|
if (index !== -1) {
|
||||||
index = Array.prototype.indexOf.call(
|
const length = gallery!.current!.children.length - (showInput ? 1 : 0);
|
||||||
gallery.current!.querySelector(".color-picker-content--canvas-colors")
|
|
||||||
?.children,
|
|
||||||
activeElement,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
|
||||||
isCustom = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parentElement = isCustom
|
|
||||||
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
|
|
||||||
: gallery.current?.querySelector(".color-picker-content--default");
|
|
||||||
|
|
||||||
if (parentElement && index !== -1) {
|
|
||||||
const length = parentElement.children.length - (showInput ? 1 : 0);
|
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
||||||
? (index + 1) % length
|
? (index + 1) % length
|
||||||
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
|
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
|
||||||
? (length + index - 1) % length
|
? (length + index - 1) % length
|
||||||
: !isCustom && event.key === KEYS.ARROW_DOWN
|
: event.key === KEYS.ARROW_DOWN
|
||||||
? (index + 5) % length
|
? (index + 5) % length
|
||||||
: !isCustom && event.key === KEYS.ARROW_UP
|
: event.key === KEYS.ARROW_UP
|
||||||
? (length + index - 5) % length
|
? (length + index - 5) % length
|
||||||
: index;
|
: index;
|
||||||
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
|
(gallery!.current!.children![nextIndex] as any).focus();
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (
|
} else if (
|
||||||
keyBindings.includes(event.key.toLowerCase()) &&
|
keyBindings.includes(event.key.toLowerCase()) &&
|
||||||
!event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
!event.altKey &&
|
|
||||||
!isWritableElement(event.target)
|
!isWritableElement(event.target)
|
||||||
) {
|
) {
|
||||||
handled = true;
|
|
||||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||||
const isCustom = index >= MAX_DEFAULT_COLORS;
|
(gallery!.current!.children![index] as any).focus();
|
||||||
const parentElement = isCustom
|
|
||||||
? gallery?.current?.querySelector(
|
|
||||||
".color-picker-content--canvas-colors",
|
|
||||||
)
|
|
||||||
: gallery?.current?.querySelector(".color-picker-content--default");
|
|
||||||
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
|
||||||
(
|
|
||||||
parentElement?.children[actualIndex] as HTMLElement | undefined
|
|
||||||
)?.focus();
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
handled = true;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
if (handled) {
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.stopPropagation();
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
|
||||||
return colors.map((_color, i) => {
|
|
||||||
const _colorWithoutHash = _color.replace("#", "");
|
|
||||||
const keyBinding = custom
|
|
||||||
? keyBindings[i + MAX_DEFAULT_COLORS]
|
|
||||||
: keyBindings[i];
|
|
||||||
const label = custom
|
|
||||||
? _colorWithoutHash
|
|
||||||
: t(`colors.${_colorWithoutHash}`);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="color-picker-swatch"
|
|
||||||
onClick={(event) => {
|
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
|
||||||
onChange(_color);
|
|
||||||
}}
|
|
||||||
title={`${label}${
|
|
||||||
!isTransparent(_color) ? ` (${_color})` : ""
|
|
||||||
} — ${keyBinding.toUpperCase()}`}
|
|
||||||
aria-label={label}
|
|
||||||
aria-keyshortcuts={keyBindings[i]}
|
|
||||||
style={{ color: _color }}
|
|
||||||
key={_color}
|
|
||||||
ref={(el) => {
|
|
||||||
if (!custom && el && i === 0) {
|
|
||||||
firstItem.current = el;
|
|
||||||
}
|
|
||||||
if (el && _color === color) {
|
|
||||||
activeItem.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
onChange(_color);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isTransparent(_color) ? (
|
|
||||||
<div className="color-picker-transparent"></div>
|
|
||||||
) : undefined}
|
|
||||||
<span className="color-picker-keybinding">{keyBinding}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -260,23 +136,43 @@ const Picker = ({
|
|||||||
gallery.current = el;
|
gallery.current = el;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
// to allow focusing by clicking but not by tabbing
|
tabIndex={0}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<div className="color-picker-content--default">
|
{colors.map((_color, i) => {
|
||||||
{renderColors(colors)}
|
const _colorWithoutHash = _color.replace("#", "");
|
||||||
</div>
|
return (
|
||||||
{!!customColors.length && (
|
<button
|
||||||
<div className="color-picker-content--canvas">
|
className="color-picker-swatch"
|
||||||
<span className="color-picker-content--canvas-title">
|
onClick={(event) => {
|
||||||
{t("labels.canvasColors")}
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
</span>
|
onChange(_color);
|
||||||
<div className="color-picker-content--canvas-colors">
|
}}
|
||||||
{renderColors(customColors, true)}
|
title={`${t(`colors.${_colorWithoutHash}`)}${
|
||||||
</div>
|
!isTransparent(_color) ? ` (${_color})` : ""
|
||||||
</div>
|
} — ${keyBindings[i].toUpperCase()}`}
|
||||||
)}
|
aria-label={t(`colors.${_colorWithoutHash}`)}
|
||||||
|
aria-keyshortcuts={keyBindings[i]}
|
||||||
|
style={{ color: _color }}
|
||||||
|
key={_color}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && i === 0) {
|
||||||
|
firstItem.current = el;
|
||||||
|
}
|
||||||
|
if (el && _color === color) {
|
||||||
|
activeItem.current = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
onChange(_color);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isTransparent(_color) ? (
|
||||||
|
<div className="color-picker-transparent"></div>
|
||||||
|
) : undefined}
|
||||||
|
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color}
|
||||||
@@ -350,8 +246,6 @@ export const ColorPicker = ({
|
|||||||
label,
|
label,
|
||||||
isActive,
|
isActive,
|
||||||
setActive,
|
setActive,
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
}: {
|
}: {
|
||||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -359,8 +253,6 @@ export const ColorPicker = ({
|
|||||||
label: string;
|
label: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
setActive: (active: boolean) => void;
|
setActive: (active: boolean) => void;
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
appState: AppState;
|
|
||||||
}) => {
|
}) => {
|
||||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -402,7 +294,6 @@ export const ColorPicker = ({
|
|||||||
label={label}
|
label={label}
|
||||||
showInput={false}
|
showInput={false}
|
||||||
type={type}
|
type={type}
|
||||||
elements={elements}
|
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { Action } from "../actions/types";
|
import { Action } from "../actions/types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
|
|
||||||
export type ContextMenuOption = "separator" | Action;
|
export type ContextMenuOption = "separator" | Action;
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ type ContextMenuProps = {
|
|||||||
left: number;
|
left: number;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({
|
const ContextMenu = ({
|
||||||
@@ -32,7 +30,6 @@ const ContextMenu = ({
|
|||||||
left,
|
left,
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
elements,
|
|
||||||
}: ContextMenuProps) => {
|
}: ContextMenuProps) => {
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -40,10 +37,6 @@ const ContextMenu = ({
|
|||||||
top={top}
|
top={top}
|
||||||
left={left}
|
left={left}
|
||||||
fitInViewport={true}
|
fitInViewport={true}
|
||||||
offsetLeft={appState.offsetLeft}
|
|
||||||
offsetTop={appState.offsetTop}
|
|
||||||
viewportWidth={appState.width}
|
|
||||||
viewportHeight={appState.height}
|
|
||||||
>
|
>
|
||||||
<ul
|
<ul
|
||||||
className="context-menu"
|
className="context-menu"
|
||||||
@@ -55,14 +48,9 @@ const ContextMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionName = option.name;
|
const actionName = option.name;
|
||||||
let label = "";
|
const label = option.contextItemLabel
|
||||||
if (option.contextItemLabel) {
|
? t(option.contextItemLabel)
|
||||||
if (typeof option.contextItemLabel === "function") {
|
: "";
|
||||||
label = t(option.contextItemLabel(elements, appState));
|
|
||||||
} else {
|
|
||||||
label = t(option.contextItemLabel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
<button
|
<button
|
||||||
@@ -70,9 +58,7 @@ const ContextMenu = ({
|
|||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: option.checked?.(appState),
|
checkmark: option.checked?.(appState),
|
||||||
})}
|
})}
|
||||||
onClick={() =>
|
onClick={() => actionManager.executeAction(option)}
|
||||||
actionManager.executeAction(option, "contextMenu")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="context-menu-option__label">{label}</div>
|
<div className="context-menu-option__label">{label}</div>
|
||||||
<kbd className="context-menu-option__shortcut">
|
<kbd className="context-menu-option__shortcut">
|
||||||
@@ -111,7 +97,6 @@ type ContextMenuParams = {
|
|||||||
actionManager: ContextMenuProps["actionManager"];
|
actionManager: ContextMenuProps["actionManager"];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = (container: HTMLElement) => {
|
const handleClose = (container: HTMLElement) => {
|
||||||
@@ -140,7 +125,6 @@ export default {
|
|||||||
onCloseRequest={() => handleClose(params.container)}
|
onCloseRequest={() => handleClose(params.container)}
|
||||||
actionManager={params.actionManager}
|
actionManager={params.actionManager}
|
||||||
appState={params.appState}
|
appState={params.appState}
|
||||||
elements={params.elements}
|
|
||||||
/>,
|
/>,
|
||||||
getContextMenuNode(params.container),
|
getContextMenuNode(params.container),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import clsx from "clsx";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer, useDevice } from "../components/App";
|
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
import { back, close } from "./icons";
|
import { back, close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { queryFocusableElements } from "../utils";
|
|
||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -65,6 +64,14 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [islandNode, props.autofocus]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
|
const queryFocusableElements = (node: HTMLElement) => {
|
||||||
|
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||||
|
"button, a, input, select, textarea, div[tabindex]",
|
||||||
|
);
|
||||||
|
|
||||||
|
return focusableElements ? Array.from(focusableElements) : [];
|
||||||
|
};
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
(lastActiveElement as HTMLElement).focus();
|
(lastActiveElement as HTMLElement).focus();
|
||||||
props.onCloseRequest();
|
props.onCloseRequest();
|
||||||
@@ -87,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useDevice().isMobile ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="Dialog__content">{props.children}</div>
|
<div className="Dialog__content">{props.children}</div>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
<Section title={t("helpDialog.shortcuts")}>
|
<Section title={t("helpDialog.shortcuts")}>
|
||||||
<Columns>
|
<Columns>
|
||||||
<Column>
|
<Column>
|
||||||
<ShortcutIsland caption={t("helpDialog.tools")}>
|
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("toolBar.selection")}
|
label={t("toolBar.selection")}
|
||||||
shortcuts={["V", "1"]}
|
shortcuts={["V", "1"]}
|
||||||
@@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={["R", "2"]}
|
shortcuts={["R", "2"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
|
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
@@ -159,10 +159,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||||
<Shortcut
|
|
||||||
label={t("toolBar.eraser")}
|
|
||||||
shortcuts={[getShortcutKey("E")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.editSelectedShape")}
|
label={t("helpDialog.editSelectedShape")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -209,10 +205,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("helpDialog.preventBinding")}
|
label={t("helpDialog.preventBinding")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("toolBar.link")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
<ShortcutIsland caption={t("helpDialog.view")}>
|
<ShortcutIsland caption={t("helpDialog.view")}>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
@@ -363,10 +355,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.toggleElementLock")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("buttons.undo")}
|
label={t("buttons.undo")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { isEraserActive } from "../appState";
|
|
||||||
|
|
||||||
interface HintViewerProps {
|
interface HintViewerProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -20,32 +19,25 @@ interface HintViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (appState.isLibraryOpen) {
|
if (elementType === "arrow" || elementType === "line") {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
|
||||||
return t("hints.eraserRevert");
|
|
||||||
}
|
|
||||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
|
||||||
if (!multiMode) {
|
if (!multiMode) {
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElement");
|
||||||
}
|
}
|
||||||
return t("hints.linearElementMulti");
|
return t("hints.linearElementMulti");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "freedraw") {
|
if (elementType === "freedraw") {
|
||||||
return t("hints.freeDraw");
|
return t("hints.freeDraw");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "text") {
|
if (elementType === "text") {
|
||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||||
return t("hints.placeImage");
|
return t("hints.placeImage");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +69,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "selection") {
|
if (elementType === "selection") {
|
||||||
if (
|
if (
|
||||||
appState.draggingElement?.type === "selection" &&
|
appState.draggingElement?.type === "selection" &&
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { canvasToBlob } from "../data/blob";
|
import { canvasToBlob } from "../data/blob";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas } from "../scene/export";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
@@ -18,7 +19,6 @@ import OpenColor from "open-color";
|
|||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
import { ActionManager } from "../actions/manager";
|
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -90,7 +90,7 @@ const ImageExportModal = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
|||||||
icon={exportImage}
|
icon={exportImage}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.exportImage")}
|
aria-label={t("buttons.exportImage")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useIsMobile()}
|
||||||
title={t("buttons.exportImage")}
|
title={t("buttons.exportImage")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateLang = async () => {
|
const updateLang = async () => {
|
||||||
await setLanguage(currentLang);
|
await setLanguage(currentLang);
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
const currentLang =
|
const currentLang =
|
||||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||||
updateLang();
|
updateLang();
|
||||||
|
setLoading(false);
|
||||||
}, [props.langCode]);
|
}, [props.langCode]);
|
||||||
|
|
||||||
return loading ? <LoadingMessage /> : props.children;
|
return loading ? <LoadingMessage /> : props.children;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||||
@@ -11,9 +12,6 @@ import { Card } from "./Card";
|
|||||||
|
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
|
||||||
import { getFrame } from "../utils";
|
|
||||||
|
|
||||||
export type ExportCB = (
|
export type ExportCB = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -31,7 +29,7 @@ const JSONExportModal = ({
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionManager;
|
actionManager: ActionsManagerInterface;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
@@ -56,7 +54,7 @@ const JSONExportModal = ({
|
|||||||
aria-label={t("exportDialog.disk_button")}
|
aria-label={t("exportDialog.disk_button")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
actionManager.executeAction(actionSaveFileToDisk);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -72,10 +70,9 @@ const JSONExportModal = ({
|
|||||||
title={t("exportDialog.link_button")}
|
title={t("exportDialog.link_button")}
|
||||||
aria-label={t("exportDialog.link_button")}
|
aria-label={t("exportDialog.link_button")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
onExportToBackend(elements, appState, files, canvas);
|
onExportToBackend(elements, appState, files, canvas)
|
||||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -97,7 +94,7 @@ export const JSONExportDialog = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionsManagerInterface;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -117,7 +114,7 @@ export const JSONExportDialog = ({
|
|||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.export")}
|
aria-label={t("buttons.export")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useIsMobile()}
|
||||||
title={t("buttons.export")}
|
title={t("buttons.export")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
@@ -1,63 +1,9 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--sat);
|
|
||||||
bottom: var(--sab);
|
|
||||||
right: var(--sar);
|
|
||||||
z-index: 5;
|
|
||||||
|
|
||||||
box-shadow: var(--shadow-island);
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
margin: var(--space-factor);
|
|
||||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
|
||||||
|
|
||||||
.Island {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon__close {
|
|
||||||
.Modal__close {
|
|
||||||
width: calc(var(--space-factor) * 7);
|
|
||||||
height: calc(var(--space-factor) * 7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Island {
|
|
||||||
--padding: 0;
|
|
||||||
background-color: var(--island-bg-color);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
|
||||||
position: relative;
|
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__wrapper.animate {
|
|
||||||
transition: width 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
// when the rightside sidebar is docked, we need to resize the UI by its
|
|
||||||
// width, making the nested UI content shift to the left. To do this,
|
|
||||||
// we need the UI container to actually have dimensions set, but
|
|
||||||
// then we also need to disable pointer events else the canvas below
|
|
||||||
// wouldn't be interactive.
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
&__top-right {
|
&__top-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
|
import { useIsMobile } from "../components/App";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
@@ -25,8 +26,9 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
@@ -34,11 +36,6 @@ import { LibraryMenu } from "./LibraryMenu";
|
|||||||
|
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import "./Toolbar.scss";
|
import "./Toolbar.scss";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
import { useDevice } from "../components/App";
|
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -49,7 +46,6 @@ interface LayerUIProps {
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
@@ -57,9 +53,11 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: (
|
||||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
isMobile: boolean,
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
appState: AppState,
|
||||||
|
) => JSX.Element | null;
|
||||||
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
@@ -68,6 +66,7 @@ interface LayerUIProps {
|
|||||||
id: string;
|
id: string;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
@@ -77,7 +76,6 @@ const LayerUI = ({
|
|||||||
elements,
|
elements,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
@@ -86,7 +84,6 @@ const LayerUI = ({
|
|||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
renderCustomStats,
|
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
@@ -95,7 +92,7 @@ const LayerUI = ({
|
|||||||
id,
|
id,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -122,7 +119,6 @@ const LayerUI = ({
|
|||||||
const createExporter =
|
const createExporter =
|
||||||
(type: ExportType): ExportCB =>
|
(type: ExportType): ExportCB =>
|
||||||
async (exportedElements) => {
|
async (exportedElements) => {
|
||||||
trackEvent("export", type, "ui");
|
|
||||||
const fileHandle = await exportCanvas(
|
const fileHandle = await exportCanvas(
|
||||||
type,
|
type,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
@@ -239,7 +235,7 @@ const LayerUI = ({
|
|||||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||||
padding={2}
|
padding={2}
|
||||||
style={{
|
style={{
|
||||||
// we want to make sure this doesn't overflow so subtracting 200
|
// we want to make sure this doesn't overflow so substracting 200
|
||||||
// which is approximately height of zoom footer and top left menu items with some buffer
|
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||||
// if active file name is displayed, subtracting 248 to account for its height
|
// if active file name is displayed, subtracting 248 to account for its height
|
||||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
||||||
@@ -249,7 +245,7 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
activeTool={appState.activeTool.type}
|
elementType={appState.elementType}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -276,9 +272,7 @@ const LayerUI = ({
|
|||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClose={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertLibraryItems={(libraryItems) => {
|
onInsertShape={onInsertElements}
|
||||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
|
||||||
}}
|
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
@@ -319,17 +313,10 @@ const LayerUI = ({
|
|||||||
"zen-mode": zenModeEnabled,
|
"zen-mode": zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PenModeButton
|
|
||||||
zenModeEnabled={zenModeEnabled}
|
|
||||||
checked={appState.penMode}
|
|
||||||
onChange={onPenModeToggle}
|
|
||||||
title={t("toolBar.penMode")}
|
|
||||||
penDetected={appState.penDetected}
|
|
||||||
/>
|
|
||||||
<LockButton
|
<LockButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={zenModeEnabled}
|
||||||
checked={appState.activeTool.locked}
|
checked={appState.elementLocked}
|
||||||
onChange={() => onLockToggle()}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
<Island
|
<Island
|
||||||
@@ -341,14 +328,13 @@ const LayerUI = ({
|
|||||||
<HintViewer
|
<HintViewer
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
isMobile={device.isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
appState={appState}
|
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
activeTool={appState.activeTool}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onImageAction={({ pointerType }) => {
|
onImageAction={({ pointerType }) => {
|
||||||
onImageAction({
|
onImageAction({
|
||||||
@@ -363,6 +349,7 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
@@ -375,11 +362,23 @@ const LayerUI = ({
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserList
|
<UserList>
|
||||||
collaborators={appState.collaborators}
|
{appState.collaborators.size > 0 &&
|
||||||
actionManager={actionManager}
|
Array.from(appState.collaborators)
|
||||||
/>
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
{renderTopRightUI?.(device.isMobile, appState)}
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
|
.map(([clientId, client]) => (
|
||||||
|
<Tooltip
|
||||||
|
label={client.username || "Unknown user"}
|
||||||
|
key={clientId}
|
||||||
|
>
|
||||||
|
{actionManager.renderAction("goToCollaborator", {
|
||||||
|
id: clientId,
|
||||||
|
})}
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</UserList>
|
||||||
|
{renderTopRightUI?.(isMobile, appState)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -409,39 +408,16 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
{!viewModeEnabled && (
|
{!viewModeEnabled && (
|
||||||
<>
|
<div
|
||||||
<div
|
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
zenModeEnabled,
|
||||||
zenModeEnabled,
|
})}
|
||||||
})}
|
>
|
||||||
>
|
{actionManager.renderAction("undo", { size: "small" })}
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
{actionManager.renderAction("redo", { size: "small" })}
|
||||||
{actionManager.renderAction("redo", { size: "small" })}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={clsx("eraser-buttons zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
|
||||||
zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("eraser", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{!viewModeEnabled &&
|
|
||||||
appState.multiElement &&
|
|
||||||
device.isTouchScreen && (
|
|
||||||
<div
|
|
||||||
className={clsx("finalize-button zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
|
||||||
zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("finalize", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +456,7 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const dialogs = (
|
const dialogs = (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={appState.errorMessage}
|
message={appState.errorMessage}
|
||||||
@@ -509,24 +485,7 @@ const LayerUI = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStats = () => {
|
return isMobile ? (
|
||||||
if (!appState.showStats) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stats
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
elements={elements}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return device.isMobile ? (
|
|
||||||
<>
|
<>
|
||||||
{dialogs}
|
{dialogs}
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
@@ -538,8 +497,7 @@ const LayerUI = ({
|
|||||||
renderImageExportDialog={renderImageExportDialog}
|
renderImageExportDialog={renderImageExportDialog}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onLockToggle={() => onLockToggle()}
|
onLockToggle={onLockToggle}
|
||||||
onPenModeToggle={onPenModeToggle}
|
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
@@ -547,48 +505,33 @@ const LayerUI = ({
|
|||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
onImageAction={onImageAction}
|
onImageAction={onImageAction}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderStats={renderStats}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div
|
||||||
<div
|
className={clsx("layer-ui__wrapper", {
|
||||||
className={clsx("layer-ui__wrapper", {
|
"disable-pointerEvents":
|
||||||
"disable-pointerEvents":
|
appState.draggingElement ||
|
||||||
appState.draggingElement ||
|
appState.resizingElement ||
|
||||||
appState.resizingElement ||
|
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||||
(appState.editingElement &&
|
})}
|
||||||
!isTextElement(appState.editingElement)),
|
>
|
||||||
})}
|
{dialogs}
|
||||||
style={
|
{renderFixedSideContainer()}
|
||||||
appState.isLibraryOpen &&
|
{renderBottomAppMenu()}
|
||||||
appState.isLibraryMenuDocked &&
|
{appState.scrolledOutside && (
|
||||||
device.canDeviceFitSidebar
|
<button
|
||||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
className="scroll-back-to-content"
|
||||||
: {}
|
onClick={() => {
|
||||||
}
|
setAppState({
|
||||||
>
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
{dialogs}
|
});
|
||||||
{renderFixedSideContainer()}
|
}}
|
||||||
{renderBottomAppMenu()}
|
>
|
||||||
{renderStats()}
|
{t("buttons.scrollBackToContent")}
|
||||||
{appState.scrolledOutside && (
|
</button>
|
||||||
<button
|
|
||||||
className="scroll-back-to-content"
|
|
||||||
onClick={() => {
|
|
||||||
setAppState({
|
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("buttons.scrollBackToContent")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{appState.isLibraryOpen && (
|
|
||||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import clsx from "clsx";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { capitalizeString } from "../utils";
|
import { capitalizeString } from "../utils";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
import { useDevice } from "./App";
|
|
||||||
|
|
||||||
const LIBRARY_ICON = (
|
const LIBRARY_ICON = (
|
||||||
<svg viewBox="0 0 576 512">
|
<svg viewBox="0 0 576 512">
|
||||||
@@ -20,7 +18,6 @@ export const LibraryButton: React.FC<{
|
|||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}> = ({ appState, setAppState, isMobile }) => {
|
}> = ({ appState, setAppState, isMobile }) => {
|
||||||
const device = useDevice();
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -37,19 +34,7 @@ export const LibraryButton: React.FC<{
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="editor-library"
|
name="editor-library"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
document
|
setAppState({ isLibraryOpen: event.target.checked });
|
||||||
.querySelector(".layer-ui__wrapper")
|
|
||||||
?.classList.remove("animate");
|
|
||||||
const nextState = event.target.checked;
|
|
||||||
setAppState({ isLibraryOpen: nextState });
|
|
||||||
// track only openings
|
|
||||||
if (nextState) {
|
|
||||||
trackEvent(
|
|
||||||
"library",
|
|
||||||
"toggleLibrary (open)",
|
|
||||||
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
checked={appState.isLibraryOpen}
|
checked={appState.isLibraryOpen}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__library {
|
.layer-ui__library {
|
||||||
|
margin: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -10,41 +11,25 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 2px 0 15px 0;
|
margin: 2px 0;
|
||||||
.Spinner {
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
// 2px from the left to account for focus border of left-most button
|
// 2px from the left to account for focus border of left-most button
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
a {
|
||||||
.layer-ui__library {
|
margin-inline-start: auto;
|
||||||
padding: 0;
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
height: 100%;
|
padding-inline-end: 18px;
|
||||||
}
|
white-space: nowrap;
|
||||||
.library-menu-items-container {
|
}
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__library-message {
|
.layer-ui__library-message {
|
||||||
padding: 2em 4em;
|
padding: 10px 20px;
|
||||||
min-width: 200px;
|
max-width: 200px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
.Spinner {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-library-success {
|
.publish-library-success {
|
||||||
@@ -67,38 +52,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-menu-browse-button {
|
|
||||||
width: 80%;
|
|
||||||
min-height: 22px;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: $oc-white;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-decoration: none !important;
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--color-primary-darkest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-menu-browse-button--mobile {
|
|
||||||
min-height: 22px;
|
|
||||||
margin-left: auto;
|
|
||||||
a {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import {
|
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||||
useRef,
|
import Library from "../data/library";
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
RefObject,
|
|
||||||
forwardRef,
|
|
||||||
} from "react";
|
|
||||||
import Library, { libraryItemsAtom } from "../data/library";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import {
|
||||||
@@ -25,11 +18,7 @@ import "./LibraryMenu.scss";
|
|||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { EVENT } from "../constants";
|
import { EVENT } from "../constants";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { trackEvent } from "../analytics";
|
import { arrayToMap } from "../utils";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { jotaiScope } from "../jotai";
|
|
||||||
import Spinner from "./Spinner";
|
|
||||||
import { useDevice } from "./App";
|
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
@@ -64,20 +53,9 @@ const getSelectedItems = (
|
|||||||
selectedItems: LibraryItem["id"][],
|
selectedItems: LibraryItem["id"][],
|
||||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
const LibraryMenuWrapper = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{ children: React.ReactNode }
|
|
||||||
>(({ children }, ref) => {
|
|
||||||
return (
|
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
|
||||||
{children}
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LibraryMenu = ({
|
export const LibraryMenu = ({
|
||||||
onClose,
|
onClose,
|
||||||
onInsertLibraryItems,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
theme,
|
||||||
@@ -91,7 +69,7 @@ export const LibraryMenu = ({
|
|||||||
}: {
|
}: {
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
@@ -104,30 +82,17 @@ export const LibraryMenu = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const device = useDevice();
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
useOnClickOutside(
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
ref,
|
return;
|
||||||
useCallback(
|
}
|
||||||
(event) => {
|
onClose();
|
||||||
// If click on the library icon, do nothing.
|
});
|
||||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (event.key === KEYS.ESCAPE) {
|
||||||
event.key === KEYS.ESCAPE &&
|
|
||||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
|
||||||
) {
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,8 +100,13 @@ export const LibraryMenu = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||||
|
|
||||||
|
const [loadingState, setIsLoading] = useState<
|
||||||
|
"preloading" | "loading" | "ready"
|
||||||
|
>("preloading");
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -144,35 +114,55 @@ export const LibraryMenu = ({
|
|||||||
url: string;
|
url: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
}>(null);
|
}>(null);
|
||||||
|
const loadingTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
useEffect(() => {
|
||||||
|
Promise.race([
|
||||||
|
new Promise((resolve) => {
|
||||||
|
loadingTimerRef.current = window.setTimeout(() => {
|
||||||
|
resolve("loading");
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
library.loadLibrary().then((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setIsLoading("ready");
|
||||||
|
}),
|
||||||
|
]).then((data) => {
|
||||||
|
if (data === "loading") {
|
||||||
|
setIsLoading("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(loadingTimerRef.current!);
|
||||||
|
};
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(
|
const removeFromLibrary = useCallback(async () => {
|
||||||
async (libraryItems: LibraryItems) => {
|
const items = await library.loadLibrary();
|
||||||
const nextItems = libraryItems.filter(
|
|
||||||
(item) => !selectedItems.includes(item.id),
|
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||||
);
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
library.setLibrary(nextItems).catch(() => {
|
setLibraryItems(items);
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
});
|
});
|
||||||
setSelectedItems([]);
|
setSelectedItems([]);
|
||||||
},
|
setLibraryItems(nextItems);
|
||||||
[library, setAppState, selectedItems, setSelectedItems],
|
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||||
);
|
|
||||||
|
|
||||||
const resetLibrary = useCallback(() => {
|
const resetLibrary = useCallback(() => {
|
||||||
library.resetLibrary();
|
library.resetLibrary();
|
||||||
|
setLibraryItems([]);
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}, [library, focusContainer]);
|
}, [library, focusContainer]);
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
const addToLibrary = useCallback(
|
||||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
async (elements: LibraryItem["elements"]) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
|
||||||
if (elements.some((element) => element.type === "image")) {
|
if (elements.some((element) => element.type === "image")) {
|
||||||
return setAppState({
|
return setAppState({
|
||||||
errorMessage: "Support for adding images to the library coming soon!",
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const items = await library.loadLibrary();
|
||||||
const nextItems: LibraryItems = [
|
const nextItems: LibraryItems = [
|
||||||
{
|
{
|
||||||
status: "unpublished",
|
status: "unpublished",
|
||||||
@@ -180,12 +170,14 @@ export const LibraryMenu = ({
|
|||||||
id: randomId(),
|
id: randomId(),
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
},
|
},
|
||||||
...libraryItems,
|
...items,
|
||||||
];
|
];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
library.setLibrary(nextItems).catch(() => {
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
});
|
});
|
||||||
|
setLibraryItems(nextItems);
|
||||||
},
|
},
|
||||||
[onAddToLibrary, library, setAppState],
|
[onAddToLibrary, library, setAppState],
|
||||||
);
|
);
|
||||||
@@ -224,7 +216,7 @@ export const LibraryMenu = ({
|
|||||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
const onPublishLibSuccess = useCallback(
|
const onPublishLibSuccess = useCallback(
|
||||||
(data, libraryItems: LibraryItems) => {
|
(data) => {
|
||||||
setShowPublishLibraryDialog(false);
|
setShowPublishLibraryDialog(false);
|
||||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
const nextLibItems = libraryItems.slice();
|
const nextLibItems = libraryItems.slice();
|
||||||
@@ -233,71 +225,102 @@ export const LibraryMenu = ({
|
|||||||
libItem.status = "published";
|
libItem.status = "published";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
library.setLibrary(nextLibItems);
|
library.saveLibrary(nextLibItems);
|
||||||
|
setLibraryItems(nextLibItems);
|
||||||
},
|
},
|
||||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
[
|
||||||
|
setShowPublishLibraryDialog,
|
||||||
|
setPublishLibSuccess,
|
||||||
|
libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
library,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
libraryItemsData.status === "loading" &&
|
LibraryItem["id"] | null
|
||||||
!libraryItemsData.isInitialized
|
>(null);
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<LibraryMenuWrapper ref={ref}>
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
<Spinner size="2em" />
|
|
||||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
|
||||||
</div>
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return loadingState === "preloading" ? null : (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
{showPublishLibraryDialog && (
|
{showPublishLibraryDialog && (
|
||||||
<PublishLibrary
|
<PublishLibrary
|
||||||
onClose={() => setShowPublishLibraryDialog(false)}
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
libraryItems={getSelectedItems(
|
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||||
libraryItemsData.libraryItems,
|
|
||||||
selectedItems,
|
|
||||||
)}
|
|
||||||
appState={appState}
|
appState={appState}
|
||||||
onSuccess={(data) =>
|
onSuccess={onPublishLibSuccess}
|
||||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
onError={(error) => window.alert(error)}
|
onError={(error) => window.alert(error)}
|
||||||
updateItemsInStorage={() =>
|
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||||
library.setLibrary(libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
onRemove={(id: string) =>
|
onRemove={(id: string) =>
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{publishLibSuccess && renderPublishSuccess()}
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
<LibraryMenuItems
|
|
||||||
isLoading={libraryItemsData.status === "loading"}
|
{loadingState === "loading" ? (
|
||||||
libraryItems={libraryItemsData.libraryItems}
|
<div className="layer-ui__library-message">
|
||||||
onRemoveFromLibrary={() =>
|
{t("labels.libraryLoadingMessage")}
|
||||||
removeFromLibrary(libraryItemsData.libraryItems)
|
</div>
|
||||||
}
|
) : (
|
||||||
onAddToLibrary={(elements) =>
|
<LibraryMenuItems
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
libraryItems={libraryItems}
|
||||||
}
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
onAddToLibrary={addToLibrary}
|
||||||
pendingElements={pendingElements}
|
onInsertShape={onInsertShape}
|
||||||
setAppState={setAppState}
|
pendingElements={pendingElements}
|
||||||
appState={appState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
library={library}
|
library={library}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
files={files}
|
files={files}
|
||||||
id={id}
|
id={id}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
onToggle={(id, event) => {
|
||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
const shouldSelect = !selectedItems.includes(id);
|
||||||
resetLibrary={resetLibrary}
|
|
||||||
/>
|
if (shouldSelect) {
|
||||||
</LibraryMenuWrapper>
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
|
const rangeStart = libraryItems.findIndex(
|
||||||
|
(item) => item.id === lastSelectedItem,
|
||||||
|
);
|
||||||
|
const rangeEnd = libraryItems.findIndex(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rangeStart === -1 || rangeEnd === -1) {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
|
const nextSelectedIds = libraryItems.reduce(
|
||||||
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
|
if (
|
||||||
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
|
selectedItemsMap.has(item.id)
|
||||||
|
) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedItems(nextSelectedIds);
|
||||||
|
} else {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
}
|
||||||
|
setLastSelectedItem(id);
|
||||||
|
} else {
|
||||||
|
setLastSelectedItem(null);
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,17 +2,8 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-menu-items-container {
|
.library-menu-items-container {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0.5rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.library-actions {
|
.library-actions {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-right: auto;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
button .library-actions-counter {
|
button .library-actions-counter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -96,16 +87,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&__items {
|
&__items {
|
||||||
flex: 1;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0.6em 0.2em;
|
margin: 0.6em 0.2em;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { chunk } from "lodash";
|
import { chunk } from "lodash";
|
||||||
import React, { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -11,57 +11,48 @@ import {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
LibraryItems,
|
LibraryItems,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { arrayToMap, muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { useDevice } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
import { VERSIONS } from "../constants";
|
||||||
import Spinner from "./Spinner";
|
|
||||||
import { fileOpen } from "../data/filesystem";
|
|
||||||
|
|
||||||
import { SidebarLockButton } from "./SidebarLockButton";
|
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
isLoading,
|
|
||||||
libraryItems,
|
libraryItems,
|
||||||
onRemoveFromLibrary,
|
onRemoveFromLibrary,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
onInsertLibraryItems,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
theme,
|
||||||
setAppState,
|
setAppState,
|
||||||
appState,
|
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
library,
|
library,
|
||||||
files,
|
files,
|
||||||
id,
|
id,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onSelectItems,
|
onToggle,
|
||||||
onPublish,
|
onPublish,
|
||||||
resetLibrary,
|
resetLibrary,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onRemoveFromLibrary: () => void;
|
onRemoveFromLibrary: () => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
appState: AppState;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
resetLibrary: () => void;
|
resetLibrary: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -93,7 +84,9 @@ const LibraryMenuItems = ({
|
|||||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||||
|
|
||||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
const device = useDevice();
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const renderLibraryActions = () => {
|
const renderLibraryActions = () => {
|
||||||
const itemsSelected = !!selectedItems.length;
|
const itemsSelected = !!selectedItems.length;
|
||||||
const items = itemsSelected
|
const items = itemsSelected
|
||||||
@@ -104,34 +97,24 @@ const LibraryMenuItems = ({
|
|||||||
: t("buttons.resetLibrary");
|
: t("buttons.resetLibrary");
|
||||||
return (
|
return (
|
||||||
<div className="library-actions">
|
<div className="library-actions">
|
||||||
{!itemsSelected && (
|
{(!itemsSelected || !isMobile) && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
key="import"
|
key="import"
|
||||||
type="button"
|
type="button"
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
icon={load}
|
icon={load}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
try {
|
importLibraryFromJSON(library)
|
||||||
await library.updateLibrary({
|
.then(() => {
|
||||||
libraryItems: fileOpen({
|
// Close and then open to get the libraries updated
|
||||||
description: "Excalidraw library files",
|
setAppState({ isLibraryOpen: false });
|
||||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
setAppState({ isLibraryOpen: true });
|
||||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
})
|
||||||
/*
|
.catch(muteFSAbortError)
|
||||||
extensions: [".json", ".excalidrawlib"],
|
.catch((error) => {
|
||||||
*/
|
setAppState({ errorMessage: error.message });
|
||||||
}),
|
|
||||||
merge: true,
|
|
||||||
openLibraryMenu: true,
|
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.name === "AbortError") {
|
|
||||||
console.warn(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="library-actions--load"
|
className="library-actions--load"
|
||||||
/>
|
/>
|
||||||
@@ -147,7 +130,7 @@ const LibraryMenuItems = ({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const libraryItems = itemsSelected
|
const libraryItems = itemsSelected
|
||||||
? items
|
? items
|
||||||
: await library.getLatestLibrary();
|
: await library.loadLibrary();
|
||||||
saveLibraryAsJSON(libraryItems)
|
saveLibraryAsJSON(libraryItems)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -179,7 +162,7 @@ const LibraryMenuItems = ({
|
|||||||
</ToolButton>
|
</ToolButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{itemsSelected && (
|
{itemsSelected && !isPublished && (
|
||||||
<Tooltip label={t("hints.publishLibrary")}>
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -189,7 +172,7 @@ const LibraryMenuItems = ({
|
|||||||
className="library-actions--publish"
|
className="library-actions--publish"
|
||||||
onClick={onPublish}
|
onClick={onPublish}
|
||||||
>
|
>
|
||||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||||
{selectedItems.length > 0 && (
|
{selectedItems.length > 0 && (
|
||||||
<span className="library-actions-counter">
|
<span className="library-actions-counter">
|
||||||
{selectedItems.length}
|
{selectedItems.length}
|
||||||
@@ -198,89 +181,17 @@ const LibraryMenuItems = ({
|
|||||||
</ToolButton>
|
</ToolButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{device.isMobile && (
|
|
||||||
<div className="library-menu-browse-button--mobile">
|
|
||||||
<a
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
|
||||||
const referrer =
|
const referrer =
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
|
const isPublished = selectedItems.some(
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||||
LibraryItem["id"] | null
|
);
|
||||||
>(null);
|
|
||||||
|
|
||||||
const onItemSelectToggle = (
|
|
||||||
id: LibraryItem["id"],
|
|
||||||
event: React.MouseEvent,
|
|
||||||
) => {
|
|
||||||
const shouldSelect = !selectedItems.includes(id);
|
|
||||||
|
|
||||||
const orderedItems = [...unpublishedItems, ...publishedItems];
|
|
||||||
|
|
||||||
if (shouldSelect) {
|
|
||||||
if (event.shiftKey && lastSelectedItem) {
|
|
||||||
const rangeStart = orderedItems.findIndex(
|
|
||||||
(item) => item.id === lastSelectedItem,
|
|
||||||
);
|
|
||||||
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
|
|
||||||
|
|
||||||
if (rangeStart === -1 || rangeEnd === -1) {
|
|
||||||
onSelectItems([...selectedItems, id]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedItemsMap = arrayToMap(selectedItems);
|
|
||||||
const nextSelectedIds = orderedItems.reduce(
|
|
||||||
(acc: LibraryItem["id"][], item, idx) => {
|
|
||||||
if (
|
|
||||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
|
||||||
selectedItemsMap.has(item.id)
|
|
||||||
) {
|
|
||||||
acc.push(item.id);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
onSelectItems(nextSelectedIds);
|
|
||||||
} else {
|
|
||||||
onSelectItems([...selectedItems, id]);
|
|
||||||
}
|
|
||||||
setLastSelectedItem(id);
|
|
||||||
} else {
|
|
||||||
setLastSelectedItem(null);
|
|
||||||
onSelectItems(selectedItems.filter((_id) => _id !== id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInsertedElements = (id: string) => {
|
|
||||||
let targetElements;
|
|
||||||
if (selectedItems.includes(id)) {
|
|
||||||
targetElements = libraryItems.filter((item) =>
|
|
||||||
selectedItems.includes(item.id),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
targetElements = libraryItems.filter((item) => item.id === id);
|
|
||||||
}
|
|
||||||
return targetElements;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createLibraryItemCompo = (params: {
|
const createLibraryItemCompo = (params: {
|
||||||
item:
|
item:
|
||||||
@@ -302,12 +213,8 @@ const LibraryMenuItems = ({
|
|||||||
onClick={params.onClick || (() => {})}
|
onClick={params.onClick || (() => {})}
|
||||||
id={params.item?.id || null}
|
id={params.item?.id || null}
|
||||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||||
onToggle={onItemSelectToggle}
|
onToggle={(id, event) => {
|
||||||
onDrag={(id, event) => {
|
onToggle(id, event);
|
||||||
event.dataTransfer.setData(
|
|
||||||
MIME_TYPES.excalidrawlib,
|
|
||||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -327,7 +234,7 @@ const LibraryMenuItems = ({
|
|||||||
if (item.id) {
|
if (item.id) {
|
||||||
return createLibraryItemCompo({
|
return createLibraryItemCompo({
|
||||||
item,
|
item,
|
||||||
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
|
onClick: () => onInsertShape(item.elements),
|
||||||
key: item.id,
|
key: item.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -366,192 +273,49 @@ const LibraryMenuItems = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const unpublishedItems = libraryItems.filter(
|
|
||||||
(item) => item.status !== "published",
|
|
||||||
);
|
|
||||||
const publishedItems = libraryItems.filter(
|
const publishedItems = libraryItems.filter(
|
||||||
(item) => item.status === "published",
|
(item) => item.status === "published",
|
||||||
);
|
);
|
||||||
|
const unpublishedItems = [
|
||||||
|
// append pending library item
|
||||||
|
...(pendingElements.length
|
||||||
|
? [{ id: null, elements: pendingElements }]
|
||||||
|
: []),
|
||||||
|
...libraryItems.filter((item) => item.status !== "published"),
|
||||||
|
];
|
||||||
|
|
||||||
const renderLibraryHeader = () => {
|
return (
|
||||||
return (
|
<div className="library-menu-items-container">
|
||||||
<>
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
{renderLibraryActions()}
|
{renderLibraryActions()}
|
||||||
{device.canDeviceFitSidebar && (
|
<a
|
||||||
<>
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
<div className="layer-ui__sidebar-lock-button">
|
window.name || "_blank"
|
||||||
<SidebarLockButton
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
checked={appState.isLibraryMenuDocked}
|
VERSIONS.excalidrawLibrary
|
||||||
onChange={() => {
|
}`}
|
||||||
document
|
target="_excalidraw_libraries"
|
||||||
.querySelector(".layer-ui__wrapper")
|
>
|
||||||
?.classList.add("animate");
|
{t("labels.libraries")}
|
||||||
const nextState = !appState.isLibraryMenuDocked;
|
</a>
|
||||||
setAppState({
|
</div>
|
||||||
isLibraryMenuDocked: nextState,
|
|
||||||
});
|
|
||||||
trackEvent(
|
|
||||||
"library",
|
|
||||||
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
|
|
||||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!device.isMobile && (
|
|
||||||
<div className="ToolIcon__icon__close">
|
|
||||||
<button
|
|
||||||
className="Modal__close"
|
|
||||||
onClick={() =>
|
|
||||||
setAppState({
|
|
||||||
isLibraryOpen: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label={t("buttons.close")}
|
|
||||||
>
|
|
||||||
{close}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibraryMenuItems = () => {
|
|
||||||
return (
|
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
className="library-menu-items-container__items"
|
className="library-menu-items-container__items"
|
||||||
align="start"
|
align="start"
|
||||||
gap={1}
|
gap={1}
|
||||||
style={{
|
|
||||||
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
|
||||||
marginBottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div className="separator">
|
<div className="separator">{t("labels.personalLib")}</div>
|
||||||
{(pendingElements.length > 0 ||
|
{renderLibrarySection(unpublishedItems)}
|
||||||
unpublishedItems.length > 0 ||
|
|
||||||
publishedItems.length > 0) && (
|
|
||||||
<div>{t("labels.personalLib")}</div>
|
|
||||||
)}
|
|
||||||
{isLoading && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginLeft: "auto",
|
|
||||||
marginRight: "1rem",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
fontWeight: "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ transform: "translateY(2px)" }}>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!pendingElements.length && !unpublishedItems.length ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 65,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: ".9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("library.noItems")}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: ".6rem 0",
|
|
||||||
fontSize: ".8em",
|
|
||||||
width: "70%",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{publishedItems.length > 0
|
|
||||||
? t("library.hint_emptyPrivateLibrary")
|
|
||||||
: t("library.hint_emptyLibrary")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
renderLibrarySection([
|
|
||||||
// append pending library item
|
|
||||||
...(pendingElements.length
|
|
||||||
? [{ id: null, elements: pendingElements }]
|
|
||||||
: []),
|
|
||||||
...unpublishedItems,
|
|
||||||
])
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
<>
|
<>
|
||||||
{(publishedItems.length > 0 ||
|
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||||
(!device.isMobile &&
|
|
||||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
{renderLibrarySection(publishedItems)}
|
||||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
|
||||||
)}
|
|
||||||
{publishedItems.length > 0 ? (
|
|
||||||
renderLibrarySection(publishedItems)
|
|
||||||
) : unpublishedItems.length > 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: "1rem 0",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: ".9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("library.noItems")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibraryFooter = () => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="library-menu-browse-button"
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="library-menu-items-container"
|
|
||||||
style={
|
|
||||||
device.isMobile
|
|
||||||
? {
|
|
||||||
minHeight: "200px",
|
|
||||||
maxHeight: "70vh",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
|
||||||
{renderLibraryHeader()}
|
|
||||||
{renderLibraryMenuItems()}
|
|
||||||
{!device.isMobile && renderLibraryFooter()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-unit {
|
.library-unit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--button-gray-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.theme--dark .library-unit {
|
||||||
|
border-color: rgb(48, 48, 48);
|
||||||
|
}
|
||||||
|
|
||||||
.library-unit__dragger {
|
.library-unit__dragger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDevice } from "../components/App";
|
import { MIME_TYPES } from "../constants";
|
||||||
|
import { useIsMobile } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { BinaryFiles, LibraryItem } from "../types";
|
import { BinaryFiles, LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
@@ -28,7 +29,6 @@ export const LibraryUnit = ({
|
|||||||
onClick,
|
onClick,
|
||||||
selected,
|
selected,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDrag,
|
|
||||||
}: {
|
}: {
|
||||||
id: LibraryItem["id"] | /** for pending item */ null;
|
id: LibraryItem["id"] | /** for pending item */ null;
|
||||||
elements?: LibraryItem["elements"];
|
elements?: LibraryItem["elements"];
|
||||||
@@ -37,7 +37,6 @@ export const LibraryUnit = ({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||||
onDrag: (id: string, event: React.DragEvent) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,7 +66,7 @@ export const LibraryUnit = ({
|
|||||||
}, [elements, files]);
|
}, [elements, files]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDevice().isMobile;
|
const isMobile = useIsMobile();
|
||||||
const adder = isPending && (
|
const adder = isPending && (
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
@@ -100,12 +99,11 @@ export const LibraryUnit = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (!id) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
onDrag(id, event);
|
event.dataTransfer.setData(
|
||||||
|
MIME_TYPES.excalidrawlib,
|
||||||
|
JSON.stringify(elements),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adder}
|
{adder}
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import Spinner from "./Spinner";
|
|
||||||
|
|
||||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
|
||||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!delay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsWaiting(false);
|
|
||||||
}, delay);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [delay]);
|
|
||||||
|
|
||||||
if (isWaiting) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const LoadingMessage = () => {
|
||||||
|
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||||
return (
|
return (
|
||||||
<div className="LoadingMessage">
|
<div className="LoadingMessage">
|
||||||
<div>
|
<span>{t("labels.loadingScene")}</span>
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
|||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
@@ -17,7 +17,6 @@ import { LockButton } from "./LockButton";
|
|||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -29,13 +28,9 @@ type MobileMenuProps = {
|
|||||||
libraryMenu: JSX.Element | null;
|
libraryMenu: JSX.Element | null;
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderCustomFooter?: (
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
isMobile: boolean,
|
|
||||||
appState: AppState,
|
|
||||||
) => JSX.Element | null;
|
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
@@ -43,7 +38,6 @@ type MobileMenuProps = {
|
|||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
renderStats: () => JSX.Element | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -56,7 +50,6 @@ export const MobileMenu = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
|
||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
@@ -64,7 +57,6 @@ export const MobileMenu = ({
|
|||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderStats,
|
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@@ -77,9 +69,8 @@ export const MobileMenu = ({
|
|||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
appState={appState}
|
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
activeTool={appState.activeTool}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onImageAction={({ pointerType }) => {
|
onImageAction={({ pointerType }) => {
|
||||||
onImageAction({
|
onImageAction({
|
||||||
@@ -91,7 +82,7 @@ export const MobileMenu = ({
|
|||||||
</Island>
|
</Island>
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||||
<LockButton
|
<LockButton
|
||||||
checked={appState.activeTool.locked}
|
checked={appState.elementLocked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
isMobile
|
isMobile
|
||||||
@@ -101,13 +92,6 @@ export const MobileMenu = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
isMobile
|
isMobile
|
||||||
/>
|
/>
|
||||||
<PenModeButton
|
|
||||||
checked={appState.penMode}
|
|
||||||
onChange={onPenModeToggle}
|
|
||||||
title={t("toolBar.penMode")}
|
|
||||||
isMobile
|
|
||||||
penDetected={appState.penDetected}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -119,12 +103,6 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderAppToolbar = () => {
|
const renderAppToolbar = () => {
|
||||||
// Render eraser conditionally in mobile
|
|
||||||
const showEraser =
|
|
||||||
!appState.viewModeEnabled &&
|
|
||||||
!appState.editingElement &&
|
|
||||||
getSelectedElements(elements, appState).length === 0;
|
|
||||||
|
|
||||||
if (viewModeEnabled) {
|
if (viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
@@ -132,16 +110,12 @@ export const MobileMenu = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
{actionManager.renderAction("toggleCanvasMenu")}
|
{actionManager.renderAction("toggleCanvasMenu")}
|
||||||
{actionManager.renderAction("toggleEditMenu")}
|
{actionManager.renderAction("toggleEditMenu")}
|
||||||
|
|
||||||
{actionManager.renderAction("undo")}
|
{actionManager.renderAction("undo")}
|
||||||
{actionManager.renderAction("redo")}
|
{actionManager.renderAction("redo")}
|
||||||
{showEraser && actionManager.renderAction("eraser")}
|
|
||||||
|
|
||||||
{actionManager.renderAction(
|
{actionManager.renderAction(
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||||
)}
|
)}
|
||||||
@@ -186,7 +160,6 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!viewModeEnabled && renderToolbar()}
|
{!viewModeEnabled && renderToolbar()}
|
||||||
{renderStats()}
|
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -205,11 +178,20 @@ export const MobileMenu = ({
|
|||||||
{appState.collaborators.size > 0 && (
|
{appState.collaborators.size > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
<UserList
|
<UserList mobile>
|
||||||
mobile
|
{Array.from(appState.collaborators)
|
||||||
collaborators={appState.collaborators}
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
actionManager={actionManager}
|
.filter(
|
||||||
/>
|
([_, client]) => Object.keys(client).length !== 0,
|
||||||
|
)
|
||||||
|
.map(([clientId, client]) => (
|
||||||
|
<React.Fragment key={clientId}>
|
||||||
|
{actionManager.renderAction("goToCollaborator", {
|
||||||
|
id: clientId,
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</UserList>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -223,7 +205,7 @@ export const MobileMenu = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
activeTool={appState.activeTool.type}
|
elementType={appState.elementType}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useDevice } from "./App";
|
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { THEME } from "../constants";
|
import { THEME } from "../constants";
|
||||||
|
|
||||||
@@ -59,17 +59,17 @@ export const Modal = (props: {
|
|||||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const device = useDevice();
|
const isMobile = useIsMobile();
|
||||||
const isMobileRef = useRef(device.isMobile);
|
const isMobileRef = useRef(isMobile);
|
||||||
isMobileRef.current = device.isMobile;
|
isMobileRef.current = isMobile;
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (div) {
|
if (div) {
|
||||||
div.classList.toggle("excalidraw--mobile", device.isMobile);
|
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||||
}
|
}
|
||||||
}, [div, device.isMobile]);
|
}, [div, isMobile]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const isDarkTheme =
|
const isDarkTheme =
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import "./ToolIcon.scss";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
|
|
||||||
type PenModeIconProps = {
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange?(): void;
|
|
||||||
zenModeEnabled?: boolean;
|
|
||||||
isMobile?: boolean;
|
|
||||||
penDetected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
CHECKED: (
|
|
||||||
<svg
|
|
||||||
width="205"
|
|
||||||
height="205"
|
|
||||||
viewBox="0 0 205 205"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path d="m35 195-25-29.17V50h50v115l-25 30" />
|
|
||||||
<path d="M10 40V10h50v30H10" />
|
|
||||||
<path d="M125 145h70v50h-70" />
|
|
||||||
<path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
UNCHECKED: (
|
|
||||||
<svg
|
|
||||||
width="205"
|
|
||||||
height="205"
|
|
||||||
viewBox="0 0 205 205"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="unlocked-icon rtl-mirror"
|
|
||||||
>
|
|
||||||
<path d="m35 195-25-29.17V50h50v115l-25 30" />
|
|
||||||
<path d="M10 40V10h50v30H10" />
|
|
||||||
<path d="M125 145h70v50h-70" />
|
|
||||||
<path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PenModeButton = (props: PenModeIconProps) => {
|
|
||||||
if (!props.penDetected) {
|
|
||||||
if (props.isMobile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
{
|
|
||||||
"is-mobile": props.isMobile,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="ToolIcon__icon ToolIcon__hidden" />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
{
|
|
||||||
"is-mobile": props.isMobile,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
title={`${props.title}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
name={props.name}
|
|
||||||
onChange={props.onChange}
|
|
||||||
checked={props.checked}
|
|
||||||
aria-label={props.title}
|
|
||||||
/>
|
|
||||||
<div className="ToolIcon__icon">
|
|
||||||
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
||||||
import "./Popover.scss";
|
import "./Popover.scss";
|
||||||
import { unstable_batchedUpdates } from "react-dom";
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
import { queryFocusableElements } from "../utils";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
top?: number;
|
top?: number;
|
||||||
@@ -10,10 +8,6 @@ type Props = {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onCloseRequest?(event: PointerEvent): void;
|
onCloseRequest?(event: PointerEvent): void;
|
||||||
fitInViewport?: boolean;
|
fitInViewport?: boolean;
|
||||||
offsetLeft?: number;
|
|
||||||
offsetTop?: number;
|
|
||||||
viewportWidth?: number;
|
|
||||||
viewportHeight?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
@@ -22,61 +16,25 @@ export const Popover = ({
|
|||||||
top,
|
top,
|
||||||
onCloseRequest,
|
onCloseRequest,
|
||||||
fitInViewport = false,
|
fitInViewport = false,
|
||||||
offsetLeft = 0,
|
|
||||||
offsetTop = 0,
|
|
||||||
viewportWidth = window.innerWidth,
|
|
||||||
viewportHeight = window.innerHeight,
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const container = popoverRef.current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === KEYS.TAB) {
|
|
||||||
const focusableElements = queryFocusableElements(container);
|
|
||||||
const { activeElement } = document;
|
|
||||||
const currentIndex = focusableElements.findIndex(
|
|
||||||
(element) => element === activeElement,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentIndex === 0 && event.shiftKey) {
|
|
||||||
focusableElements[focusableElements.length - 1].focus();
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
} else if (
|
|
||||||
currentIndex === focusableElements.length - 1 &&
|
|
||||||
!event.shiftKey
|
|
||||||
) {
|
|
||||||
focusableElements[0].focus();
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [container]);
|
|
||||||
|
|
||||||
// ensure the popover doesn't overflow the viewport
|
// ensure the popover doesn't overflow the viewport
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (fitInViewport && popoverRef.current) {
|
if (fitInViewport && popoverRef.current) {
|
||||||
const element = popoverRef.current;
|
const element = popoverRef.current;
|
||||||
const { x, y, width, height } = element.getBoundingClientRect();
|
const { x, y, width, height } = element.getBoundingClientRect();
|
||||||
if (x + width - offsetLeft > viewportWidth) {
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
if (x + width > viewportWidth) {
|
||||||
element.style.left = `${viewportWidth - width}px`;
|
element.style.left = `${viewportWidth - width}px`;
|
||||||
}
|
}
|
||||||
if (y + height - offsetTop > viewportHeight) {
|
const viewportHeight = window.innerHeight;
|
||||||
|
if (y + height > viewportHeight) {
|
||||||
element.style.top = `${viewportHeight - height}px`;
|
element.style.top = `${viewportHeight - height}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
}, [fitInViewport]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onCloseRequest) {
|
if (onCloseRequest) {
|
||||||
|
|||||||
@@ -82,10 +82,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-warning {
|
|
||||||
color: $oc-red-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-note {
|
&-note {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@@ -295,11 +295,6 @@ const PublishLibrary = ({
|
|||||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||||
|
|
||||||
const shouldRenderForm = !!libraryItems.length;
|
const shouldRenderForm = !!libraryItems.length;
|
||||||
|
|
||||||
const containsPublishedItems = libraryItems.some(
|
|
||||||
(item) => item.status === "published",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
onCloseRequest={onDialogClose}
|
onCloseRequest={onDialogClose}
|
||||||
@@ -334,11 +329,6 @@ const PublishLibrary = ({
|
|||||||
<div className="publish-library-note">
|
<div className="publish-library-note">
|
||||||
{t("publishDialog.noteItems")}
|
{t("publishDialog.noteItems")}
|
||||||
</div>
|
</div>
|
||||||
{containsPublishedItems && (
|
|
||||||
<span className="publish-library-note publish-library-warning">
|
|
||||||
{t("publishDialog.republishWarning")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{renderLibraryItems()}
|
{renderLibraryItems()}
|
||||||
<div className="publish-library__fields">
|
<div className="publish-library__fields">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.layer-ui__sidebar-lock-button {
|
|
||||||
@include toolbarButtonColorStates;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
}
|
|
||||||
.ToolIcon_type_floating .side_lock_icon {
|
|
||||||
width: calc(var(--space-factor) * 7);
|
|
||||||
height: calc(var(--space-factor) * 7);
|
|
||||||
svg {
|
|
||||||
// mirror
|
|
||||||
transform: scale(-1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import "./ToolIcon.scss";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./SidebarLockButton.scss";
|
|
||||||
|
|
||||||
type SidebarLockIconProps = {
|
|
||||||
checked: boolean;
|
|
||||||
onChange?(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
|
||||||
|
|
||||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
|
||||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
|
||||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("labels.sidebarLock")}>
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
onChange={props.onChange}
|
|
||||||
checked={props.checked}
|
|
||||||
aria-label={t("labels.sidebarLock")}
|
|
||||||
/>{" "}
|
|
||||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
|
||||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
|
||||||
</div>{" "}
|
|
||||||
</label>{" "}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,24 +3,11 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.single-library-item {
|
.single-library-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&-status {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.3rem;
|
|
||||||
left: 0.3rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: $oc-red-7;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
padding: 0.1rem 0.2rem;
|
|
||||||
border-radius: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__svg {
|
&__svg {
|
||||||
background-color: $oc-white;
|
|
||||||
padding: 0.3rem;
|
|
||||||
width: 7.5rem;
|
width: 7.5rem;
|
||||||
height: 7.5rem;
|
height: 7.5rem;
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
|
margin: 0.3rem;
|
||||||
svg {
|
svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -53,7 +40,7 @@
|
|||||||
&--remove {
|
&--remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
right: 1rem;
|
right: 1.3rem;
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -45,11 +45,6 @@ const SingleLibraryItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="single-library-item">
|
<div className="single-library-item">
|
||||||
{libItem.status === "published" && (
|
|
||||||
<span className="single-library-item-status">
|
|
||||||
{t("labels.statusPublished")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div ref={svgRef} className="single-library-item__svg" />
|
<div ref={svgRef} className="single-library-item__svg" />
|
||||||
<ToolButton
|
<ToolButton
|
||||||
aria-label={t("buttons.remove")}
|
aria-label={t("buttons.remove")}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const ColStack = ({
|
|||||||
align,
|
align,
|
||||||
justifyContent,
|
justifyContent,
|
||||||
className,
|
className,
|
||||||
style,
|
|
||||||
}: StackProps) => {
|
}: StackProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -50,7 +49,6 @@ const ColStack = ({
|
|||||||
"--gap": gap,
|
"--gap": gap,
|
||||||
justifyItems: align,
|
justifyItems: align,
|
||||||
justifyContent,
|
justifyContent,
|
||||||
...style,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: all;
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 24px 8px 0;
|
margin: 0 24px 8px 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { getTargetElements } from "../scene";
|
import { getTargetElements } from "../scene";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import { close } from "./icons";
|
import { close } from "./icons";
|
||||||
@@ -16,13 +16,16 @@ export const Stats = (props: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const boundingBox = getCommonBounds(props.elements);
|
const boundingBox = getCommonBounds(props.elements);
|
||||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||||
if (device.isMobile && props.appState.openMenu) {
|
|
||||||
|
if (isMobile && props.appState.openMenu) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Stats">
|
<div className="Stats">
|
||||||
<Island padding={2}>
|
<Island padding={2}>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ type ToolButtonProps =
|
|||||||
type: "radio";
|
type: "radio";
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(data: { pointerType: PointerType | null }): void;
|
onChange?(data: { pointerType: PointerType | null }): void;
|
||||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
@@ -150,7 +149,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
title={props.title}
|
title={props.title}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
lastPointerTypeRef.current = event.pointerType || null;
|
lastPointerTypeRef.current = event.pointerType || null;
|
||||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,10 +219,6 @@
|
|||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__penMode {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
top: 140px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unlocked-icon {
|
.unlocked-icon {
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
@import "../css/variables.module";
|
|
||||||
|
@mixin toolbarButtonColorStates {
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
& + .ToolIcon__icon:active {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
&:checked + .ToolIcon__icon {
|
||||||
|
background: var(--color-primary);
|
||||||
|
--icon-fill-color: #{$oc-white};
|
||||||
|
--keybinding-color: #{$oc-white};
|
||||||
|
}
|
||||||
|
&:checked + .ToolIcon__icon:active {
|
||||||
|
background: var(--color-primary-darker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__keybinding {
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.App-toolbar-container {
|
.App-toolbar-container {
|
||||||
@@ -25,12 +46,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__hidden {
|
|
||||||
box-shadow: none !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: var(--space-factor);
|
margin-inline-end: var(--space-factor);
|
||||||
&.ToolIcon_type_floating {
|
&.ToolIcon_type_floating {
|
||||||
@@ -66,14 +81,8 @@
|
|||||||
|
|
||||||
.ToolIcon {
|
.ToolIcon {
|
||||||
&:hover {
|
&:hover {
|
||||||
--icon-fill-color: var(
|
--icon-fill-color: var(--color-primary-chubb);
|
||||||
--color-primary-contrast-offset,
|
--keybinding-color: var(--color-primary-chubb);
|
||||||
var(--color-primary)
|
|
||||||
);
|
|
||||||
--keybinding-color: var(
|
|
||||||
--color-primary-contrast-offset,
|
|
||||||
var(--color-primary)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
&:active {
|
&:active {
|
||||||
--icon-fill-color: #{$oc-gray-9};
|
--icon-fill-color: #{$oc-gray-9};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// container in body where the actual tooltip is appended to
|
// container in body where the actual tooltip is appended to
|
||||||
.excalidraw-tooltip {
|
.excalidraw-tooltip {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import "./Tooltip.scss";
|
|||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
export const getTooltipDiv = () => {
|
const getTooltipDiv = () => {
|
||||||
const existingDiv = document.querySelector<HTMLDivElement>(
|
const existingDiv = document.querySelector<HTMLDivElement>(
|
||||||
".excalidraw-tooltip",
|
".excalidraw-tooltip",
|
||||||
);
|
);
|
||||||
@@ -15,50 +15,6 @@ export const getTooltipDiv = () => {
|
|||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTooltipPosition = (
|
|
||||||
tooltip: HTMLDivElement,
|
|
||||||
item: {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
},
|
|
||||||
position: "bottom" | "top" = "bottom",
|
|
||||||
) => {
|
|
||||||
const tooltipRect = tooltip.getBoundingClientRect();
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
const margin = 5;
|
|
||||||
|
|
||||||
let left = item.left + item.width / 2 - tooltipRect.width / 2;
|
|
||||||
if (left < 0) {
|
|
||||||
left = margin;
|
|
||||||
} else if (left + tooltipRect.width >= viewportWidth) {
|
|
||||||
left = viewportWidth - tooltipRect.width - margin;
|
|
||||||
}
|
|
||||||
|
|
||||||
let top: number;
|
|
||||||
|
|
||||||
if (position === "bottom") {
|
|
||||||
top = item.top + item.height + margin;
|
|
||||||
if (top + tooltipRect.height >= viewportHeight) {
|
|
||||||
top = item.top - tooltipRect.height - margin;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
top = item.top - tooltipRect.height - margin;
|
|
||||||
if (top < 0) {
|
|
||||||
top = item.top + item.height + margin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(tooltip.style, {
|
|
||||||
top: `${top}px`,
|
|
||||||
left: `${left}px`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTooltip = (
|
const updateTooltip = (
|
||||||
item: HTMLDivElement,
|
item: HTMLDivElement,
|
||||||
tooltip: HTMLDivElement,
|
tooltip: HTMLDivElement,
|
||||||
@@ -71,8 +27,35 @@ const updateTooltip = (
|
|||||||
|
|
||||||
tooltip.textContent = label;
|
tooltip.textContent = label;
|
||||||
|
|
||||||
const itemRect = item.getBoundingClientRect();
|
const {
|
||||||
updateTooltipPosition(tooltip, itemRect);
|
x: itemX,
|
||||||
|
bottom: itemBottom,
|
||||||
|
top: itemTop,
|
||||||
|
width: itemWidth,
|
||||||
|
} = item.getBoundingClientRect();
|
||||||
|
|
||||||
|
const { width: labelWidth, height: labelHeight } =
|
||||||
|
tooltip.getBoundingClientRect();
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
const margin = 5;
|
||||||
|
|
||||||
|
const left = itemX + itemWidth / 2 - labelWidth / 2;
|
||||||
|
const offsetLeft =
|
||||||
|
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
|
||||||
|
|
||||||
|
const top = itemBottom + margin;
|
||||||
|
const offsetTop =
|
||||||
|
top + labelHeight >= viewportHeight
|
||||||
|
? itemBottom - itemTop + labelHeight + margin * 2
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
Object.assign(tooltip.style, {
|
||||||
|
top: `${top - offsetTop}px`,
|
||||||
|
left: `${left - offsetLeft}px`,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
type TooltipProps = {
|
type TooltipProps = {
|
||||||
@@ -92,6 +75,7 @@ export const Tooltip = ({
|
|||||||
return () =>
|
return () =>
|
||||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="excalidraw-tooltip-wrapper"
|
className="excalidraw-tooltip-wrapper"
|
||||||
|
|||||||
@@ -2,51 +2,17 @@ import "./UserList.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { AppState, Collaborator } from "../types";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
|
||||||
|
|
||||||
export const UserList: React.FC<{
|
type UserListProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
collaborators: AppState["collaborators"];
|
};
|
||||||
actionManager: ActionManager;
|
|
||||||
}> = ({ className, mobile, collaborators, actionManager }) => {
|
|
||||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
|
||||||
|
|
||||||
collaborators.forEach((collaborator, socketId) => {
|
|
||||||
uniqueCollaborators.set(
|
|
||||||
// filter on user id, else fall back on unique socketId
|
|
||||||
collaborator.id || socketId,
|
|
||||||
collaborator,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const avatars =
|
|
||||||
uniqueCollaborators.size > 0 &&
|
|
||||||
Array.from(uniqueCollaborators)
|
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
|
||||||
.map(([clientId, collaborator]) => {
|
|
||||||
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
|
||||||
clientId,
|
|
||||||
collaborator,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return mobile ? (
|
|
||||||
<Tooltip
|
|
||||||
label={collaborator.username || "Unknown user"}
|
|
||||||
key={clientId}
|
|
||||||
>
|
|
||||||
{avatarJSX}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
export const UserList = ({ children, className, mobile }: UserListProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||||
{avatars}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -885,40 +885,6 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|
||||||
createIcon(
|
|
||||||
<path
|
|
||||||
d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
|
|
||||||
fill={iconFillColor(theme)}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>,
|
|
||||||
{ width: 448, height: 512 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|
||||||
createIcon(
|
|
||||||
<path
|
|
||||||
d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
|
|
||||||
fill={iconFillColor(theme)}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>,
|
|
||||||
{ width: 448, height: 512 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|
||||||
createIcon(
|
|
||||||
<path
|
|
||||||
transform="matrix(1,0,0,1,0,80)"
|
|
||||||
d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
|
|
||||||
fill={iconFillColor(theme)}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>,
|
|
||||||
{ width: 448, height: 512 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const publishIcon = createIcon(
|
export const publishIcon = createIcon(
|
||||||
<path
|
<path
|
||||||
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
||||||
@@ -926,15 +892,3 @@ export const publishIcon = createIcon(
|
|||||||
/>,
|
/>,
|
||||||
{ width: 640, height: 512 },
|
{ width: 640, height: 512 },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const editIcon = createIcon(
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M402.3 344.9l32-32c5-5 13.7-1.5 13.7 5.7V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h273.5c7.1 0 10.7 8.6 5.7 13.7l-32 32c-1.5 1.5-3.5 2.3-5.7 2.3H48v352h352V350.5c0-2.1.8-4.1 2.3-5.6zm156.6-201.8L296.3 405.7l-90.4 10c-26.2 2.9-48.5-19.2-45.6-45.6l10-90.4L432.9 17.1c22.9-22.9 59.9-22.9 82.7 0l43.2 43.2c22.9 22.9 22.9 60 .1 82.8zM460.1 174L402 115.9 216.2 301.8l-7.3 65.3 65.3-7.3L460.1 174zm64.8-79.7l-43.2-43.2c-4.1-4.1-10.8-4.1-14.8 0L436 82l58.1 58.1 30.9-30.9c4-4.2 4-10.8-.1-14.9z"
|
|
||||||
></path>,
|
|
||||||
{ width: 640, height: 512 },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const eraser = createIcon(
|
|
||||||
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const POINTER_BUTTON = {
|
|||||||
WHEEL: 1,
|
WHEEL: 1,
|
||||||
SECONDARY: 2,
|
SECONDARY: 2,
|
||||||
TOUCH: -1,
|
TOUCH: -1,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export enum EVENT {
|
export enum EVENT {
|
||||||
COPY = "copy",
|
COPY = "copy",
|
||||||
@@ -52,8 +52,6 @@ export enum EVENT {
|
|||||||
HASHCHANGE = "hashchange",
|
HASHCHANGE = "hashchange",
|
||||||
VISIBILITY_CHANGE = "visibilitychange",
|
VISIBILITY_CHANGE = "visibilitychange",
|
||||||
SCROLL = "scroll",
|
SCROLL = "scroll",
|
||||||
// custom events
|
|
||||||
EXCALIDRAW_LINK = "excalidraw-link",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENV = {
|
export const ENV = {
|
||||||
@@ -94,9 +92,7 @@ export const MIME_TYPES = {
|
|||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
svg: "image/svg+xml",
|
svg: "image/svg+xml",
|
||||||
"excalidraw.svg": "image/svg+xml",
|
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
"excalidraw.png": "image/png",
|
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
binary: "application/octet-stream",
|
binary: "application/octet-stream",
|
||||||
@@ -108,8 +104,7 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
excalidrawLibrary: "excalidrawlib",
|
excalidrawLibrary: "excalidrawlib",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_SOURCE =
|
export const EXPORT_SOURCE = window.location.origin;
|
||||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||||
@@ -120,7 +115,6 @@ export const TOAST_TIMEOUT = 5000;
|
|||||||
export const VERSION_TIMEOUT = 30000;
|
export const VERSION_TIMEOUT = 30000;
|
||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
|
||||||
|
|
||||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||||
export const IDLE_THRESHOLD = 60_000;
|
export const IDLE_THRESHOLD = 60_000;
|
||||||
@@ -155,19 +149,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// breakpoints
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// sm screen
|
|
||||||
export const MQ_SM_MAX_WIDTH = 640;
|
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
// sidebar
|
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
|
|
||||||
|
|
||||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||||
|
|
||||||
@@ -195,15 +179,3 @@ export const VERSIONS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BOUND_TEXT_PADDING = 5;
|
export const BOUND_TEXT_PADDING = 5;
|
||||||
|
|
||||||
export const VERTICAL_ALIGN = {
|
|
||||||
TOP: "top",
|
|
||||||
MIDDLE: "middle",
|
|
||||||
BOTTOM: "bottom",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
|
||||||
|
|
||||||
export const COOKIES = {
|
|
||||||
AUTH_STATE_COOKIE: "excplus-auth",
|
|
||||||
} as const;
|
|
||||||
|
|||||||
@@ -16,17 +16,15 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
}
|
||||||
.Spinner {
|
|
||||||
font-size: 2.8em;
|
.LoadingMessage span {
|
||||||
}
|
background-color: var(--button-gray-1);
|
||||||
|
border-radius: 5px;
|
||||||
.LoadingMessage-text {
|
padding: 0.8em 1.2em;
|
||||||
margin-top: 1em;
|
color: var(--popup-text-color);
|
||||||
font-size: 0.8em;
|
font-size: 1.3em;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,16 +290,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.eraser {
|
|
||||||
&.ToolIcon:hover {
|
|
||||||
--icon-fill-color: #fff;
|
|
||||||
--keybinding-color: #fff;
|
|
||||||
}
|
|
||||||
&.active {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-toolbar-content {
|
.App-toolbar-content {
|
||||||
@@ -350,6 +340,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
left: 0.25rem;
|
left: 0.25rem;
|
||||||
@@ -390,7 +381,6 @@
|
|||||||
|
|
||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-island);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-select {
|
.dropdown-select {
|
||||||
@@ -449,7 +439,6 @@
|
|||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
pointer-events: all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-icon {
|
.help-icon {
|
||||||
@@ -478,17 +467,7 @@
|
|||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finalize-button {
|
.undo-redo-buttons {
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
gap: 0.4em;
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-start: 0.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.undo-redo-buttons,
|
|
||||||
.eraser-buttons {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
@@ -568,22 +547,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// use custom, minimalistic scrollbar
|
|
||||||
// (doesn't work in Firefox)
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--button-gray-2);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:active {
|
|
||||||
background: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ErrorSplash.excalidraw {
|
.ErrorSplash.excalidraw {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
--text-primary-color: #{$oc-gray-8};
|
--text-primary-color: #{$oc-gray-8};
|
||||||
|
|
||||||
--color-primary: #6965db;
|
--color-primary: #6965db;
|
||||||
|
--color-primary-chubb: #625ee0; // to offset Chubb illusion
|
||||||
--color-primary-darker: #5b57d1;
|
--color-primary-darker: #5b57d1;
|
||||||
--color-primary-darkest: #4a47b1;
|
--color-primary-darkest: #4a47b1;
|
||||||
--color-primary-light: #e2e1fc;
|
--color-primary-light: #e2e1fc;
|
||||||
@@ -84,6 +85,7 @@
|
|||||||
--text-primary-color: #{$oc-gray-4};
|
--text-primary-color: #{$oc-gray-4};
|
||||||
|
|
||||||
--color-primary: #5650f0;
|
--color-primary: #5650f0;
|
||||||
|
--color-primary-chubb: #726dff; // to offset Chubb illusion
|
||||||
--color-primary-darker: #4b46d8;
|
--color-primary-darker: #4b46d8;
|
||||||
--color-primary-darkest: #3e39be;
|
--color-primary-darkest: #3e39be;
|
||||||
--color-primary-light: #3f3d64;
|
--color-primary-light: #3f3d64;
|
||||||
|
|||||||
@@ -6,32 +6,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin toolbarButtonColorStates {
|
|
||||||
.ToolIcon_type_radio,
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
& + .ToolIcon__icon:active {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
&:checked + .ToolIcon__icon {
|
|
||||||
background: var(--color-primary);
|
|
||||||
--icon-fill-color: #{$oc-white};
|
|
||||||
--keybinding-color: #{$oc-white};
|
|
||||||
}
|
|
||||||
&:checked + .ToolIcon__icon:active {
|
|
||||||
background: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__keybinding {
|
|
||||||
bottom: 4px;
|
|
||||||
right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||||
$right-sidebar-width: "302px";
|
|
||||||
|
|
||||||
:export {
|
:export {
|
||||||
themeFilter: unquote($theme-filter);
|
themeFilter: unquote($theme-filter);
|
||||||
rightSidebarWidth: unquote($right-sidebar-width);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user