Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef7330243f | |||
| e6a9ff1b96 | |||
| 832b88249c | |||
| 9902092fd1 | |||
| 8f0863d335 | |||
| 9423ac3263 | |||
| a66cfe2627 | |||
| 86cf28f2b4 | |||
| b5a46dd671 | |||
| cd942c3e3b | |||
| 55ccd5b79b | |||
| 4348c55c31 | |||
| a3fbe40b26 | |||
| 7431ca81d1 | |||
| 4d13dbf625 | |||
| 3840e2f4e6 | |||
| 52d10bb41e | |||
| 96c87f920a | |||
| 7d4189c624 | |||
| f3e17c90d3 | |||
| 70b3a9de49 | |||
| bf6d0eeef7 | |||
| 5359e4fec9 | |||
| 58fe639b8d | |||
| 327ed0e2d1 | |||
| c2fce6d8c4 | |||
| cb6b7559b4 | |||
| 77d789ed8e | |||
| 89471094ce | |||
| 670ceafc84 | |||
| 873afdacd3 | |||
| 880e4feede | |||
| 9ba7ca3845 | |||
| 734bb4d2ed | |||
| f2d2f97546 | |||
| 2fa69ddc32 | |||
| 1331cffe93 | |||
| f242721f3b | |||
| e940aeb1a3 | |||
| 580e719580 | |||
| 127af9db23 | |||
| 2209e2c1e8 | |||
| ed31980f84 | |||
| db28595302 | |||
| cded1cd63d | |||
| 8e447b4c32 | |||
| e29d3fc5e6 | |||
| 9da56e46f0 | |||
| 625ecc64ed | |||
| ceb43ed8fb | |||
| 8c0a0415de | |||
| 192debd829 | |||
| 1cfb4dfd8b | |||
| fb32886355 | |||
| 065df495ba | |||
| 558227f744 | |||
| 6d45430344 | |||
| 3aa0c5ebc0 | |||
| e940993e0e | |||
| 8f90aeb8d5 | |||
| e92d133973 | |||
| b682d88167 | |||
| 7daf1a7944 | |||
| 5c0eff50a0 | |||
| 19056d635b | |||
| 4d5f00ff08 | |||
| 20de06ef50 | |||
| 1849ff6ee2 | |||
| 6765fc16be | |||
| 5ca4f5bbf4 | |||
| 9392ec276d | |||
| b26e4fcf99 | |||
| 45f3410da8 | |||
| 94b387ef7b | |||
| 6d0716eb6b | |||
| 8e26d5b500 | |||
| c5a7723185 | |||
| 49172ac2d3 | |||
| 618a846451 | |||
| d9f49ffd67 | |||
| 46e43baad1 | |||
| bd35b682fa | |||
| b6f9a8005e | |||
| 1acfaf6b6e | |||
| 5cf7087754 | |||
| b2d49155ef | |||
| 9745461db7 | |||
| 21e9fcb2f5 | |||
| e203203993 | |||
| f224e4d596 | |||
| e0ca689759 | |||
| f792eb5ae7 | |||
| 4604c8d823 | |||
| 0896892f8a | |||
| 7fe225ee99 | |||
| d2fd7be457 | |||
| 5c61613a2e | |||
| b2767924de | |||
| 59d0a77862 | |||
| 987526d1e5 | |||
| e894d41a22 | |||
| 14d1d39e8e | |||
| 69336b4832 | |||
| 32b677fb8a | |||
| 570f725516 | |||
| a60860867c | |||
| 7a61196462 | |||
| 9653d676fe | |||
| 0cdd0eebf1 | |||
| ae8b1d8bf7 | |||
| 92ffe8dda6 | |||
| 4d9dbd5a45 | |||
| c66cabaefd | |||
| e073128469 | |||
| 835848d711 | |||
| 2bd1d7ef59 | |||
| 37c8b9c2ff | |||
| cf9f00f55f | |||
| 7ae9043221 | |||
| 7c567408c5 | |||
| 54612621aa | |||
| d27b3bbebe | |||
| e4ffc9812e | |||
| a066317d3c | |||
| 050bc1ce2b | |||
| 5007df6522 | |||
| d450c36581 | |||
| 66c92fc65a | |||
| 5f1cd4591a | |||
| 9be6243873 | |||
| c3f6d6d344 | |||
| 339636caab | |||
| 08115ef311 | |||
| e68abdbab4 | |||
| 8aff076782 | |||
| 96de887cc8 | |||
| 98ea46664c | |||
| 00e30ca0e4 | |||
| de6371aac4 | |||
| f47ddb988f | |||
| 59cbf5fde5 | |||
| 4486fbc2c6 | |||
| edfbac9d7d | |||
| 719ae7b72f | |||
| 631a228ca1 | |||
| 4b5270ab12 | |||
| dcee594b66 | |||
| 79d323fab1 | |||
| e4edda4555 | |||
| ca89d47d4c | |||
| 18c526d877 | |||
| cbc6bd1ad8 | |||
| 83d9282dbf | |||
| abff780983 | |||
| c009e03c8e | |||
| 24bf4cb5fb | |||
| 0850ab0dd0 | |||
| a7473169ba | |||
| f6325b1e5e | |||
| 466220a3a8 | |||
| d9cc7d1033 | |||
| c037e9854c | |||
| 9373961857 | |||
| 1fd2fe56ee | |||
| dba71e358d | |||
| 1ef287027b | |||
| a51ed9ced6 | |||
| 4501d6d630 | |||
| 92a5936c7f | |||
| 50bd5fbae1 | |||
| 62bead66d7 | |||
| b3073984b3 | |||
| 3c9ee13979 | |||
| 228c8136cf | |||
| 324dd460c8 | |||
| d8ea085a94 | |||
| adbd486f32 | |||
| 0a89c4b0c8 | |||
| c03845bac3 | |||
| d5a6014076 | |||
| 74861b1398 | |||
| ac71ee7278 | |||
| 9088df8f5a | |||
| c5fe0cd446 | |||
| 9f8783c2dd | |||
| b475412199 | |||
| 5f1616f2c5 | |||
| cec92c1d17 | |||
| 5f476e09d4 | |||
| 9aa6a27252 | |||
| a2e8806f57 | |||
| b71e702991 | |||
| 5c67329be6 | |||
| 28546fbb55 | |||
| b0cccbb9e8 | |||
| b621d065de | |||
| 96580c92a5 | |||
| 975441549b | |||
| 4be701416a | |||
| 1acb1e33f1 | |||
| 986e1e40d3 | |||
| fab4a0e060 | |||
| b265ebf88f | |||
| 351845019e | |||
| c0fcce6f27 | |||
| b093d2d2b6 | |||
| 69548c5502 | |||
| 6ca0afa6e5 | |||
| c50f81b829 | |||
| b122c8c4eb | |||
| 9a7216fe94 | |||
| 8eee749076 | |||
| 2158ad0656 | |||
| 74c3fea7f5 | |||
| 5e456e6d05 | |||
| 477cce2ed6 | |||
| dd8e465304 | |||
| 11396a21de | |||
| 38236bc5e0 | |||
| 63ce5b82d7 | |||
| bae0e985b2 | |||
| 04f852a40a | |||
| f463c047c0 | |||
| 1fd347cade | |||
| ef62390841 | |||
| bf2bca221e | |||
| d0733b1960 | |||
| 64c2d76cfa | |||
| c76784b774 | |||
| 25e54e5999 | |||
| 55b7a7d554 | |||
| c1c37a6ee7 | |||
| 25b529f519 | |||
| 8e6a747873 | |||
| 089b05db1b | |||
| 081e097cef | |||
| 8b5657e1ce | |||
| 8b2b03347c | |||
| c2a8712593 | |||
| ff1d7728a0 | |||
| 98b5c37e45 | |||
| 7db63bd397 | |||
| 390da3fd0f | |||
| 104664cb9e | |||
| c822055ec8 | |||
| e15d73d94c | |||
| 80ee097b85 | |||
| 10048b877b | |||
| 5dd5862bb9 | |||
| 79989fedda | |||
| cecabc2196 | |||
| ed8fb40b63 | |||
| 6e391728fe | |||
| dfbfbc3f11 | |||
| 9b8ee3cacf | |||
| 4ea73d5d5b | |||
| 618f204ddd | |||
| 720588130c | |||
| f354788cd0 | |||
| 1c7ee09010 | |||
| ca15b0a008 | |||
| 650930c5ce | |||
| 79c0d59244 | |||
| cd50b5f7e9 | |||
| c0434957ff | |||
| 66aeaeb38d | |||
| 7f545e74ab | |||
| a776955579 | |||
| afa7932c9b | |||
| 1ee8d7d082 | |||
| 06db702b5d | |||
| b53d1f6f3e | |||
| ca1f3aa094 | |||
| 8ff159e76e | |||
| f9d2d537a2 | |||
| dac970c640 | |||
| 78bb3b3d84 | |||
| 7d9d7ad297 | |||
| de20a5e3ba | |||
| 289f72e45d | |||
| 6dd0e6a4c5 | |||
| 96b31ecbce | |||
| a132f154cb | |||
| 23acd8f6d1 | |||
| a60709f5ea | |||
| 896c476716 | |||
| 133ba19919 | |||
| a2136bfe9d | |||
| 6fbd64fdaa | |||
| cc4b0c2932 |
@@ -4,5 +4,10 @@ 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
|
||||||
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
|
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||||
|
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"}'
|
||||||
+5
-1
@@ -4,7 +4,11 @@ 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_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
|
REACT_APP_PORTAL_URL=https://portal.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
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
|
yarn add @actions/core
|
||||||
yarn autorelease
|
yarn autorelease
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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 release package' && 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 }}"
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -23,3 +23,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
yarn lint-staged
|
yarn lint-staged
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ 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
|
||||||
@@ -118,16 +122,47 @@ yarn start
|
|||||||
|
|
||||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||||
|
|
||||||
|
#### Collaboration
|
||||||
|
|
||||||
|
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
|
||||||
|
|
||||||
#### Commands
|
#### Commands
|
||||||
|
|
||||||
| Command | Description |
|
##### Install the dependencies
|
||||||
| ------------------ | --------------------------------- |
|
|
||||||
| `yarn` | Install the dependencies |
|
```
|
||||||
| `yarn start` | Run the project |
|
yarn
|
||||||
| `yarn fix` | Reformat all files with Prettier |
|
```
|
||||||
| `yarn test` | Run tests |
|
|
||||||
| `yarn test:update` | Update test snapshots |
|
##### Run the project
|
||||||
| `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,12 +1,11 @@
|
|||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
service firebase.storage {
|
service firebase.storage {
|
||||||
match /b/{bucket}/o {
|
match /b/{bucket}/o {
|
||||||
match /{migrations} {
|
match /{files}/rooms/{room}/{file} {
|
||||||
match /{scenes}/{scene} {
|
allow get, write: if true;
|
||||||
allow get, write: if true;
|
}
|
||||||
// redundant, but let's be explicit'
|
match /{files}/shareLinks/{shareLink}/{file} {
|
||||||
allow list: if false;
|
allow get, write: if true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-16
@@ -21,23 +21,24 @@
|
|||||||
"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.15.0",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
"@testing-library/react": "12.1.2",
|
"@testing-library/react": "12.1.2",
|
||||||
"@tldraw/vec": "0.1.3",
|
"@tldraw/vec": "1.4.3",
|
||||||
"@types/jest": "27.0.2",
|
"@types/jest": "27.4.0",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/react": "17.0.34",
|
"@types/react": "17.0.39",
|
||||||
"@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.21.1",
|
"browser-fs-access": "0.24.1",
|
||||||
"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.1.30",
|
"nanoid": "3.1.32",
|
||||||
"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",
|
||||||
@@ -49,31 +50,31 @@
|
|||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.5.0",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.43.4",
|
"sass": "1.49.7",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.5.2"
|
"typescript": "4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
"@types/chai": "4.2.22",
|
"@types/chai": "4.3.0",
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.2",
|
"@types/pako": "1.0.3",
|
||||||
"@types/resize-observer-browser": "0.1.6",
|
"@types/resize-observer-browser": "0.1.6",
|
||||||
"chai": "4.3.4",
|
"chai": "4.3.6",
|
||||||
|
"dotenv": "10.0.0",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.22.0",
|
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "12.0.1",
|
"lint-staged": "12.3.7",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.4.1",
|
"prettier": "2.5.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@typescript-eslint/typescript-estree": "5.3.0"
|
"@typescript-eslint/typescript-estree": "5.10.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
+4
-32
@@ -72,12 +72,6 @@
|
|||||||
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"
|
||||||
@@ -130,26 +124,6 @@
|
|||||||
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;
|
||||||
@@ -158,8 +132,10 @@
|
|||||||
-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;
|
||||||
@@ -176,10 +152,6 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
<div id="root">
|
<div id="root"></div>
|
||||||
<div class="LoadingMessage">
|
|
||||||
<span>Loading scene...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+30
-5
@@ -1,5 +1,6 @@
|
|||||||
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`;
|
||||||
@@ -15,18 +16,25 @@ 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/;
|
||||||
|
|
||||||
@@ -37,16 +45,33 @@ 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";
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||||
|
|
||||||
// update readme
|
// update readme
|
||||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
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(`${excalidrawDir}/README.md`, data, "utf8");
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
|
console.info("Publish in progress...");
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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",
|
||||||
@@ -42,6 +43,7 @@ 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",
|
||||||
@@ -69,6 +71,7 @@ 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": "🇳🇴",
|
||||||
@@ -102,6 +105,7 @@ 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",
|
||||||
@@ -114,6 +118,7 @@ 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",
|
||||||
|
|||||||
+18
-11
@@ -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,19 +53,26 @@ 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 prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
const prMatch = commit.match(/\(#([0-9]*)\)/);
|
||||||
|
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,8 +7,14 @@ 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) => {
|
||||||
if (elements.some((element) => element.type === "image")) {
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (selectedElements.some((element) => element.type === "image")) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -25,10 +31,7 @@ export const actionAddToLibrary = register({
|
|||||||
{
|
{
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
status: "unpublished",
|
status: "unpublished",
|
||||||
elements: getSelectedElements(
|
elements: selectedElements.map(deepCopyElement),
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
).map(deepCopyElement),
|
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
},
|
},
|
||||||
...items,
|
...items,
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getElementMap, 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 { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -34,13 +34,16 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
const updatedElements = alignElements(selectedElements, alignment);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
@@ -70,6 +73,7 @@ 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,
|
||||||
@@ -99,6 +103,7 @@ 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,
|
||||||
@@ -128,6 +133,8 @@ 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,
|
||||||
@@ -157,6 +164,8 @@ 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,
|
||||||
@@ -182,6 +191,7 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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 { zoomIn, zoomOut } from "../components/icons";
|
import { eraser, 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,24 +9,26 @@ 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 { getNewZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } 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 } from "../appState";
|
import { getDefaultAppState, isEraserActive } 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: ({ appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@@ -39,6 +41,8 @@ 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>
|
||||||
);
|
);
|
||||||
@@ -47,6 +51,7 @@ 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 {
|
||||||
@@ -57,12 +62,17 @@ export const actionClearCanvas = register({
|
|||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
files: {},
|
files: {},
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
elementLocked: appState.elementLocked,
|
penMode: appState.penMode,
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
@@ -73,17 +83,19 @@ export const actionClearCanvas = register({
|
|||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
const zoom = getNewZoom(
|
perform: (_elements, appState, _, app) => {
|
||||||
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,
|
||||||
zoom,
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -107,18 +119,19 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
perform: (_elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
const zoom = getNewZoom(
|
perform: (_elements, appState, _, app) => {
|
||||||
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,
|
||||||
zoom,
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -142,25 +155,25 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
|
perform: (_elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNewZoom(
|
...getStateForZoom(
|
||||||
1 as NormalizedZoomValue,
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{
|
{
|
||||||
x: appState.width / 2,
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
y: appState.height / 2,
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(1),
|
||||||
},
|
},
|
||||||
|
appState,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<Tooltip label={t("buttons.resetZoom")}>
|
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="reset-zoom-button"
|
className="reset-zoom-button"
|
||||||
@@ -212,14 +225,12 @@ const zoomToFitElements = (
|
|||||||
? getCommonBounds(selectedElements)
|
? getCommonBounds(selectedElements)
|
||||||
: getCommonBounds(nonDeletedElements);
|
: getCommonBounds(nonDeletedElements);
|
||||||
|
|
||||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
const newZoom = {
|
||||||
width: appState.width,
|
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
height: appState.height,
|
width: appState.width,
|
||||||
});
|
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;
|
||||||
@@ -243,6 +254,7 @@ 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 &&
|
||||||
@@ -253,6 +265,7 @@ 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 &&
|
||||||
@@ -263,6 +276,7 @@ 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: {
|
||||||
@@ -285,3 +299,42 @@ 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) => {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
activeTool: {
|
||||||
|
...appState.activeTool,
|
||||||
|
type: isEraserActive(appState)
|
||||||
|
? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
|
||||||
|
: "eraser",
|
||||||
|
lastActiveToolBeforeEraser:
|
||||||
|
appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
|
||||||
|
? null
|
||||||
|
: appState.activeTool.type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,14 +1,19 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { copyToClipboard } from "../clipboard";
|
import {
|
||||||
|
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 } from "../element";
|
import { getNonDeletedElements, isTextElement } 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) => {
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||||
|
|
||||||
@@ -23,9 +28,10 @@ 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, data, app);
|
return actionDeleteSelected.perform(elements, appState);
|
||||||
},
|
},
|
||||||
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,
|
||||||
@@ -33,6 +39,7 @@ 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 {
|
||||||
@@ -42,6 +49,7 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -72,6 +80,7 @@ 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 {
|
||||||
@@ -81,6 +90,7 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -120,3 +130,35 @@ 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",
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { newElementWith } from "../element/mutateElement";
|
|||||||
import { getElementsInGroup } from "../groups";
|
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";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -21,6 +22,12 @@ const deleteSelectedElements = (
|
|||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isBoundToContainer(el) &&
|
||||||
|
appState.selectedElementIds[el.containerId]
|
||||||
|
) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
@@ -51,11 +58,12 @@ 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 {
|
||||||
elementId,
|
elementId,
|
||||||
activePointIndex,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
@@ -65,8 +73,7 @@ export const actionDeleteSelected = register({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// case: no point selected → delete whole element
|
// case: no point selected → delete whole element
|
||||||
activePointIndex == null ||
|
selectedPointsIndices == null ||
|
||||||
activePointIndex === -1 ||
|
|
||||||
// case: deleting last remaining point
|
// case: deleting last remaining point
|
||||||
element.points.length < 2
|
element.points.length < 2
|
||||||
) {
|
) {
|
||||||
@@ -86,15 +93,17 @@ export const actionDeleteSelected = register({
|
|||||||
// We cannot do this inside `movePoint` because it is also called
|
// We cannot do this inside `movePoint` because it is also called
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||||
const binding = {
|
const binding = {
|
||||||
startBindingElement:
|
startBindingElement: selectedPointsIndices?.includes(0)
|
||||||
activePointIndex === 0 ? null : startBindingElement,
|
? null
|
||||||
endBindingElement:
|
: startBindingElement,
|
||||||
activePointIndex === element.points.length - 1
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
? null
|
element.points.length - 1,
|
||||||
: endBindingElement,
|
)
|
||||||
|
? null
|
||||||
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -103,13 +112,15 @@ export const actionDeleteSelected = register({
|
|||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
selectedPointsIndices:
|
||||||
|
selectedPointsIndices?.[0] > 0
|
||||||
|
? [selectedPointsIndices[0] - 1]
|
||||||
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState);
|
deleteSelectedElements(elements, appState);
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
@@ -123,7 +134,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
elementType: "selection",
|
activeTool: { ...appState.activeTool, type: "selection" },
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: isSomeElementSelected(
|
commitToHistory: isSomeElementSelected(
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {
|
|||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
import { getElementMap, 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 } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -30,13 +30,16 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
const updatedElements = distributeElements(selectedElements, distribution);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
@@ -47,7 +50,8 @@ export const distributeHorizontally = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.code === CODES.H,
|
keyTest: (event) =>
|
||||||
|
!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)}
|
||||||
@@ -65,6 +69,7 @@ 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,
|
||||||
@@ -75,7 +80,8 @@ export const distributeVertically = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.code === CODES.V,
|
keyTest: (event) =>
|
||||||
|
!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)}
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import { KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
getSelectedGroupForElement,
|
getSelectedGroupForElement,
|
||||||
@@ -18,41 +17,24 @@ import { AppState } from "../types";
|
|||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import { ActionResult } from "./types";
|
import { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
|
import { bindTextToShapeAfterDuplication } from "../element/textElement";
|
||||||
|
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 point if selected while editing multi-point element
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element || activePointIndex === null) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { points } = element;
|
|
||||||
const selectedPoint = points[activePointIndex];
|
|
||||||
const nextPoint = points[activePointIndex + 1];
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
...points.slice(0, activePointIndex + 1),
|
|
||||||
nextPoint
|
|
||||||
? [
|
|
||||||
(selectedPoint[0] + nextPoint[0]) / 2,
|
|
||||||
(selectedPoint[1] + nextPoint[1]) / 2,
|
|
||||||
]
|
|
||||||
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
|
||||||
...points.slice(activePointIndex + 1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
activePointIndex: activePointIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements,
|
elements,
|
||||||
|
appState: ret.appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,9 +88,12 @@ const duplicateElements = (
|
|||||||
const finalElements: ExcalidrawElement[] = [];
|
const finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
const selectedElementIds = arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, true),
|
||||||
|
);
|
||||||
while (index < elements.length) {
|
while (index < elements.length) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (selectedElementIds.get(element.id)) {
|
||||||
if (element.groupIds.length) {
|
if (element.groupIds.length) {
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
// if group selected, duplicate it atomically
|
// if group selected, duplicate it atomically
|
||||||
@@ -130,7 +115,11 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
bindTextToShapeAfterDuplication(
|
||||||
|
finalElements,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +129,9 @@ const duplicateElements = (
|
|||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce((acc, element) => {
|
||||||
acc[element.id] = true;
|
if (!isBoundToContainer(element)) {
|
||||||
|
acc[element.id] = true;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -8,7 +7,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 { useIsMobile } from "../components/App";
|
import { useDeviceType } 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";
|
||||||
@@ -23,8 +22,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 }) => (
|
||||||
@@ -41,6 +40,7 @@ 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,6 +89,7 @@ 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 },
|
||||||
@@ -107,6 +108,7 @@ 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 },
|
||||||
@@ -128,6 +130,7 @@ 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;
|
||||||
|
|
||||||
@@ -172,6 +175,7 @@ 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(
|
||||||
@@ -200,7 +204,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={useIsMobile()}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
@@ -210,6 +214,7 @@ 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 {
|
||||||
@@ -243,7 +248,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={useIsMobile()}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
data-testid="load-button"
|
data-testid="load-button"
|
||||||
/>
|
/>
|
||||||
@@ -252,6 +257,7 @@ 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 },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
|
|||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
@@ -38,6 +39,7 @@ export const actionFinalize = register({
|
|||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -119,13 +121,17 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
if (
|
||||||
|
!appState.activeTool.locked &&
|
||||||
|
appState.activeTool.type !== "freedraw"
|
||||||
|
) {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
(!appState.activeTool.locked &&
|
||||||
|
appState.activeTool.type !== "freedraw") ||
|
||||||
!multiPointElement
|
!multiPointElement
|
||||||
) {
|
) {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
@@ -135,11 +141,20 @@ export const actionFinalize = register({
|
|||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
cursorButton: "up",
|
||||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
activeTool:
|
||||||
|
(appState.activeTool.locked ||
|
||||||
|
appState.activeTool.type === "freedraw") &&
|
||||||
multiPointElement
|
multiPointElement
|
||||||
? appState.elementType
|
? appState.activeTool
|
||||||
: "selection",
|
: {
|
||||||
|
...appState.activeTool,
|
||||||
|
type:
|
||||||
|
appState.activeTool.type === "eraser" &&
|
||||||
|
appState.activeTool.lastActiveToolBeforeEraser
|
||||||
|
? appState.activeTool.lastActiveToolBeforeEraser
|
||||||
|
: "selection",
|
||||||
|
},
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
@@ -147,8 +162,8 @@ export const actionFinalize = register({
|
|||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
multiPointElement &&
|
||||||
!appState.elementLocked &&
|
!appState.activeTool.locked &&
|
||||||
appState.elementType !== "freedraw"
|
appState.activeTool.type !== "freedraw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
@@ -156,7 +171,7 @@ export const actionFinalize = register({
|
|||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
pendingImageElement: null,
|
pendingImageElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "freedraw",
|
commitToHistory: appState.activeTool.type === "freedraw",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
@@ -165,7 +180,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 }) => (
|
PanelComponent: ({ appState, updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={done}
|
icon={done}
|
||||||
@@ -173,6 +188,7 @@ 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"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||||
@@ -9,6 +9,7 @@ import { getTransformHandles } from "../element/transformHandles";
|
|||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { updateBoundElements } from "../element/binding";
|
import { updateBoundElements } from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -34,6 +35,7 @@ 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"),
|
||||||
@@ -49,6 +51,7 @@ 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"),
|
||||||
@@ -83,9 +86,11 @@ const flipSelectedElements = (
|
|||||||
flipDirection,
|
flipDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
@@ -142,10 +147,9 @@ const flipElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element)) {
|
if (isLinearElement(element)) {
|
||||||
for (let i = 1; i < element.points.length; i++) {
|
for (let index = 1; index < element.points.length; index++) {
|
||||||
LinearElementEditor.movePoint(element, i, [
|
LinearElementEditor.movePoints(element, [
|
||||||
-element.points[i][0],
|
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||||
element.points[i][1],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element);
|
||||||
@@ -153,7 +157,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(
|
||||||
element,
|
new Map().set(element.id, element),
|
||||||
true,
|
true,
|
||||||
element,
|
element,
|
||||||
usingNWHandle ? "nw" : "ne",
|
usingNWHandle ? "nw" : "ne",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
@@ -44,6 +45,7 @@ const enableActionGroup = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
@@ -52,10 +54,12 @@ 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),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
@@ -83,8 +87,9 @@ export const actionGroup = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newGroupId = randomId();
|
const newGroupId = randomId();
|
||||||
|
const selectElementIds = arrayToMap(selectedElements);
|
||||||
const updatedElements = elements.map((element) => {
|
const updatedElements = elements.map((element) => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!selectElementIds.get(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
@@ -143,12 +148,18 @@ 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) {
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||||
const nextElements = elements.map((element) => {
|
const nextElements = elements.map((element) => {
|
||||||
|
if (isBoundToContainer(element)) {
|
||||||
|
boundTextElementIds.push(element.id);
|
||||||
|
}
|
||||||
const nextGroupIds = removeFromSelectedGroups(
|
const nextGroupIds = removeFromSelectedGroups(
|
||||||
element.groupIds,
|
element.groupIds,
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
@@ -160,11 +171,19 @@ export const actionUngroup = register({
|
|||||||
groupIds: nextGroupIds,
|
groupIds: nextGroupIds,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateAppState = selectGroupsForSelectedElements(
|
||||||
|
{ ...appState, selectedGroupIds: {} },
|
||||||
|
getNonDeletedElements(nextElements),
|
||||||
|
);
|
||||||
|
|
||||||
|
// remove binded text elements from selection
|
||||||
|
boundTextElementIds.forEach(
|
||||||
|
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: updateAppState,
|
||||||
{ ...appState, selectedGroupIds: {} },
|
|
||||||
getNonDeletedElements(nextElements),
|
|
||||||
),
|
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import History, { HistoryEntry } from "../history";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isWindows, KEYS } from "../keys";
|
import { isWindows, KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
@@ -27,17 +27,17 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = arrayToMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = arrayToMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
const deletedElements = prevElements.filter(
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||||
);
|
);
|
||||||
const elements = nextElements
|
const elements = nextElements
|
||||||
.map((nextElement) =>
|
.map((nextElement) =>
|
||||||
newElementWith(
|
newElementWith(
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
prevElementMap.get(nextElement.id) || nextElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -62,6 +62,7 @@ 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) =>
|
||||||
@@ -82,6 +83,7 @@ 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,6 +9,7 @@ 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,
|
||||||
@@ -29,6 +30,7 @@ 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,
|
||||||
@@ -53,6 +55,7 @@ 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();
|
||||||
@@ -69,6 +72,7 @@ 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();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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) {
|
||||||
|
|||||||
@@ -30,19 +30,31 @@ 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,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
getContainerElement,
|
||||||
|
} from "../element/textElement";
|
||||||
|
import {
|
||||||
|
isBoundToContainer,
|
||||||
|
isLinearElement,
|
||||||
|
isLinearElementType,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -50,27 +62,37 @@ 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 { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getCommonAttributeOfSelectedElements,
|
getCommonAttributeOfSelectedElements,
|
||||||
|
getSelectedElements,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
||||||
|
includeBoundText = false,
|
||||||
) => {
|
) => {
|
||||||
|
const selectedElementIds = arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, includeBoundText),
|
||||||
|
);
|
||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
appState.selectedElementIds[element.id] ||
|
selectedElementIds.get(element.id) ||
|
||||||
element.id === appState.editingElement?.id
|
element.id === appState.editingElement?.id
|
||||||
) {
|
) {
|
||||||
return callback(element);
|
return callback(element);
|
||||||
@@ -100,18 +122,94 @@ const getFormValue = function <T>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const offsetElementAfterFontResize = (
|
||||||
|
prevElement: ExcalidrawTextElement,
|
||||||
|
nextElement: ExcalidrawTextElement,
|
||||||
|
) => {
|
||||||
|
if (isBoundToContainer(nextElement)) {
|
||||||
|
return nextElement;
|
||||||
|
}
|
||||||
|
return mutateElement(
|
||||||
|
nextElement,
|
||||||
|
{
|
||||||
|
x:
|
||||||
|
prevElement.textAlign === "left"
|
||||||
|
? prevElement.x
|
||||||
|
: prevElement.x +
|
||||||
|
(prevElement.width - nextElement.width) /
|
||||||
|
(prevElement.textAlign === "center" ? 2 : 1),
|
||||||
|
// centering vertically is non-standard, but for Excalidraw I think
|
||||||
|
// it makes sense
|
||||||
|
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeFontSize = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
||||||
|
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
||||||
|
) => {
|
||||||
|
const newFontSizes = new Set<number>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: changeProperty(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(oldElement) => {
|
||||||
|
if (isTextElement(oldElement)) {
|
||||||
|
const newFontSize = getNewFontSize(oldElement);
|
||||||
|
newFontSizes.add(newFontSize);
|
||||||
|
|
||||||
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
|
fontSize: newFontSize,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
|
||||||
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldElement;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
// update state only if we've set all select text elements to
|
||||||
|
// the same font size
|
||||||
|
currentItemFontSize:
|
||||||
|
newFontSizes.size === 1
|
||||||
|
? [...newFontSizes][0]
|
||||||
|
: fallbackValue ?? appState.currentItemFontSize,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
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 && {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(
|
||||||
return hasStrokeColor(el.type)
|
elements,
|
||||||
? newElementWith(el, {
|
appState,
|
||||||
strokeColor: value.currentItemStrokeColor,
|
(el) => {
|
||||||
})
|
return hasStrokeColor(el.type)
|
||||||
: el;
|
? newElementWith(el, {
|
||||||
}),
|
strokeColor: value.currentItemStrokeColor,
|
||||||
|
})
|
||||||
|
: el;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -137,6 +235,8 @@ export const actionChangeStrokeColor = register({
|
|||||||
setActive={(active) =>
|
setActive={(active) =>
|
||||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||||
}
|
}
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -144,6 +244,7 @@ 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 && {
|
||||||
@@ -177,6 +278,8 @@ export const actionChangeBackgroundColor = register({
|
|||||||
setActive={(active) =>
|
setActive={(active) =>
|
||||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||||
}
|
}
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -184,6 +287,7 @@ 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) =>
|
||||||
@@ -233,6 +337,7 @@ 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) =>
|
||||||
@@ -280,6 +385,7 @@ 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) =>
|
||||||
@@ -328,6 +434,7 @@ 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) =>
|
||||||
@@ -375,6 +482,7 @@ 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, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -424,25 +532,9 @@ 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 {
|
return changeFontSize(elements, appState, () => value, value);
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
|
||||||
if (isTextElement(el)) {
|
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
|
||||||
fontSize: value,
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(element);
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
currentItemFontSize: value,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -454,27 +546,40 @@ 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(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && element.fontSize,
|
(element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
return element.fontSize;
|
||||||
|
}
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.fontSize;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -483,21 +588,70 @@ export const actionChangeFontSize = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionDecreaseFontSize = register({
|
||||||
|
name: "decreaseFontSize",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
return changeFontSize(elements, appState, (element) =>
|
||||||
|
Math.round(
|
||||||
|
// get previous value before relative increase (doesn't work fully
|
||||||
|
// due to rounding and float precision issues)
|
||||||
|
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyTest: (event) => {
|
||||||
|
return (
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
// KEYS.COMMA needed for MacOS
|
||||||
|
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionIncreaseFontSize = register({
|
||||||
|
name: "increaseFontSize",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
return changeFontSize(elements, appState, (element) =>
|
||||||
|
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyTest: (event) => {
|
||||||
|
return (
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
// KEYS.PERIOD needed for MacOS
|
||||||
|
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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, appState, (el) => {
|
elements: changeProperty(
|
||||||
if (isTextElement(el)) {
|
elements,
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
appState,
|
||||||
fontFamily: value,
|
(oldElement) => {
|
||||||
});
|
if (isTextElement(oldElement)) {
|
||||||
redrawTextBoundingBox(element);
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
return element;
|
oldElement,
|
||||||
}
|
{
|
||||||
|
fontFamily: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
return el;
|
return oldElement;
|
||||||
}),
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFontFamily: value,
|
||||||
@@ -537,7 +691,16 @@ export const actionChangeFontFamily = register({
|
|||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && element.fontFamily,
|
(element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
return element.fontFamily;
|
||||||
|
}
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.fontFamily;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -549,19 +712,26 @@ 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, appState, (el) => {
|
elements: changeProperty(
|
||||||
if (isTextElement(el)) {
|
elements,
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
appState,
|
||||||
textAlign: value,
|
(oldElement) => {
|
||||||
});
|
if (isTextElement(oldElement)) {
|
||||||
redrawTextBoundingBox(element);
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
return element;
|
oldElement,
|
||||||
}
|
{ textAlign: value },
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
return el;
|
return oldElement;
|
||||||
}),
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemTextAlign: value,
|
currentItemTextAlign: value,
|
||||||
@@ -569,42 +739,121 @@ export const actionChangeTextAlign = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
<fieldset>
|
return (
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<fieldset>
|
||||||
<ButtonIconSelect<TextAlign | false>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
group="text-align"
|
<ButtonIconSelect<TextAlign | false>
|
||||||
options={[
|
group="text-align"
|
||||||
{
|
options={[
|
||||||
value: "left",
|
{
|
||||||
text: t("labels.left"),
|
value: "left",
|
||||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
text: t("labels.left"),
|
||||||
},
|
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||||
{
|
},
|
||||||
value: "center",
|
{
|
||||||
text: t("labels.center"),
|
value: "center",
|
||||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
text: t("labels.center"),
|
||||||
},
|
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||||
{
|
},
|
||||||
value: "right",
|
{
|
||||||
text: t("labels.right"),
|
value: "right",
|
||||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
text: t("labels.right"),
|
||||||
},
|
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||||
]}
|
},
|
||||||
value={getFormValue(
|
]}
|
||||||
elements,
|
value={getFormValue(
|
||||||
appState,
|
elements,
|
||||||
(element) => isTextElement(element) && element.textAlign,
|
appState,
|
||||||
appState.currentItemTextAlign,
|
(element) => {
|
||||||
)}
|
if (isTextElement(element)) {
|
||||||
onChange={(value) => updateData(value)}
|
return element.textAlign;
|
||||||
/>
|
}
|
||||||
</fieldset>
|
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);
|
||||||
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.verticalAlign;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
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),
|
||||||
@@ -612,10 +861,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.elementType);
|
: !isLinearElementType(appState.activeTool.type);
|
||||||
const shouldUpdateForLinearElements = targetElements.length
|
const shouldUpdateForLinearElements = targetElements.length
|
||||||
? targetElements.every(isLinearElement)
|
? targetElements.every(isLinearElement)
|
||||||
: isLinearElementType(appState.elementType);
|
: isLinearElementType(appState.activeTool.type);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -655,8 +904,8 @@ export const actionChangeSharpness = register({
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => element.strokeSharpness,
|
(element) => element.strokeSharpness,
|
||||||
(canChangeSharpness(appState.elementType) &&
|
(canChangeSharpness(appState.activeTool.type) &&
|
||||||
(isLinearElementType(appState.elementType)
|
(isLinearElementType(appState.activeTool.type)
|
||||||
? appState.currentItemLinearStrokeSharpness
|
? appState.currentItemLinearStrokeSharpness
|
||||||
: appState.currentItemStrokeSharpness)) ||
|
: appState.currentItemStrokeSharpness)) ||
|
||||||
null,
|
null,
|
||||||
@@ -669,6 +918,7 @@ export const actionChangeSharpness = register({
|
|||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
export const actionChangeArrowhead = register({
|
||||||
name: "changeArrowhead",
|
name: "changeArrowhead",
|
||||||
|
trackEvent: false,
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements } from "../element";
|
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;
|
||||||
@@ -15,7 +16,11 @@ export const actionSelectAll = register({
|
|||||||
...appState,
|
...appState,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
if (!element.isDeleted) {
|
if (
|
||||||
|
!element.isDeleted &&
|
||||||
|
!(isTextElement(element) && element.containerId) &&
|
||||||
|
element.locked === false
|
||||||
|
) {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import { getContainerElement } from "../element/textElement";
|
||||||
|
|
||||||
// `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 element = elements.find((el) => appState.selectedElementIds[el.id]);
|
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -38,6 +40,7 @@ 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 pastedElement = JSON.parse(copiedStyles);
|
const pastedElement = JSON.parse(copiedStyles);
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
@@ -55,13 +58,14 @@ export const actionPasteStyles = register({
|
|||||||
opacity: pastedElement?.opacity,
|
opacity: pastedElement?.opacity,
|
||||||
roughness: pastedElement?.roughness,
|
roughness: pastedElement?.roughness,
|
||||||
});
|
});
|
||||||
if (isTextElement(newElement)) {
|
if (isTextElement(newElement) && isTextElement(element)) {
|
||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement);
|
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||||
}
|
}
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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,6 +3,7 @@ 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,11 +1,13 @@
|
|||||||
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,12 +1,13 @@
|
|||||||
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,6 +18,7 @@ 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),
|
||||||
@@ -45,6 +46,7 @@ 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),
|
||||||
@@ -72,6 +74,7 @@ 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),
|
||||||
@@ -106,6 +109,8 @@ 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,6 +17,7 @@ export {
|
|||||||
actionChangeFontSize,
|
actionChangeFontSize,
|
||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
|
actionChangeVerticalAlign,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -74,9 +75,13 @@ 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";
|
||||||
|
|||||||
+59
-21
@@ -1,18 +1,47 @@
|
|||||||
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";
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
const trackAction = (
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
action: Action,
|
||||||
|
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.deviceType.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;
|
||||||
|
|
||||||
@@ -65,9 +94,15 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length !== 1) {
|
||||||
|
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)) {
|
||||||
@@ -75,27 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elements = this.getElementsIncludingDeleted();
|
||||||
|
const appState = this.getAppState();
|
||||||
|
const value = null;
|
||||||
|
|
||||||
|
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.updater(
|
event.stopPropagation();
|
||||||
data[0].perform(
|
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||||
this.getElementsIncludingDeleted(),
|
|
||||||
this.getAppState(),
|
|
||||||
null,
|
|
||||||
this.app,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
executeAction(action: Action) {
|
executeAction(action: Action, source: ActionSource = "api") {
|
||||||
this.updater(
|
const elements = this.getElementsIncludingDeleted();
|
||||||
action.perform(
|
const appState = this.getAppState();
|
||||||
this.getElementsIncludingDeleted(),
|
const value = null;
|
||||||
this.getAppState(),
|
|
||||||
null,
|
trackAction(action, source, appState, elements, this.app, value);
|
||||||
this.app,
|
|
||||||
),
|
this.updater(action.perform(elements, appState, value, this.app));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
) {
|
) {
|
||||||
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,7 +2,9 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = (action: Action): Action => {
|
export const register = <T extends Action>(action: T) => {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action;
|
return action as T & {
|
||||||
|
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
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 =
|
export type ShortcutName = SubtypeOf<
|
||||||
|
ActionName,
|
||||||
| "cut"
|
| "cut"
|
||||||
| "copy"
|
| "copy"
|
||||||
| "paste"
|
| "paste"
|
||||||
@@ -25,7 +27,10 @@ export type ShortcutName =
|
|||||||
| "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")],
|
||||||
@@ -62,10 +67,12 @@ 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 availiable, take the first one
|
// if multiple shortcuts available, take the first one
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||||
};
|
};
|
||||||
|
|||||||
+37
-10
@@ -8,6 +8,8 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { ToolButtonSize } from "../components/ToolButton";
|
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 =
|
||||||
| {
|
| {
|
||||||
@@ -39,6 +41,7 @@ export type ActionName =
|
|||||||
| "paste"
|
| "paste"
|
||||||
| "copyAsPng"
|
| "copyAsPng"
|
||||||
| "copyAsSvg"
|
| "copyAsSvg"
|
||||||
|
| "copyText"
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
@@ -82,6 +85,7 @@ export type ActionName =
|
|||||||
| "zoomToSelection"
|
| "zoomToSelection"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
|
| "changeVerticalAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
| "group"
|
| "group"
|
||||||
@@ -101,7 +105,14 @@ export type ActionName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme";
|
| "toggleTheme"
|
||||||
|
| "increaseFontSize"
|
||||||
|
| "decreaseFontSize"
|
||||||
|
| "unbindText"
|
||||||
|
| "hyperlink"
|
||||||
|
| "eraser"
|
||||||
|
| "bindText"
|
||||||
|
| "toggleLock";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@@ -121,18 +132,34 @@ export interface Action {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?:
|
||||||
|
| 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 {
|
| {
|
||||||
actions: Record<ActionName, Action>;
|
category:
|
||||||
registerAction: (action: Action) => void;
|
| "toolbar"
|
||||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
| "element"
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
| "canvas"
|
||||||
executeAction: (action: Action) => void;
|
| "export"
|
||||||
|
| "history"
|
||||||
|
| "menu"
|
||||||
|
| "collab"
|
||||||
|
| "hyperlink";
|
||||||
|
action?: string;
|
||||||
|
predicate?: (
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
value: any,
|
||||||
|
) => boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-22
@@ -1,6 +1,7 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||||
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -30,28 +31,6 @@ export const alignElements = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaximumGroups = (
|
|
||||||
elements: ExcalidrawElement[],
|
|
||||||
): ExcalidrawElement[][] => {
|
|
||||||
const groups: Map<String, ExcalidrawElement[]> = new Map<
|
|
||||||
String,
|
|
||||||
ExcalidrawElement[]
|
|
||||||
>();
|
|
||||||
|
|
||||||
elements.forEach((element: ExcalidrawElement) => {
|
|
||||||
const groupId =
|
|
||||||
element.groupIds.length === 0
|
|
||||||
? element.id
|
|
||||||
: element.groupIds[element.groupIds.length - 1];
|
|
||||||
|
|
||||||
const currentGroupMembers = groups.get(groupId) || [];
|
|
||||||
|
|
||||||
groups.set(groupId, [...currentGroupMembers, element]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(groups.values());
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTranslation = (
|
const calculateTranslation = (
|
||||||
group: ExcalidrawElement[],
|
group: ExcalidrawElement[],
|
||||||
selectionBoundingBox: Box,
|
selectionBoundingBox: Box,
|
||||||
|
|||||||
+13
-9
@@ -3,16 +3,20 @@ 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, name: string, label?: string, value?: number) => {
|
? (category: string, action: string, label?: string, value?: number) => {
|
||||||
window.gtag("event", name, {
|
try {
|
||||||
event_category: category,
|
window.gtag("event", action, {
|
||||||
event_label: label,
|
event_category: category,
|
||||||
value,
|
event_label: label,
|
||||||
});
|
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, name: string, label?: string, value?: number) => {}
|
? (category: string, action: string, label?: string, value?: number) => {}
|
||||||
: (category: string, name: string, label?: string, value?: number) => {
|
: (category: string, action: string, label?: string, value?: number) => {
|
||||||
// Uncomment the next line to track locally
|
// Uncomment the next line to track locally
|
||||||
// console.info("Track Event", category, name, label, value);
|
// console.log("Track Event", { category, action, label, value });
|
||||||
};
|
};
|
||||||
|
|||||||
+21
-5
@@ -41,8 +41,13 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
elementLocked: false,
|
activeTool: {
|
||||||
elementType: "selection",
|
type: "selection",
|
||||||
|
locked: false,
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
},
|
||||||
|
penMode: false,
|
||||||
|
penDetected: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportScale: defaultExportScale,
|
exportScale: defaultExportScale,
|
||||||
@@ -77,9 +82,12 @@ export const getDefaultAppState = (): Omit<
|
|||||||
toastMessage: null,
|
toastMessage: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: {
|
||||||
|
value: 1 as NormalizedZoomValue,
|
||||||
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElement: null,
|
pendingImageElement: null,
|
||||||
|
showHyperlinkPopup: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,8 +133,9 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
editingElement: { browser: false, export: false, server: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
elementLocked: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
elementType: { browser: true, export: false, server: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
|
penDetected: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false, server: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
exportBackground: { browser: true, export: false, server: false },
|
exportBackground: { browser: true, export: false, server: false },
|
||||||
exportEmbedScene: { browser: true, export: false, server: false },
|
exportEmbedScene: { browser: true, export: false, server: false },
|
||||||
@@ -168,6 +177,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
zoom: { browser: true, export: false, server: false },
|
zoom: { browser: true, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElement: { browser: false, export: false, server: false },
|
pendingImageElement: { browser: false, export: false, server: false },
|
||||||
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
@@ -205,3 +215,9 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
|||||||
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";
|
||||||
|
|||||||
+9
-3
@@ -1,5 +1,10 @@
|
|||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
import {
|
||||||
|
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";
|
||||||
@@ -103,7 +108,7 @@ const transposeCells = (cells: string[][]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
// Copy/paste from excel, spreadsheets, 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
|
||||||
@@ -161,7 +166,8 @@ const commonProps = {
|
|||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
verticalAlign: "middle",
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
|
locked: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
|
|||||||
+33
-6
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -58,7 +59,8 @@ export const copyToClipboard = async (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
// 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: selectedElements,
|
elements: selectedElements,
|
||||||
@@ -123,7 +125,7 @@ const getSystemClipboard = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attemps to parse clipboard. Prefers system clipboard.
|
* Attempts to parse clipboard. Prefers system clipboard.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
@@ -165,10 +167,35 @@ export const parseClipboard = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||||
await navigator.clipboard.write([
|
let promise;
|
||||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
try {
|
||||||
]);
|
// 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) => {
|
||||||
|
|||||||
+59
-23
@@ -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 { useIsMobile } from "../components/App";
|
import { useDeviceType } from "../components/App";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
@@ -19,36 +19,50 @@ import { capitalizeString, isTransparent, 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,
|
||||||
elementType,
|
activeTool,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
elementType: ExcalidrawElement["type"];
|
activeTool: AppState["activeTool"]["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 isMobile = useIsMobile();
|
const deviceType = useDeviceType();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const showFillIcons =
|
const showFillIcons =
|
||||||
hasBackground(elementType) ||
|
hasBackground(activeTool) ||
|
||||||
targetElements.some(
|
targetElements.some(
|
||||||
(element) =>
|
(element) =>
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
);
|
);
|
||||||
const showChangeBackgroundIcons =
|
const showChangeBackgroundIcons =
|
||||||
hasBackground(elementType) ||
|
hasBackground(activeTool) ||
|
||||||
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) {
|
||||||
@@ -60,23 +74,23 @@ export const SelectedShapeActions = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{((hasStrokeColor(elementType) &&
|
{((hasStrokeColor(activeTool) &&
|
||||||
elementType !== "image" &&
|
activeTool !== "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(elementType) ||
|
{(hasStrokeWidth(activeTool) ||
|
||||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||||
renderAction("changeStrokeWidth")}
|
renderAction("changeStrokeWidth")}
|
||||||
|
|
||||||
{(elementType === "freedraw" ||
|
{(activeTool === "freedraw" ||
|
||||||
targetElements.some((element) => element.type === "freedraw")) &&
|
targetElements.some((element) => element.type === "freedraw")) &&
|
||||||
renderAction("changeStrokeShape")}
|
renderAction("changeStrokeShape")}
|
||||||
|
|
||||||
{(hasStrokeStyle(elementType) ||
|
{(hasStrokeStyle(activeTool) ||
|
||||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeStrokeStyle")}
|
{renderAction("changeStrokeStyle")}
|
||||||
@@ -84,12 +98,12 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canChangeSharpness(elementType) ||
|
{(canChangeSharpness(activeTool) ||
|
||||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||||
<>{renderAction("changeSharpness")}</>
|
<>{renderAction("changeSharpness")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasText(elementType) ||
|
{(hasText(activeTool) ||
|
||||||
targetElements.some((element) => hasText(element.type))) && (
|
targetElements.some((element) => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeFontSize")}
|
{renderAction("changeFontSize")}
|
||||||
@@ -100,7 +114,11 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canHaveArrowheads(elementType) ||
|
{targetElements.some(
|
||||||
|
(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")}</>
|
||||||
)}
|
)}
|
||||||
@@ -117,7 +135,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{targetElements.length > 1 && (
|
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.align")}</legend>
|
<legend>{t("labels.align")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -150,14 +168,15 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isMobile && !isEditing && targetElements.length > 0 && (
|
{!isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{renderAction("duplicateSelection")}
|
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
||||||
{renderAction("deleteSelectedElements")}
|
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
@@ -167,14 +186,16 @@ export const SelectedShapeActions = ({
|
|||||||
|
|
||||||
export const ShapesSwitcher = ({
|
export const ShapesSwitcher = ({
|
||||||
canvas,
|
canvas,
|
||||||
elementType,
|
activeTool,
|
||||||
setAppState,
|
setAppState,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
|
appState,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
activeTool: AppState["activeTool"];
|
||||||
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) => {
|
||||||
@@ -189,20 +210,35 @@ export const ShapesSwitcher = ({
|
|||||||
key={value}
|
key={value}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={elementType === value}
|
checked={activeTool.type === 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 = { ...activeTool, type: value };
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
activeTool: nextActiveTool,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, value);
|
setCursorForShape(canvas, {
|
||||||
|
...appState,
|
||||||
|
activeTool: nextActiveTool,
|
||||||
|
});
|
||||||
if (value === "image") {
|
if (value === "image") {
|
||||||
onImageAction({ pointerType });
|
onImageAction({ pointerType });
|
||||||
}
|
}
|
||||||
|
|||||||
+1173
-338
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
onChange,
|
onChange,
|
||||||
group,
|
group,
|
||||||
}: {
|
}: {
|
||||||
options: { value: T; text: string; icon: JSX.Element }[];
|
options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
|
||||||
value: T | null;
|
value: T | null;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
group: string;
|
group: string;
|
||||||
@@ -24,6 +24,7 @@ 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>
|
||||||
|
|||||||
+11
-4
@@ -3,15 +3,22 @@ import OpenColor from "open-color";
|
|||||||
import "./Card.scss";
|
import "./Card.scss";
|
||||||
|
|
||||||
export const Card: React.FC<{
|
export const Card: React.FC<{
|
||||||
color: keyof OpenColor;
|
color: keyof OpenColor | "primary";
|
||||||
}> = ({ children, color }) => {
|
}> = ({ children, color }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Card"
|
className="Card"
|
||||||
style={{
|
style={{
|
||||||
["--card-color" as any]: OpenColor[color][7],
|
["--card-color" as any]:
|
||||||
["--card-color-darker" as any]: OpenColor[color][8],
|
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
|
||||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
["--card-color-darker" as any]:
|
||||||
|
color === "primary"
|
||||||
|
? "var(--color-primary-darker)"
|
||||||
|
: OpenColor[color][8],
|
||||||
|
["--card-color-darkest" as any]:
|
||||||
|
color === "primary"
|
||||||
|
? "var(--color-primary-darkest)"
|
||||||
|
: OpenColor[color][9],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import "./CheckboxItem.scss";
|
|||||||
|
|
||||||
export const CheckboxItem: React.FC<{
|
export const CheckboxItem: React.FC<{
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ children, checked, onChange, className }) => {
|
}> = ({ children, checked, onChange, className }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("Checkbox", className, { "is-checked": checked })}
|
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onChange(!checked);
|
onChange(!checked, event);
|
||||||
(
|
(
|
||||||
(event.currentTarget as HTMLDivElement).querySelector(
|
(event.currentTarget as HTMLDivElement).querySelector(
|
||||||
".Checkbox-box",
|
".Checkbox-box",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import { useDeviceType } 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={useIsMobile()}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
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 { useIsMobile } from "../components/App";
|
import { useDeviceType } 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={useIsMobile()}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
>
|
>
|
||||||
{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 {
|
.color-picker-content--default {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, auto);
|
grid-template-columns: repeat(5, auto);
|
||||||
@@ -59,6 +59,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-42
@@ -7,6 +7,53 @@ 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;
|
||||||
@@ -35,6 +82,7 @@ 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 = ({
|
||||||
@@ -45,6 +93,7 @@ const Picker = ({
|
|||||||
label,
|
label,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
type,
|
type,
|
||||||
|
elements,
|
||||||
}: {
|
}: {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -53,12 +102,20 @@ 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) {
|
||||||
@@ -85,23 +142,42 @@ const Picker = ({
|
|||||||
} else if (isArrowKey(event.key)) {
|
} else if (isArrowKey(event.key)) {
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
const index = Array.prototype.indexOf.call(
|
let isCustom = false;
|
||||||
gallery!.current!.children,
|
let index = Array.prototype.indexOf.call(
|
||||||
|
gallery!.current!.querySelector(".color-picker-content--default")!
|
||||||
|
.children,
|
||||||
activeElement,
|
activeElement,
|
||||||
);
|
);
|
||||||
|
if (index === -1) {
|
||||||
|
index = Array.prototype.indexOf.call(
|
||||||
|
gallery!.current!.querySelector(
|
||||||
|
".color-picker-content--canvas-colors",
|
||||||
|
)!.children,
|
||||||
|
activeElement,
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
isCustom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentSelector = isCustom
|
||||||
|
? gallery!.current!.querySelector(
|
||||||
|
".color-picker-content--canvas-colors",
|
||||||
|
)!
|
||||||
|
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const length = gallery!.current!.children.length - (showInput ? 1 : 0);
|
const length = parentSelector!.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
|
||||||
: event.key === KEYS.ARROW_DOWN
|
: !isCustom && event.key === KEYS.ARROW_DOWN
|
||||||
? (index + 5) % length
|
? (index + 5) % length
|
||||||
: event.key === KEYS.ARROW_UP
|
: !isCustom && event.key === KEYS.ARROW_UP
|
||||||
? (length + index - 5) % length
|
? (length + index - 5) % length
|
||||||
: index;
|
: index;
|
||||||
(gallery!.current!.children![nextIndex] as any).focus();
|
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (
|
} else if (
|
||||||
@@ -109,7 +185,15 @@ const Picker = ({
|
|||||||
!isWritableElement(event.target)
|
!isWritableElement(event.target)
|
||||||
) {
|
) {
|
||||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||||
(gallery!.current!.children![index] as any).focus();
|
const isCustom = index >= MAX_DEFAULT_COLORS;
|
||||||
|
const parentSelector = isCustom
|
||||||
|
? gallery!.current!.querySelector(
|
||||||
|
".color-picker-content--canvas-colors",
|
||||||
|
)!
|
||||||
|
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||||
|
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
||||||
|
(parentSelector!.children![actualIndex] as HTMLElement)?.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) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -119,6 +203,50 @@ const Picker = ({
|
|||||||
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 (
|
||||||
<div
|
<div
|
||||||
className={`color-picker color-picker-type-${type}`}
|
className={`color-picker color-picker-type-${type}`}
|
||||||
@@ -138,41 +266,20 @@ const Picker = ({
|
|||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => {
|
<div className="color-picker-content--default">
|
||||||
const _colorWithoutHash = _color.replace("#", "");
|
{renderColors(colors)}
|
||||||
return (
|
</div>
|
||||||
<button
|
{!!customColors.length && (
|
||||||
className="color-picker-swatch"
|
<div className="color-picker-content--canvas">
|
||||||
onClick={(event) => {
|
<span className="color-picker-content--canvas-title">
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
{t("labels.canvasColors")}
|
||||||
onChange(_color);
|
</span>
|
||||||
}}
|
<div className="color-picker-content--canvas-colors">
|
||||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
{renderColors(customColors, true)}
|
||||||
!isTransparent(_color) ? ` (${_color})` : ""
|
</div>
|
||||||
} — ${keyBindings[i].toUpperCase()}`}
|
</div>
|
||||||
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}
|
||||||
@@ -246,6 +353,8 @@ 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;
|
||||||
@@ -253,6 +362,8 @@ 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);
|
||||||
|
|
||||||
@@ -294,6 +405,7 @@ export const ColorPicker = ({
|
|||||||
label={label}
|
label={label}
|
||||||
showInput={false}
|
showInput={false}
|
||||||
type={type}
|
type={type}
|
||||||
|
elements={elements}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ type ContextMenuProps = {
|
|||||||
left: number;
|
left: number;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({
|
const ContextMenu = ({
|
||||||
@@ -30,6 +32,7 @@ const ContextMenu = ({
|
|||||||
left,
|
left,
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
|
elements,
|
||||||
}: ContextMenuProps) => {
|
}: ContextMenuProps) => {
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -37,6 +40,10 @@ 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"
|
||||||
@@ -48,9 +55,14 @@ const ContextMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionName = option.name;
|
const actionName = option.name;
|
||||||
const label = option.contextItemLabel
|
let label = "";
|
||||||
? t(option.contextItemLabel)
|
if (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
|
||||||
@@ -58,7 +70,9 @@ const ContextMenu = ({
|
|||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: option.checked?.(appState),
|
checkmark: option.checked?.(appState),
|
||||||
})}
|
})}
|
||||||
onClick={() => actionManager.executeAction(option)}
|
onClick={() =>
|
||||||
|
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">
|
||||||
@@ -97,6 +111,7 @@ 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) => {
|
||||||
@@ -125,6 +140,7 @@ 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,7 +2,7 @@ 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, useIsMobile } from "../components/App";
|
import { useExcalidrawContainer, useDeviceType } 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";
|
||||||
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useIsMobile() ? back : close}
|
{useDeviceType().isMobile ? 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.shapes")}>
|
<ShortcutIsland caption={t("helpDialog.tools")}>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("toolBar.selection")}
|
label={t("toolBar.selection")}
|
||||||
shortcuts={["V", "1"]}
|
shortcuts={["V", "1"]}
|
||||||
@@ -149,16 +149,20 @@ 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={["E", "4"]} />
|
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "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
|
||||||
label={t("toolBar.freedraw")}
|
label={t("toolBar.freedraw")}
|
||||||
shortcuts={["Shift+P", "7"]}
|
shortcuts={["Shift + P", "X", "7"]}
|
||||||
/>
|
/>
|
||||||
<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={[
|
||||||
@@ -205,6 +209,10 @@ 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
|
||||||
@@ -260,6 +268,18 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.multiSelect")}
|
label={t("labels.multiSelect")}
|
||||||
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.deepSelect")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.deepBoxSelect")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.moveCanvas")}
|
label={t("labels.moveCanvas")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -343,6 +363,10 @@ 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")]}
|
||||||
@@ -382,6 +406,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.showBackground")}
|
label={t("labels.showBackground")}
|
||||||
shortcuts={[getShortcutKey("G")]}
|
shortcuts={[getShortcutKey("G")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.decreaseFontSize")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.increaseFontSize")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
|
||||||
|
/>
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
</Columns>
|
</Columns>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { AppState } from "../types";
|
|||||||
import {
|
import {
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isTextBindableContainer,
|
||||||
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;
|
||||||
@@ -18,25 +20,32 @@ interface HintViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (elementType === "arrow" || elementType === "line") {
|
if (appState.isLibraryOpen) {
|
||||||
|
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 (elementType === "freedraw") {
|
if (activeTool.type === "freedraw") {
|
||||||
return t("hints.freeDraw");
|
return t("hints.freeDraw");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementType === "text") {
|
if (activeTool.type === "text") {
|
||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
||||||
return t("hints.placeImage");
|
return t("hints.placeImage");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +69,6 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
return t("hints.rotate");
|
return t("hints.rotate");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
return appState.editingLinearElement.activePointIndex
|
|
||||||
? t("hints.lineEditor_pointSelected")
|
|
||||||
: t("hints.lineEditor_nothingSelected");
|
|
||||||
}
|
|
||||||
return t("hints.lineEditor_info");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||||
return t("hints.text_selected");
|
return t("hints.text_selected");
|
||||||
}
|
}
|
||||||
@@ -77,8 +77,31 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementType === "selection" && !selectedElements.length && !isMobile) {
|
if (activeTool.type === "selection") {
|
||||||
return t("hints.canvasPanning");
|
if (
|
||||||
|
appState.draggingElement?.type === "selection" &&
|
||||||
|
!appState.editingElement &&
|
||||||
|
!appState.editingLinearElement
|
||||||
|
) {
|
||||||
|
return t("hints.deepBoxSelect");
|
||||||
|
}
|
||||||
|
if (!selectedElements.length && !isMobile) {
|
||||||
|
return t("hints.canvasPanning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedElements.length === 1) {
|
||||||
|
if (isLinearElement(selectedElements[0])) {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
return appState.editingLinearElement.selectedPointsIndices
|
||||||
|
? t("hints.lineEditor_pointSelected")
|
||||||
|
: t("hints.lineEditor_nothingSelected");
|
||||||
|
}
|
||||||
|
return t("hints.lineEditor_info");
|
||||||
|
}
|
||||||
|
if (isTextBindableContainer(selectedElements[0])) {
|
||||||
|
return t("hints.bindTextToElement");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--button-gray-2);
|
||||||
& svg {
|
& svg {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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 { useIsMobile } from "./App";
|
import { useDeviceType } 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";
|
||||||
@@ -19,6 +18,7 @@ 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: ActionsManagerInterface;
|
actionManager: ActionManager;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
@@ -102,7 +102,7 @@ const ImageExportModal = ({
|
|||||||
const { exportBackground, viewBackgroundColor } = appState;
|
const { exportBackground, viewBackgroundColor } = appState;
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const exportedElements = exportSelected
|
||||||
? getSelectedElements(elements, appState)
|
? getSelectedElements(elements, appState, true)
|
||||||
: elements;
|
: elements;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionManager;
|
||||||
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={useIsMobile()}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
title={t("buttons.exportImage")}
|
title={t("buttons.exportImage")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
--padding: 0;
|
--padding: 0;
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
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 { useIsMobile } from "./App";
|
import { useDeviceType } 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";
|
||||||
@@ -12,6 +11,9 @@ 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[],
|
||||||
@@ -29,7 +31,7 @@ const JSONExportModal = ({
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionManager;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
@@ -54,7 +56,7 @@ const JSONExportModal = ({
|
|||||||
aria-label={t("exportDialog.disk_button")}
|
aria-label={t("exportDialog.disk_button")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
actionManager.executeAction(actionSaveFileToDisk);
|
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -70,9 +72,10 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionManager;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -114,7 +117,7 @@ export const JSONExportDialog = ({
|
|||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.export")}
|
aria-label={t("buttons.export")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
title={t("buttons.export")}
|
title={t("buttons.export")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
+71
-25
@@ -6,7 +6,6 @@ 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";
|
||||||
@@ -19,7 +18,6 @@ import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
|||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@@ -35,6 +33,12 @@ import { LibraryButton } from "./LibraryButton";
|
|||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
import { LibraryMenu } from "./LibraryMenu";
|
import { LibraryMenu } from "./LibraryMenu";
|
||||||
|
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import "./Toolbar.scss";
|
||||||
|
import { PenModeButton } from "./PenModeButton";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { useDeviceType } from "../components/App";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -44,6 +48,7 @@ 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;
|
||||||
@@ -74,6 +79,7 @@ const LayerUI = ({
|
|||||||
elements,
|
elements,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
|
onPenModeToggle,
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
@@ -90,7 +96,7 @@ const LayerUI = ({
|
|||||||
id,
|
id,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const deviceType = useDeviceType();
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -117,6 +123,7 @@ 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,
|
||||||
@@ -233,7 +240,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 substracting 200
|
// we want to make sure this doesn't overflow so subtracting 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`,
|
||||||
@@ -243,7 +250,7 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
elementType={appState.elementType}
|
activeTool={appState.activeTool.type}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -268,7 +275,7 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
const libraryMenu = appState.isLibraryOpen ? (
|
||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClose={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertShape={onInsertElements}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
@@ -305,27 +312,42 @@ const LayerUI = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row
|
||||||
|
gap={1}
|
||||||
|
className={clsx("App-toolbar-container", {
|
||||||
|
"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.elementLocked}
|
checked={appState.activeTool.locked}
|
||||||
onChange={onLockToggle}
|
onChange={() => onLockToggle()}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
className={clsx("App-toolbar", {
|
||||||
|
"zen-mode": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
isMobile={isMobile}
|
isMobile={deviceType.isMobile}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
|
appState={appState}
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
activeTool={appState.activeTool}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onImageAction={({ pointerType }) => {
|
onImageAction={({ pointerType }) => {
|
||||||
onImageAction({
|
onImageAction({
|
||||||
@@ -369,7 +391,7 @@ const LayerUI = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
{renderTopRightUI?.(isMobile, appState)}
|
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -399,16 +421,39 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
{!viewModeEnabled && (
|
{!viewModeEnabled && (
|
||||||
<div
|
<>
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
<div
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||||
zenModeEnabled,
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
})}
|
zenModeEnabled,
|
||||||
>
|
})}
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
>
|
||||||
{actionManager.renderAction("redo", { size: "small" })}
|
{actionManager.renderAction("undo", { size: "small" })}
|
||||||
</div>
|
{actionManager.renderAction("redo", { size: "small" })}
|
||||||
|
</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 &&
|
||||||
|
deviceType.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>
|
||||||
@@ -447,7 +492,7 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const dialogs = (
|
const dialogs = (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={appState.errorMessage}
|
message={appState.errorMessage}
|
||||||
@@ -476,7 +521,7 @@ const LayerUI = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isMobile ? (
|
return deviceType.isMobile ? (
|
||||||
<>
|
<>
|
||||||
{dialogs}
|
{dialogs}
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
@@ -488,7 +533,8 @@ 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}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
|
|||||||
export const LibraryButton: React.FC<{
|
export const LibraryButton: React.FC<{
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
}> = ({ appState, setAppState }) => {
|
isMobile?: boolean;
|
||||||
|
}> = ({ appState, setAppState, isMobile }) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
"ToolIcon ToolIcon_type_floating ToolIcon__library",
|
||||||
`ToolIcon_size_medium`,
|
`ToolIcon_size_medium`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
"is-mobile": isMobile,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
|
|||||||
@@ -28,8 +28,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__library-message {
|
.layer-ui__library-message {
|
||||||
padding: 10px 20px;
|
padding: 2em 4em;
|
||||||
max-width: 200px;
|
min-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 {
|
||||||
|
|||||||
+130
-88
@@ -1,5 +1,12 @@
|
|||||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
import {
|
||||||
import Library from "../data/library";
|
useRef,
|
||||||
|
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 {
|
||||||
@@ -18,6 +25,11 @@ 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 { arrayToMap } from "../utils";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
@@ -52,6 +64,17 @@ 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,
|
||||||
onInsertShape,
|
onInsertShape,
|
||||||
@@ -101,11 +124,6 @@ export const LibraryMenu = ({
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [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);
|
||||||
@@ -113,55 +131,35 @@ export const LibraryMenu = ({
|
|||||||
url: string;
|
url: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
}>(null);
|
}>(null);
|
||||||
const loadingTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
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(async () => {
|
const removeFromLibrary = useCallback(
|
||||||
const items = await library.loadLibrary();
|
async (libraryItems: LibraryItems) => {
|
||||||
|
const nextItems = libraryItems.filter(
|
||||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
(item) => !selectedItems.includes(item.id),
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
);
|
||||||
setLibraryItems(items);
|
library.saveLibrary(nextItems).catch(() => {
|
||||||
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"]) => {
|
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||||
|
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",
|
||||||
@@ -169,14 +167,12 @@ export const LibraryMenu = ({
|
|||||||
id: randomId(),
|
id: randomId(),
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
},
|
},
|
||||||
...items,
|
...libraryItems,
|
||||||
];
|
];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
library.saveLibrary(nextItems).catch(() => {
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
});
|
});
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
},
|
||||||
[onAddToLibrary, library, setAppState],
|
[onAddToLibrary, library, setAppState],
|
||||||
);
|
);
|
||||||
@@ -215,7 +211,7 @@ export const LibraryMenu = ({
|
|||||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
const onPublishLibSuccess = useCallback(
|
const onPublishLibSuccess = useCallback(
|
||||||
(data) => {
|
(data, libraryItems: LibraryItems) => {
|
||||||
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();
|
||||||
@@ -225,63 +221,109 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
library.saveLibrary(nextLibItems);
|
library.saveLibrary(nextLibItems);
|
||||||
setLibraryItems(nextLibItems);
|
|
||||||
},
|
},
|
||||||
[
|
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||||
setShowPublishLibraryDialog,
|
|
||||||
setPublishLibSuccess,
|
|
||||||
libraryItems,
|
|
||||||
selectedItems,
|
|
||||||
library,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return loadingState === "preloading" ? null : (
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
LibraryItem["id"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
if (libraryItemsData.status === "loading") {
|
||||||
|
return (
|
||||||
|
<LibraryMenuWrapper ref={ref}>
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
<Spinner size="2em" />
|
||||||
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
|
</div>
|
||||||
|
</LibraryMenuWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryMenuWrapper ref={ref}>
|
||||||
{showPublishLibraryDialog && (
|
{showPublishLibraryDialog && (
|
||||||
<PublishLibrary
|
<PublishLibrary
|
||||||
onClose={() => setShowPublishLibraryDialog(false)}
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
libraryItems={getSelectedItems(
|
||||||
|
libraryItemsData.libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
)}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
onSuccess={onPublishLibSuccess}
|
onSuccess={(data) =>
|
||||||
|
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
onError={(error) => window.alert(error)}
|
onError={(error) => window.alert(error)}
|
||||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
updateItemsInStorage={() =>
|
||||||
|
library.saveLibrary(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
|
||||||
|
libraryItems={libraryItemsData.libraryItems}
|
||||||
|
onRemoveFromLibrary={() =>
|
||||||
|
removeFromLibrary(libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
onAddToLibrary={(elements) =>
|
||||||
|
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
onInsertShape={onInsertShape}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
setAppState={setAppState}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
library={library}
|
||||||
|
theme={theme}
|
||||||
|
files={files}
|
||||||
|
id={id}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggle={(id, event) => {
|
||||||
|
const shouldSelect = !selectedItems.includes(id);
|
||||||
|
|
||||||
{loadingState === "loading" ? (
|
if (shouldSelect) {
|
||||||
<div className="layer-ui__library-message">
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
{t("labels.libraryLoadingMessage")}
|
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||||
</div>
|
(item) => item.id === lastSelectedItem,
|
||||||
) : (
|
);
|
||||||
<LibraryMenuItems
|
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||||
libraryItems={libraryItems}
|
(item) => item.id === id,
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
);
|
||||||
onAddToLibrary={addToLibrary}
|
|
||||||
onInsertShape={onInsertShape}
|
if (rangeStart === -1 || rangeEnd === -1) {
|
||||||
pendingElements={pendingElements}
|
setSelectedItems([...selectedItems, id]);
|
||||||
setAppState={setAppState}
|
return;
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
}
|
||||||
library={library}
|
|
||||||
theme={theme}
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
files={files}
|
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
|
||||||
id={id}
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
selectedItems={selectedItems}
|
if (
|
||||||
onToggle={(id) => {
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
if (!selectedItems.includes(id)) {
|
selectedItemsMap.has(item.id)
|
||||||
setSelectedItems([...selectedItems, id]);
|
) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedItems(nextSelectedIds);
|
||||||
} else {
|
} else {
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
setSelectedItems([...selectedItems, id]);
|
||||||
}
|
}
|
||||||
}}
|
setLastSelectedItem(id);
|
||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
} else {
|
||||||
resetLibrary={resetLibrary}
|
setLastSelectedItem(null);
|
||||||
/>
|
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||||
)}
|
}
|
||||||
</Island>
|
}}
|
||||||
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
</LibraryMenuWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
LibraryItems,
|
LibraryItems,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { useIsMobile } from "./App";
|
import { useDeviceType } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
@@ -21,6 +21,7 @@ import { ToolButton } from "./ToolButton";
|
|||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
|
import { VERSIONS } from "../constants";
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
libraryItems,
|
libraryItems,
|
||||||
@@ -51,7 +52,7 @@ const LibraryMenuItems = ({
|
|||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onToggle: (id: LibraryItem["id"]) => void;
|
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
resetLibrary: () => void;
|
resetLibrary: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -84,7 +85,7 @@ const LibraryMenuItems = ({
|
|||||||
|
|
||||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useDeviceType().isMobile;
|
||||||
|
|
||||||
const renderLibraryActions = () => {
|
const renderLibraryActions = () => {
|
||||||
const itemsSelected = !!selectedItems.length;
|
const itemsSelected = !!selectedItems.length;
|
||||||
@@ -105,11 +106,6 @@ const LibraryMenuItems = ({
|
|||||||
icon={load}
|
icon={load}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
importLibraryFromJSON(library)
|
importLibraryFromJSON(library)
|
||||||
.then(() => {
|
|
||||||
// Close and then open to get the libraries updated
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
setAppState({ isLibraryOpen: true });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
@@ -212,10 +208,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={() => {
|
onToggle={(id, event) => {
|
||||||
if (params.item?.id) {
|
onToggle(id, event);
|
||||||
onToggle(params.item.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -293,7 +287,9 @@ const LibraryMenuItems = ({
|
|||||||
<a
|
<a
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
window.name || "_blank"
|
window.name || "_blank"
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
|
VERSIONS.excalidrawLibrary
|
||||||
|
}`}
|
||||||
target="_excalidraw_libraries"
|
target="_excalidraw_libraries"
|
||||||
>
|
>
|
||||||
{t("labels.libraries")}
|
{t("labels.libraries")}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
|
|
||||||
.library-unit__dragger {
|
.library-unit__dragger {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -99,8 +101,13 @@
|
|||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.library-unit--hover .library-unit__adder {
|
.library-unit:hover .library-unit__adder {
|
||||||
color: $oc-blue-7;
|
fill: $oc-blue-7;
|
||||||
|
}
|
||||||
|
.library-unit:active .library-unit__adder {
|
||||||
|
animation: none;
|
||||||
|
transform: scale(0.8);
|
||||||
|
fill: $oc-black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__active {
|
.library-unit__active {
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ 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 { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useDeviceType } 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";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
|
|
||||||
// fa-plus
|
|
||||||
const PLUS_ICON = (
|
const PLUS_ICON = (
|
||||||
<svg viewBox="0 0 1792 1792">
|
<svg viewBox="0 0 1792 1792">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
|
||||||
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
|
style={{
|
||||||
|
stroke: "#fff",
|
||||||
|
strokeWidth: 140,
|
||||||
|
}}
|
||||||
|
transform="translate(0 64)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -33,7 +36,7 @@ export const LibraryUnit = ({
|
|||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,7 +66,7 @@ export const LibraryUnit = ({
|
|||||||
}, [elements, files]);
|
}, [elements, files]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useDeviceType().isMobile;
|
||||||
const adder = isPending && (
|
const adder = isPending && (
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
@@ -84,7 +87,17 @@ export const LibraryUnit = ({
|
|||||||
})}
|
})}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
draggable={!!elements}
|
draggable={!!elements}
|
||||||
onClick={!!elements || !!isPending ? onClick : undefined}
|
onClick={
|
||||||
|
!!elements || !!isPending
|
||||||
|
? (event) => {
|
||||||
|
if (id && event.shiftKey) {
|
||||||
|
onToggle(id, event);
|
||||||
|
} else {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
@@ -97,7 +110,7 @@ export const LibraryUnit = ({
|
|||||||
{id && elements && (isHovered || isMobile || selected) && (
|
{id && elements && (isHovered || isMobile || selected) && (
|
||||||
<CheckboxItem
|
<CheckboxItem
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={() => onToggle(id)}
|
onChange={(checked, event) => onToggle(id, event)}
|
||||||
className="library-unit__checkbox"
|
className="library-unit__checkbox"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
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">
|
||||||
<span>{t("labels.loadingScene")}</span>
|
<div>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type LockIconProps = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
zenModeEnabled?: boolean;
|
zenModeEnabled?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||||
@@ -42,10 +43,10 @@ export const LockButton = (props: LockIconProps) => {
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
"is-mobile": props.isMobile,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${props.title} — Q`}
|
title={`${props.title} — Q`}
|
||||||
|
|||||||
@@ -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 } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } 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,6 +17,7 @@ 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;
|
||||||
@@ -28,6 +29,7 @@ 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?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
@@ -50,6 +52,7 @@ export const MobileMenu = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
|
onPenModeToggle,
|
||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
@@ -64,13 +67,14 @@ export const MobileMenu = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="center">
|
<Stack.Col gap={4} align="center">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1} className="App-toolbar-container">
|
||||||
<Island padding={1}>
|
<Island padding={1} className="App-toolbar">
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
|
appState={appState}
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
activeTool={appState.activeTool}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onImageAction={({ pointerType }) => {
|
onImageAction={({ pointerType }) => {
|
||||||
onImageAction({
|
onImageAction({
|
||||||
@@ -82,11 +86,23 @@ export const MobileMenu = ({
|
|||||||
</Island>
|
</Island>
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||||
<LockButton
|
<LockButton
|
||||||
checked={appState.elementLocked}
|
checked={appState.activeTool.locked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
<LibraryButton
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
<PenModeButton
|
||||||
|
checked={appState.penMode}
|
||||||
|
onChange={onPenModeToggle}
|
||||||
|
title={t("toolBar.penMode")}
|
||||||
|
isMobile
|
||||||
|
penDetected={appState.penDetected}
|
||||||
/>
|
/>
|
||||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -98,6 +114,12 @@ 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">
|
||||||
@@ -105,12 +127,16 @@ 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",
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +226,7 @@ export const MobileMenu = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
elementType={appState.elementType}
|
activeTool={appState.activeTool.type}
|
||||||
/>
|
/>
|
||||||
</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, useIsMobile } from "./App";
|
import { useExcalidrawContainer, useDeviceType } 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 isMobile = useIsMobile();
|
const deviceType = useDeviceType();
|
||||||
const isMobileRef = useRef(isMobile);
|
const isMobileRef = useRef(deviceType.isMobile);
|
||||||
isMobileRef.current = isMobile;
|
isMobileRef.current = deviceType.isMobile;
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (div) {
|
if (div) {
|
||||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
||||||
}
|
}
|
||||||
}, [div, isMobile]);
|
}, [div, deviceType.isMobile]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const isDarkTheme =
|
const isDarkTheme =
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,10 @@ 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 = ({
|
||||||
@@ -16,6 +20,10 @@ 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);
|
||||||
|
|
||||||
@@ -24,17 +32,14 @@ export const Popover = ({
|
|||||||
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`;
|
||||||
}
|
}
|
||||||
const viewportHeight = window.innerHeight;
|
if (y + height - offsetTop > viewportHeight) {
|
||||||
if (y + height > viewportHeight) {
|
|
||||||
element.style.top = `${viewportHeight - height}px`;
|
element.style.top = `${viewportHeight - height}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fitInViewport]);
|
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onCloseRequest) {
|
if (onCloseRequest) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
import oc from "open-color";
|
import OpenColor from "open-color";
|
||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -7,16 +7,19 @@ import { t } from "../i18n";
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||||
import { exportToBlob } from "../packages/utils";
|
import { exportToCanvas } from "../packages/utils";
|
||||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
|
import {
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
EXPORT_SOURCE,
|
||||||
|
MIME_TYPES,
|
||||||
|
VERSIONS,
|
||||||
|
} from "../constants";
|
||||||
import { ExportedLibraryData } from "../data/types";
|
import { ExportedLibraryData } from "../data/types";
|
||||||
|
|
||||||
import "./PublishLibrary.scss";
|
import "./PublishLibrary.scss";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { newElement } from "../element";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
|
||||||
import SingleLibraryItem from "./SingleLibraryItem";
|
import SingleLibraryItem from "./SingleLibraryItem";
|
||||||
|
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||||
|
import { chunk } from "../utils";
|
||||||
|
|
||||||
interface PublishLibraryDataParams {
|
interface PublishLibraryDataParams {
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@@ -55,6 +58,75 @@ const importPublishLibDataFromStorage = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
||||||
|
const MAX_ITEMS_PER_ROW = 6;
|
||||||
|
const BOX_SIZE = 128;
|
||||||
|
const BOX_PADDING = Math.round(BOX_SIZE / 16);
|
||||||
|
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
|
||||||
|
|
||||||
|
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
canvas.width =
|
||||||
|
rows[0].length * BOX_SIZE +
|
||||||
|
(rows[0].length + 1) * (BOX_PADDING * 2) -
|
||||||
|
BOX_PADDING * 2;
|
||||||
|
canvas.height =
|
||||||
|
rows.length * BOX_SIZE +
|
||||||
|
(rows.length + 1) * (BOX_PADDING * 2) -
|
||||||
|
BOX_PADDING * 2;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
ctx.fillStyle = OpenColor.white;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// draw items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
for (const [index, item] of libraryItems.entries()) {
|
||||||
|
const itemCanvas = await exportToCanvas({
|
||||||
|
elements: item.elements,
|
||||||
|
files: null,
|
||||||
|
maxWidthOrHeight: BOX_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { width, height } = itemCanvas;
|
||||||
|
|
||||||
|
// draw item
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const rowOffset =
|
||||||
|
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
|
||||||
|
const colOffset =
|
||||||
|
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
itemCanvas,
|
||||||
|
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
|
||||||
|
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
|
||||||
|
);
|
||||||
|
|
||||||
|
// draw item border
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
ctx.lineWidth = BORDER_WIDTH;
|
||||||
|
ctx.strokeStyle = OpenColor.gray[4];
|
||||||
|
ctx.strokeRect(
|
||||||
|
colOffset + BOX_PADDING / 2,
|
||||||
|
rowOffset + BOX_PADDING / 2,
|
||||||
|
BOX_SIZE + BOX_PADDING,
|
||||||
|
BOX_SIZE + BOX_PADDING,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resizeImageFile(
|
||||||
|
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
|
||||||
|
{
|
||||||
|
outputType: MIME_TYPES.jpg,
|
||||||
|
maxWidthOrHeight: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PublishLibrary = ({
|
const PublishLibrary = ({
|
||||||
onClose,
|
onClose,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
@@ -129,59 +201,12 @@ const PublishLibrary = ({
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elements: ExcalidrawElement[] = [];
|
|
||||||
const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
||||||
clonedLibItems.forEach((libItem) => {
|
|
||||||
const boundingBox = getCommonBoundingBox(libItem.elements);
|
|
||||||
const width = boundingBox.maxX - boundingBox.minX + 30;
|
|
||||||
const height = boundingBox.maxY - boundingBox.minY + 30;
|
|
||||||
const offset = {
|
|
||||||
x: prevBoundingBox.maxX - boundingBox.minX,
|
|
||||||
y: prevBoundingBox.maxY - boundingBox.minY,
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemsWithUpdatedCoords = libItem.elements.map((element) => {
|
const previewImage = await generatePreviewImage(clonedLibItems);
|
||||||
element = mutateElement(element, {
|
|
||||||
x: element.x + offset.x + 15,
|
|
||||||
y: element.y + offset.y + 15,
|
|
||||||
});
|
|
||||||
return element;
|
|
||||||
});
|
|
||||||
const items = [
|
|
||||||
...itemsWithUpdatedCoords,
|
|
||||||
newElement({
|
|
||||||
type: "rectangle",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
x: prevBoundingBox.maxX,
|
|
||||||
y: prevBoundingBox.maxY,
|
|
||||||
strokeColor: "#ced4da",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
opacity: 100,
|
|
||||||
roughness: 0,
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
fillStyle: "solid",
|
|
||||||
strokeWidth: 1,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
elements.push(...items);
|
|
||||||
prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
|
|
||||||
});
|
|
||||||
const png = await exportToBlob({
|
|
||||||
elements,
|
|
||||||
mimeType: "image/png",
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
exportBackground: true,
|
|
||||||
},
|
|
||||||
files: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const libContent: ExportedLibraryData = {
|
const libContent: ExportedLibraryData = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||||
version: 2,
|
version: VERSIONS.excalidrawLibrary,
|
||||||
source: EXPORT_SOURCE,
|
source: EXPORT_SOURCE,
|
||||||
libraryItems: clonedLibItems,
|
libraryItems: clonedLibItems,
|
||||||
};
|
};
|
||||||
@@ -190,7 +215,8 @@ const PublishLibrary = ({
|
|||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("excalidrawLib", lib);
|
formData.append("excalidrawLib", lib);
|
||||||
formData.append("excalidrawPng", png!);
|
formData.append("previewImage", previewImage);
|
||||||
|
formData.append("previewImageType", previewImage.type);
|
||||||
formData.append("title", libraryData.name);
|
formData.append("title", libraryData.name);
|
||||||
formData.append("authorName", libraryData.authorName);
|
formData.append("authorName", libraryData.authorName);
|
||||||
formData.append("githubHandle", libraryData.githubHandle);
|
formData.append("githubHandle", libraryData.githubHandle);
|
||||||
|
|||||||
@@ -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 { useIsMobile } from "../components/App";
|
import { useDeviceType } 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,13 @@ export const Stats = (props: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useIsMobile();
|
const deviceType = useDeviceType();
|
||||||
|
|
||||||
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 (isMobile && props.appState.openMenu) {
|
if (deviceType.isMobile && props.appState.openMenu) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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) => {
|
||||||
@@ -149,6 +150,7 @@ 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(() => {
|
||||||
|
|||||||
@@ -8,17 +8,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
border-radius: var(--space-factor);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
background-color: var(--button-gray-1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon--plain {
|
.ToolIcon--plain {
|
||||||
@@ -29,6 +19,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
& + .ToolIcon__icon {
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--button-gray-2);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: var(--button-gray-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
@@ -38,7 +42,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--border-radius-lg);
|
||||||
|
|
||||||
|
& + .ToolIcon__label {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -46,10 +54,6 @@
|
|||||||
fill: var(--icon-fill-color);
|
fill: var(--icon-fill-color);
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
& + .ToolIcon__label {
|
|
||||||
margin-inline-start: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +125,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus + .ToolIcon__icon {
|
&:focus-visible + .ToolIcon__icon {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +145,6 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -155,14 +155,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2em;
|
height: 2rem;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
|
||||||
margin-left: 0.1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +219,10 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
.App-toolbar-container {
|
||||||
|
.ToolIcon_type_floating {
|
||||||
|
@include toolbarButtonColorStates;
|
||||||
|
|
||||||
|
&:not(.is-mobile) {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
padding: 1px;
|
||||||
|
background-color: var(--island-bg-color);
|
||||||
|
box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: box-shadow 0.5s ease, transform 0.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
&:focus-within + .ToolIcon__icon {
|
||||||
|
// override for custom floating button shadow
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__hidden {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon.ToolIcon__lock {
|
||||||
|
margin-inline-end: var(--space-factor);
|
||||||
|
&.ToolIcon_type_floating {
|
||||||
|
margin-left: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__library {
|
||||||
|
margin-inline-start: var(--space-factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.zen-mode {
|
||||||
|
.ToolIcon_type_floating {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
box-shadow: none;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
|
||||||
|
& + .ToolIcon__icon {
|
||||||
|
svg {
|
||||||
|
fill: $oc-gray-5;
|
||||||
|
color: $oc-gray-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-toolbar {
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
|
||||||
|
|
||||||
|
.ToolIcon {
|
||||||
|
&:hover {
|
||||||
|
--icon-fill-color: var(
|
||||||
|
--color-primary-contrast-offset,
|
||||||
|
var(--color-primary)
|
||||||
|
);
|
||||||
|
--keybinding-color: var(
|
||||||
|
--color-primary-contrast-offset,
|
||||||
|
var(--color-primary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
--icon-fill-color: #{$oc-gray-9};
|
||||||
|
--keybinding-color: #{$oc-gray-9};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include toolbarButtonColorStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.zen-mode {
|
||||||
|
.ToolIcon__keybinding,
|
||||||
|
.HintViewer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme--dark .App-toolbar .ToolIcon:active {
|
||||||
|
--icon-fill-color: #{$oc-gray-3};
|
||||||
|
--keybinding-color: #{$oc-gray-3};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
// wraps the element we want to apply the tooltip to
|
// wraps the element we want to apply the tooltip to
|
||||||
.excalidraw-tooltip-wrapper {
|
.excalidraw-tooltip-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
.excalidraw-tooltip-icon {
|
||||||
|
|||||||
+55
-32
@@ -2,7 +2,7 @@ import "./Tooltip.scss";
|
|||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
const getTooltipDiv = () => {
|
export const getTooltipDiv = () => {
|
||||||
const existingDiv = document.querySelector<HTMLDivElement>(
|
const existingDiv = document.querySelector<HTMLDivElement>(
|
||||||
".excalidraw-tooltip",
|
".excalidraw-tooltip",
|
||||||
);
|
);
|
||||||
@@ -15,6 +15,50 @@ 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,
|
||||||
@@ -27,49 +71,27 @@ const updateTooltip = (
|
|||||||
|
|
||||||
tooltip.textContent = label;
|
tooltip.textContent = label;
|
||||||
|
|
||||||
const {
|
const itemRect = item.getBoundingClientRect();
|
||||||
x: itemX,
|
updateTooltipPosition(tooltip, itemRect);
|
||||||
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 = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
long?: boolean;
|
long?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
export const Tooltip = ({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
long = false,
|
||||||
|
style,
|
||||||
|
}: TooltipProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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"
|
||||||
@@ -84,6 +106,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
|||||||
onPointerLeave={() =>
|
onPointerLeave={() =>
|
||||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
||||||
}
|
}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList > * {
|
.UserList > * {
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { THEME } from "../constants";
|
|||||||
|
|
||||||
const activeElementColor = (theme: Theme) =>
|
const activeElementColor = (theme: Theme) =>
|
||||||
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
||||||
const iconFillColor = (theme: Theme) =>
|
|
||||||
theme === THEME.LIGHT ? oc.black : oc.gray[4];
|
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||||
|
|
||||||
const handlerColor = (theme: Theme) =>
|
const handlerColor = (theme: Theme) =>
|
||||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||||
|
|
||||||
@@ -884,6 +885,40 @@ 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"
|
||||||
@@ -891,3 +926,15 @@ 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" />,
|
||||||
|
);
|
||||||
|
|||||||
+21
-5
@@ -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,6 +52,8 @@ 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 = {
|
||||||
@@ -92,7 +94,9 @@ 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",
|
||||||
@@ -106,10 +110,6 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
|
|
||||||
export const EXPORT_SOURCE = window.location.origin;
|
export const EXPORT_SOURCE = window.location.origin;
|
||||||
|
|
||||||
export const STORAGE_KEYS = {
|
|
||||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
@@ -119,6 +119,7 @@ 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;
|
||||||
@@ -176,3 +177,18 @@ export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
|||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
export const ENCRYPTION_KEY_BITS = 128;
|
export const ENCRYPTION_KEY_BITS = 128;
|
||||||
|
|
||||||
|
export const VERSIONS = {
|
||||||
|
excalidraw: 2,
|
||||||
|
excalidrawLibrary: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BOUND_TEXT_PADDING = 5;
|
||||||
|
|
||||||
|
export const VERTICAL_ALIGN = {
|
||||||
|
TOP: "top",
|
||||||
|
MIDDLE: "middle",
|
||||||
|
BOTTOM: "bottom",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||||
|
|||||||
+9
-7
@@ -16,15 +16,17 @@
|
|||||||
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;
|
||||||
}
|
|
||||||
|
|
||||||
.LoadingMessage span {
|
.Spinner {
|
||||||
background-color: var(--button-gray-1);
|
font-size: 2.8em;
|
||||||
border-radius: 5px;
|
}
|
||||||
padding: 0.8em 1.2em;
|
|
||||||
color: var(--popup-text-color);
|
.LoadingMessage-text {
|
||||||
font-size: 1.3em;
|
margin-top: 1em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-9
@@ -180,7 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.buttonList label:focus-within,
|
.buttonList label:focus-within,
|
||||||
input:focus {
|
input:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
@@ -190,14 +190,14 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-md);
|
||||||
margin: 0.125rem 0;
|
margin: 0.125rem 0;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
@@ -217,14 +217,16 @@
|
|||||||
|
|
||||||
.active,
|
.active,
|
||||||
.buttonList label.active {
|
.buttonList label.active {
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--color-primary);
|
||||||
|
|
||||||
|
--icon-fill-color: #{$oc-white};
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--color-primary-darker);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--button-gray-3);
|
background-color: var(--color-primary-darkest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
svg {
|
svg {
|
||||||
width: 36px;
|
width: 35px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -288,6 +290,16 @@
|
|||||||
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 {
|
||||||
@@ -311,7 +323,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.App-menu_top {
|
.App-menu_top {
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: auto max-content auto;
|
||||||
grid-gap: 4px;
|
grid-gap: 4px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -465,7 +477,17 @@
|
|||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
.undo-redo-buttons {
|
.finalize-button {
|
||||||
|
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;
|
||||||
|
|||||||
+17
-3
@@ -12,7 +12,7 @@
|
|||||||
--dialog-border-color: #{$oc-gray-6};
|
--dialog-border-color: #{$oc-gray-6};
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||||
--focus-highlight-color: #{$oc-blue-2};
|
--focus-highlight-color: #{$oc-blue-2};
|
||||||
--icon-fill-color: #{$oc-black};
|
--icon-fill-color: #{$oc-gray-9};
|
||||||
--icon-green-fill-color: #{$oc-green-9};
|
--icon-green-fill-color: #{$oc-green-9};
|
||||||
--default-bg-color: #{$oc-white};
|
--default-bg-color: #{$oc-white};
|
||||||
--input-bg-color: #{$oc-white};
|
--input-bg-color: #{$oc-white};
|
||||||
@@ -32,10 +32,19 @@
|
|||||||
--sar: env(safe-area-inset-right);
|
--sar: env(safe-area-inset-right);
|
||||||
--sat: env(safe-area-inset-top);
|
--sat: env(safe-area-inset-top);
|
||||||
--select-highlight-color: #{$oc-blue-5};
|
--select-highlight-color: #{$oc-blue-5};
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
--shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
|
||||||
|
|
||||||
--space-factor: 0.25rem;
|
--space-factor: 0.25rem;
|
||||||
--text-primary-color: #{$oc-gray-8};
|
--text-primary-color: #{$oc-gray-8};
|
||||||
|
|
||||||
|
--color-primary: #6965db;
|
||||||
|
--color-primary-darker: #5b57d1;
|
||||||
|
--color-primary-darkest: #4a47b1;
|
||||||
|
--color-primary-light: #e2e1fc;
|
||||||
|
|
||||||
|
--border-radius-md: 0.375rem;
|
||||||
|
--border-radius-lg: 0.5rem;
|
||||||
|
|
||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
background: $oc-black;
|
background: $oc-black;
|
||||||
|
|
||||||
@@ -71,7 +80,12 @@
|
|||||||
--popup-text-color: #{$oc-gray-4};
|
--popup-text-color: #{$oc-gray-4};
|
||||||
--popup-text-inverted-color: #2c2c2c;
|
--popup-text-inverted-color: #2c2c2c;
|
||||||
--select-highlight-color: #{$oc-blue-4};
|
--select-highlight-color: #{$oc-blue-4};
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
--shadow-island: 1px 1px 5px #{transparentize($oc-black, 0.7)};
|
||||||
--text-primary-color: #{$oc-gray-4};
|
--text-primary-color: #{$oc-gray-4};
|
||||||
|
|
||||||
|
--color-primary: #5650f0;
|
||||||
|
--color-primary-darker: #4b46d8;
|
||||||
|
--color-primary-darkest: #3e39be;
|
||||||
|
--color-primary-light: #3f3d64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-17
@@ -1,20 +1,16 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import {
|
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||||
ALLOWED_IMAGE_MIME_TYPES,
|
|
||||||
EXPORT_DATA_TYPES,
|
|
||||||
MIME_TYPES,
|
|
||||||
} from "../constants";
|
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement, FileId } from "../element/types";
|
import { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState, DataURL } from "../types";
|
import { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import { bytesToHexString } from "../utils";
|
import { bytesToHexString } from "../utils";
|
||||||
import { FileSystemHandle } from "./filesystem";
|
import { FileSystemHandle } from "./filesystem";
|
||||||
import { isValidExcalidrawData } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
import { restore } from "./restore";
|
import { restore, restoreLibraryItems } from "./restore";
|
||||||
import { ImportedLibraryData } from "./types";
|
import { ImportedLibraryData } from "./types";
|
||||||
|
|
||||||
const parseFileContents = async (blob: Blob | File) => {
|
const parseFileContents = async (blob: Blob | File) => {
|
||||||
@@ -163,13 +159,17 @@ export const loadFromBlob = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
export const loadLibraryFromBlob = async (
|
||||||
|
blob: Blob,
|
||||||
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const contents = await parseFileContents(blob);
|
||||||
const data: ImportedLibraryData = JSON.parse(contents);
|
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
if (!isValidLibrary(data)) {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error("Invalid library");
|
||||||
}
|
}
|
||||||
return data;
|
const libraryItems = data.libraryItems || data.library;
|
||||||
|
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const canvasToBlob = async (
|
export const canvasToBlob = async (
|
||||||
@@ -237,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
|||||||
|
|
||||||
export const resizeImageFile = async (
|
export const resizeImageFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
maxWidthOrHeight: number,
|
opts: {
|
||||||
|
/** undefined indicates auto */
|
||||||
|
outputType?: typeof MIME_TYPES["jpg"];
|
||||||
|
maxWidthOrHeight: number;
|
||||||
|
},
|
||||||
): Promise<File> => {
|
): Promise<File> => {
|
||||||
// SVG files shouldn't a can't be resized
|
// SVG files shouldn't a can't be resized
|
||||||
if (file.type === MIME_TYPES.svg) {
|
if (file.type === MIME_TYPES.svg) {
|
||||||
@@ -257,16 +261,26 @@ export const resizeImageFile = async (
|
|||||||
pica: pica({ features: ["js", "wasm"] }),
|
pica: pica({ features: ["js", "wasm"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileType = file.type;
|
if (opts.outputType) {
|
||||||
|
const { outputType } = opts;
|
||||||
|
reduce._create_blob = function (env) {
|
||||||
|
return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
|
||||||
|
env.out_blob = blob;
|
||||||
|
return env;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSupportedImageFile(file)) {
|
if (!isSupportedImageFile(file)) {
|
||||||
throw new Error(t("errors.unsupportedFileType"));
|
throw new Error(t("errors.unsupportedFileType"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new File(
|
return new File(
|
||||||
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
|
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
||||||
file.name,
|
file.name,
|
||||||
{ type: fileType },
|
{
|
||||||
|
type: opts.outputType || file.type,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -234,7 +234,19 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
|
|||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
// first chunk is the version (ignored for now)
|
// first chunk is the version
|
||||||
|
const version = dataView(
|
||||||
|
concatenatedBuffer,
|
||||||
|
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
|
||||||
|
cursor,
|
||||||
|
);
|
||||||
|
// If version is outside of the supported versions, throw an error.
|
||||||
|
// This usually means the buffer wasn't encoded using this API, so we'd only
|
||||||
|
// waste compute.
|
||||||
|
if (version > CONCAT_BUFFERS_VERSION) {
|
||||||
|
throw new Error(`invalid version ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
cursor += VERSION_DATAVIEW_BYTES;
|
cursor += VERSION_DATAVIEW_BYTES;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ type FILE_EXTENSION =
|
|||||||
| "gif"
|
| "gif"
|
||||||
| "jpg"
|
| "jpg"
|
||||||
| "png"
|
| "png"
|
||||||
|
| "excalidraw.png"
|
||||||
| "svg"
|
| "svg"
|
||||||
|
| "excalidraw.svg"
|
||||||
| "json"
|
| "json"
|
||||||
| "excalidraw"
|
| "excalidraw"
|
||||||
| "excalidrawlib";
|
| "excalidrawlib";
|
||||||
|
|||||||
+3
-1
@@ -105,7 +105,9 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
|||||||
|
|
||||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||||
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
|
const match = svg.match(
|
||||||
|
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||||
|
);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error("INVALID");
|
throw new Error("INVALID");
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
|
|||||||
export { loadFromJSON, saveAsJSON } from "./json";
|
export { loadFromJSON, saveAsJSON } from "./json";
|
||||||
|
|
||||||
export const exportCanvas = async (
|
export const exportCanvas = async (
|
||||||
type: ExportType,
|
type: Omit<ExportType, "backend">,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
@@ -56,7 +56,7 @@ export const exportCanvas = async (
|
|||||||
{
|
{
|
||||||
description: "Export to SVG",
|
description: "Export to SVG",
|
||||||
name,
|
name,
|
||||||
extension: "svg",
|
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
||||||
fileHandle,
|
fileHandle,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -73,10 +73,10 @@ export const exportCanvas = async (
|
|||||||
});
|
});
|
||||||
tempCanvas.style.display = "none";
|
tempCanvas.style.display = "none";
|
||||||
document.body.appendChild(tempCanvas);
|
document.body.appendChild(tempCanvas);
|
||||||
let blob = await canvasToBlob(tempCanvas);
|
|
||||||
tempCanvas.remove();
|
|
||||||
|
|
||||||
if (type === "png") {
|
if (type === "png") {
|
||||||
|
let blob = await canvasToBlob(tempCanvas);
|
||||||
|
tempCanvas.remove();
|
||||||
if (appState.exportEmbedScene) {
|
if (appState.exportEmbedScene) {
|
||||||
blob = await (
|
blob = await (
|
||||||
await import(/* webpackChunkName: "image" */ "./image")
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
@@ -89,17 +89,24 @@ export const exportCanvas = async (
|
|||||||
return await fileSave(blob, {
|
return await fileSave(blob, {
|
||||||
description: "Export to PNG",
|
description: "Export to PNG",
|
||||||
name,
|
name,
|
||||||
extension: "png",
|
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||||
fileHandle,
|
fileHandle,
|
||||||
});
|
});
|
||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
try {
|
try {
|
||||||
|
const blob = canvasToBlob(tempCanvas);
|
||||||
await copyBlobToClipboardAsPng(blob);
|
await copyBlobToClipboardAsPng(blob);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||||
|
} finally {
|
||||||
|
tempCanvas.remove();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tempCanvas.remove();
|
||||||
|
// shouldn't happen
|
||||||
|
throw new Error("Unsupported export type");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-6
@@ -1,6 +1,11 @@
|
|||||||
import { fileOpen, fileSave } from "./filesystem";
|
import { fileOpen, fileSave } from "./filesystem";
|
||||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
import {
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
EXPORT_SOURCE,
|
||||||
|
MIME_TYPES,
|
||||||
|
VERSIONS,
|
||||||
|
} from "../constants";
|
||||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||||
@@ -10,6 +15,7 @@ import {
|
|||||||
ExportedDataState,
|
ExportedDataState,
|
||||||
ImportedDataState,
|
ImportedDataState,
|
||||||
ExportedLibraryData,
|
ExportedLibraryData,
|
||||||
|
ImportedLibraryData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import Library from "./library";
|
import Library from "./library";
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@ export const serializeAsJSON = (
|
|||||||
): string => {
|
): string => {
|
||||||
const data: ExportedDataState = {
|
const data: ExportedDataState = {
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
version: 2,
|
version: VERSIONS.excalidraw,
|
||||||
source: EXPORT_SOURCE,
|
source: EXPORT_SOURCE,
|
||||||
elements:
|
elements:
|
||||||
type === "local"
|
type === "local"
|
||||||
@@ -109,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidLibrary = (json: any) => {
|
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
|
||||||
return (
|
return (
|
||||||
typeof json === "object" &&
|
typeof json === "object" &&
|
||||||
json &&
|
json &&
|
||||||
@@ -118,14 +124,18 @@ export const isValidLibrary = (json: any) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
|
||||||
const data: ExportedLibraryData = {
|
const data: ExportedLibraryData = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||||
version: 2,
|
version: VERSIONS.excalidrawLibrary,
|
||||||
source: EXPORT_SOURCE,
|
source: EXPORT_SOURCE,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
};
|
};
|
||||||
const serialized = JSON.stringify(data, null, 2);
|
return JSON.stringify(data, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||||
|
const serialized = serializeLibraryAsJSON(libraryItems);
|
||||||
await fileSave(
|
await fileSave(
|
||||||
new Blob([serialized], {
|
new Blob([serialized], {
|
||||||
type: MIME_TYPES.excalidrawlib,
|
type: MIME_TYPES.excalidrawlib,
|
||||||
|
|||||||
+113
-87
@@ -1,11 +1,52 @@
|
|||||||
import { loadLibraryFromBlob } from "./blob";
|
import { loadLibraryFromBlob } from "./blob";
|
||||||
import { LibraryItems, LibraryItem } from "../types";
|
import { LibraryItems, LibraryItem } from "../types";
|
||||||
import { restoreElements, restoreLibraryItems } from "./restore";
|
import { restoreLibraryItems } from "./restore";
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
|
import { ImportedDataState } from "./types";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { jotaiStore } from "../jotai";
|
||||||
|
import { isPromiseLike } from "../utils";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
export const libraryItemsAtom = atom<
|
||||||
|
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
|
||||||
|
| { status: "loaded"; libraryItems: LibraryItems }
|
||||||
|
>({ status: "loaded", libraryItems: [] });
|
||||||
|
|
||||||
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||||
|
JSON.parse(JSON.stringify(libraryItems));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks if library item does not exist already in current library
|
||||||
|
*/
|
||||||
|
const isUniqueItem = (
|
||||||
|
existingLibraryItems: LibraryItems,
|
||||||
|
targetLibraryItem: LibraryItem,
|
||||||
|
) => {
|
||||||
|
return !existingLibraryItems.find((libraryItem) => {
|
||||||
|
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// detect z-index difference by checking the excalidraw elements
|
||||||
|
// are in order
|
||||||
|
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||||
|
return (
|
||||||
|
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||||
|
libItemExcalidrawItem.versionNonce ===
|
||||||
|
targetLibraryItem.elements[idx].versionNonce
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
private libraryCache: LibraryItems | null = null;
|
/** cache for currently active promise when initializing/updating libaries
|
||||||
|
asynchronously */
|
||||||
|
private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
||||||
|
/** last resolved libraryItems */
|
||||||
|
private lastLibraryItems: LibraryItems = [];
|
||||||
|
|
||||||
private app: App;
|
private app: App;
|
||||||
|
|
||||||
constructor(app: App) {
|
constructor(app: App) {
|
||||||
@@ -13,107 +54,92 @@ class Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetLibrary = async () => {
|
resetLibrary = async () => {
|
||||||
await this.app.props.onLibraryChange?.([]);
|
this.saveLibrary([]);
|
||||||
this.libraryCache = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
|
||||||
const elements = getNonDeletedElements(
|
|
||||||
restoreElements(libraryItem.elements, null),
|
|
||||||
);
|
|
||||||
return elements.length ? { ...libraryItem, elements } : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** imports library (currently merges, removing duplicates) */
|
/** imports library (currently merges, removing duplicates) */
|
||||||
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
async importLibrary(
|
||||||
const libraryFile = await loadLibraryFromBlob(blob);
|
library:
|
||||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
| Blob
|
||||||
return;
|
| Required<ImportedDataState>["libraryItems"]
|
||||||
}
|
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
||||||
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
|
) {
|
||||||
|
return this.saveLibrary(
|
||||||
|
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let libraryItems: LibraryItems;
|
||||||
|
if (library instanceof Blob) {
|
||||||
|
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
||||||
|
} else {
|
||||||
|
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
const existingLibraryItems = this.lastLibraryItems;
|
||||||
* checks if library item does not exist already in current library
|
|
||||||
*/
|
const filteredItems = [];
|
||||||
const isUniqueitem = (
|
for (const item of libraryItems) {
|
||||||
existingLibraryItems: LibraryItems,
|
if (isUniqueItem(existingLibraryItems, item)) {
|
||||||
targetLibraryItem: LibraryItem,
|
filteredItems.push(item);
|
||||||
) => {
|
}
|
||||||
return !existingLibraryItems.find((libraryItem) => {
|
}
|
||||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
|
||||||
return false;
|
resolve([...filteredItems, ...existingLibraryItems]);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(t("errors.importLibraryError")));
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
// detect z-index difference by checking the excalidraw elements
|
|
||||||
// are in order
|
|
||||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
|
||||||
return (
|
|
||||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
|
||||||
libItemExcalidrawItem.versionNonce ===
|
|
||||||
targetLibraryItem.elements[idx].versionNonce
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingLibraryItems = await this.loadLibrary();
|
|
||||||
|
|
||||||
const library = libraryFile.libraryItems || libraryFile.library || [];
|
|
||||||
const restoredLibItems = restoreLibraryItems(
|
|
||||||
library,
|
|
||||||
defaultStatus as "published" | "unpublished",
|
|
||||||
);
|
);
|
||||||
const filteredItems = [];
|
|
||||||
for (const item of restoredLibItems) {
|
|
||||||
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
|
|
||||||
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
|
|
||||||
filteredItems.push(restoredItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadLibrary = (): Promise<LibraryItems> => {
|
loadLibrary = (): Promise<LibraryItems> => {
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
if (this.libraryCache) {
|
|
||||||
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const libraryItems = this.app.libraryItemsFromStorage;
|
resolve(
|
||||||
if (!libraryItems) {
|
cloneLibraryItems(
|
||||||
return resolve([]);
|
await (this.libraryItemsPromise || this.lastLibraryItems),
|
||||||
}
|
),
|
||||||
|
);
|
||||||
const items = libraryItems.reduce((acc, item) => {
|
} catch (error) {
|
||||||
const restoredItem = this.restoreLibraryItem(item);
|
return resolve(this.lastLibraryItems);
|
||||||
if (restoredItem) {
|
|
||||||
acc.push(item);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [] as Mutable<LibraryItems>);
|
|
||||||
|
|
||||||
// clone to ensure we don't mutate the cached library elements in the app
|
|
||||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
|
||||||
|
|
||||||
resolve(items);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
resolve([]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
saveLibrary = async (items: LibraryItems) => {
|
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
||||||
const prevLibraryItems = this.libraryCache;
|
const prevLibraryItems = this.lastLibraryItems;
|
||||||
try {
|
try {
|
||||||
const serializedItems = JSON.stringify(items);
|
let nextLibraryItems;
|
||||||
// cache optimistically so that the app has access to the latest
|
if (isPromiseLike(items)) {
|
||||||
// immediately
|
const promise = items.then((items) => cloneLibraryItems(items));
|
||||||
this.libraryCache = JSON.parse(serializedItems);
|
this.libraryItemsPromise = promise;
|
||||||
await this.app.props.onLibraryChange?.(items);
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loading",
|
||||||
|
promise,
|
||||||
|
libraryItems: null,
|
||||||
|
});
|
||||||
|
nextLibraryItems = await promise;
|
||||||
|
} else {
|
||||||
|
nextLibraryItems = cloneLibraryItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastLibraryItems = nextLibraryItems;
|
||||||
|
this.libraryItemsPromise = null;
|
||||||
|
|
||||||
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loaded",
|
||||||
|
libraryItems: nextLibraryItems,
|
||||||
|
});
|
||||||
|
await this.app.props.onLibraryChange?.(
|
||||||
|
cloneLibraryItems(nextLibraryItems),
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.libraryCache = prevLibraryItems;
|
this.lastLibraryItems = prevLibraryItems;
|
||||||
|
this.libraryItemsPromise = null;
|
||||||
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loaded",
|
||||||
|
libraryItems: prevLibraryItems,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+55
-20
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { ImportedDataState } from "./types";
|
import { ImportedDataState } from "./types";
|
||||||
import {
|
import {
|
||||||
getElementMap,
|
getNonDeletedElements,
|
||||||
getNormalizedDimensions,
|
getNormalizedDimensions,
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
@@ -26,15 +26,17 @@ import {
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
|
import { getUpdatedTimestamp } from "../utils";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const AllowedExcalidrawElementTypes: Record<
|
export const AllowedExcalidrawActiveTools: Record<
|
||||||
ExcalidrawElement["type"],
|
AppState["activeTool"]["type"],
|
||||||
true
|
boolean
|
||||||
> = {
|
> = {
|
||||||
selection: true,
|
selection: true,
|
||||||
text: true,
|
text: true,
|
||||||
@@ -45,6 +47,7 @@ export const AllowedExcalidrawElementTypes: Record<
|
|||||||
image: true,
|
image: true,
|
||||||
arrow: true,
|
arrow: true,
|
||||||
freedraw: true,
|
freedraw: true,
|
||||||
|
eraser: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
@@ -66,7 +69,10 @@ const restoreElementWithProperties = <
|
|||||||
T extends ExcalidrawElement,
|
T extends ExcalidrawElement,
|
||||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||||
>(
|
>(
|
||||||
element: Required<T>,
|
element: Required<T> & {
|
||||||
|
/** @deprecated */
|
||||||
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
|
},
|
||||||
extra: Pick<
|
extra: Pick<
|
||||||
T,
|
T,
|
||||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||||
@@ -100,7 +106,12 @@ const restoreElementWithProperties = <
|
|||||||
strokeSharpness:
|
strokeSharpness:
|
||||||
element.strokeSharpness ??
|
element.strokeSharpness ??
|
||||||
(isLinearElementType(element.type) ? "round" : "sharp"),
|
(isLinearElementType(element.type) ? "round" : "sharp"),
|
||||||
boundElementIds: element.boundElementIds ?? [],
|
boundElements: element.boundElementIds
|
||||||
|
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
|
||||||
|
: element.boundElements ?? [],
|
||||||
|
updated: element.updated ?? getUpdatedTimestamp(),
|
||||||
|
link: element.link ?? null,
|
||||||
|
locked: element.locked ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -131,6 +142,8 @@ const restoreElement = (
|
|||||||
baseline: element.baseline,
|
baseline: element.baseline,
|
||||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||||
|
containerId: element.containerId ?? null,
|
||||||
|
originalText: element.originalText || element.text,
|
||||||
});
|
});
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
return restoreElementWithProperties(element, {
|
return restoreElementWithProperties(element, {
|
||||||
@@ -204,14 +217,14 @@ export const restoreElements = (
|
|||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const localElementsMap = localElements ? getElementMap(localElements) : null;
|
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||||
return (elements || []).reduce((elements, element) => {
|
return (elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.[element.id];
|
const localElement = localElementsMap?.get(element.id);
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
migratedElement = bumpVersion(migratedElement, localElement.version);
|
||||||
}
|
}
|
||||||
@@ -227,10 +240,8 @@ export const restoreAppState = (
|
|||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null | undefined,
|
||||||
): RestoredAppState => {
|
): RestoredAppState => {
|
||||||
appState = appState || {};
|
appState = appState || {};
|
||||||
|
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
const nextAppState = {} as typeof defaultAppState;
|
const nextAppState = {} as typeof defaultAppState;
|
||||||
|
|
||||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||||
keyof typeof defaultAppState,
|
keyof typeof defaultAppState,
|
||||||
any,
|
any,
|
||||||
@@ -244,25 +255,32 @@ export const restoreAppState = (
|
|||||||
? localValue
|
? localValue
|
||||||
: defaultValue;
|
: defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
|
cursorButton: localAppState?.cursorButton || "up",
|
||||||
? nextAppState.elementType
|
// reset on fresh restore so as to hide the UI button if penMode not active
|
||||||
: "selection",
|
penDetected:
|
||||||
|
localAppState?.penDetected ??
|
||||||
|
(appState.penMode ? appState.penDetected ?? false : false),
|
||||||
|
activeTool: {
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
locked: nextAppState.activeTool.locked ?? false,
|
||||||
|
type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
|
||||||
|
? nextAppState.activeTool.type ?? "selection"
|
||||||
|
: "selection",
|
||||||
|
},
|
||||||
// Migrates from previous version where appState.zoom was a number
|
// Migrates from previous version where appState.zoom was a number
|
||||||
zoom:
|
zoom:
|
||||||
typeof appState.zoom === "number"
|
typeof appState.zoom === "number"
|
||||||
? {
|
? {
|
||||||
value: appState.zoom as NormalizedZoomValue,
|
value: appState.zoom as NormalizedZoomValue,
|
||||||
translation: defaultAppState.zoom.translation,
|
|
||||||
}
|
}
|
||||||
: appState.zoom || defaultAppState.zoom,
|
: appState.zoom || defaultAppState.zoom,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restore = (
|
export const restore = (
|
||||||
data: ImportedDataState | null,
|
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
|
||||||
/**
|
/**
|
||||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
* Local AppState (`this.state` or initial state from localStorage) so that we
|
||||||
* don't overwrite local state with default values (when values not
|
* don't overwrite local state with default values (when values not
|
||||||
@@ -279,28 +297,45 @@ export const restore = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
||||||
|
const elements = restoreElements(
|
||||||
|
getNonDeletedElements(libraryItem.elements),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return elements.length ? { ...libraryItem, elements } : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const restoreLibraryItems = (
|
export const restoreLibraryItems = (
|
||||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
libraryItems: ImportedDataState["libraryItems"] = [],
|
||||||
defaultStatus: LibraryItem["status"],
|
defaultStatus: LibraryItem["status"],
|
||||||
) => {
|
) => {
|
||||||
const restoredItems: LibraryItem[] = [];
|
const restoredItems: LibraryItem[] = [];
|
||||||
for (const item of libraryItems) {
|
for (const item of libraryItems) {
|
||||||
// migrate older libraries
|
// migrate older libraries
|
||||||
if (Array.isArray(item)) {
|
if (Array.isArray(item)) {
|
||||||
restoredItems.push({
|
const restoredItem = restoreLibraryItem({
|
||||||
status: defaultStatus,
|
status: defaultStatus,
|
||||||
elements: item,
|
elements: item,
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
});
|
});
|
||||||
|
if (restoredItem) {
|
||||||
|
restoredItems.push(restoredItem);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
|
const _item = item as MarkOptional<
|
||||||
restoredItems.push({
|
LibraryItem,
|
||||||
|
"id" | "status" | "created"
|
||||||
|
>;
|
||||||
|
const restoredItem = restoreLibraryItem({
|
||||||
..._item,
|
..._item,
|
||||||
id: _item.id || randomId(),
|
id: _item.id || randomId(),
|
||||||
status: _item.status || defaultStatus,
|
status: _item.status || defaultStatus,
|
||||||
created: _item.created || Date.now(),
|
created: _item.created || Date.now(),
|
||||||
});
|
});
|
||||||
|
if (restoredItem) {
|
||||||
|
restoredItems.push(restoredItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return restoredItems;
|
return restoredItems;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user