Compare commits
255 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c83a322b6 | |||
| 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 | |||
| b6ef953dc9 | |||
| 620b662085 | |||
| 1c11df011a | |||
| 59e9651547 | |||
| 1c48d122e0 | |||
| e4d02fb275 | |||
| 34a382ace9 | |||
| e60e48e67d | |||
| 84d1d9993c | |||
| 3ff9744b39 | |||
| b9abcc825a | |||
| 9679eaf74c | |||
| 284747d742 | |||
| 876f85fd7a | |||
| efc2bbed21 | |||
| 61d193b87b | |||
| 3989d6a989 | |||
| f6559b65ef | |||
| bc6b066c07 | |||
| 6370d517a2 | |||
| b8a37c42e4 | |||
| 76763b80a9 | |||
| d2a2c9d6b5 | |||
| 3a72f347d2 | |||
| c1d9456235 | |||
| c4f8b98208 | |||
| b6eb57d3f1 | |||
| 473b8ca0ca | |||
| 45206c4ef1 | |||
| 56b4a29aaa | |||
| bb4dda64b5 | |||
| 39e53b4ae7 | |||
| 6143d5195a | |||
| f59e608f18 | |||
| 6b24592e4a | |||
| 7b442997dc | |||
| 4bfc5bbcaa | |||
| 2b29b9a96d | |||
| cc201a6d80 | |||
| 5be58b59e0 | |||
| f1eb969565 | |||
| 8d4f455cd3 | |||
| 60262cb4cc | |||
| 7501c24f22 | |||
| 00d81aa982 | |||
| 67fe156d06 | |||
| ef433233d1 | |||
| 1c7056bdaa | |||
| 277ffaacb9 | |||
| 2a3e242cfd | |||
| b1c6051d6b | |||
| 8df9742463 | |||
| 9fdc382d71 | |||
| f70d11c2d1 | |||
| 05e54d6785 | |||
| 795a6e4546 | |||
| a01a4ad739 | |||
| e09b96ac6f | |||
| d48fb17718 | |||
| ede3c4af82 | |||
| 8bcfd97fc5 | |||
| 5721c6dfb5 | |||
| 9b1f77c3be | |||
| 3369035f40 | |||
| dbc7a8599b | |||
| 09f649daf7 | |||
| d357664850 | |||
| f973fdfa89 | |||
| c15bc50f17 | |||
| c2d0107cc5 | |||
| c43fac31a1 | |||
| 9dfaf1752b | |||
| d9a1eb2f01 | |||
| f1e17a320f | |||
| 75ecd818b3 | |||
| a7abc71f6a | |||
| 6d0f0c8f21 | |||
| 790e6da500 | |||
| 8df1a11535 | |||
| b61ee56dc8 | |||
| c61f95a327 | |||
| d6d629f416 | |||
| 65dec605f2 | |||
| cacec0b5c4 | |||
| 87a302d7e9 | |||
| 899b36c206 | |||
| 534cbef982 | |||
| b7f118404e | |||
| aab5067718 | |||
| b679da02ee | |||
| ec652820ea | |||
| 5d941ed107 | |||
| adc478ca34 | |||
| f1202adb15 | |||
| fd439cf38a | |||
| 83c63be846 | |||
| b59d49dd7f | |||
| 0116b70edf | |||
| 3f390d4858 | |||
| fdde73bff4 | |||
| 90a416e265 | |||
| a828b2e5de | |||
| 7c51d3c24c | |||
| 4d2d6f181a | |||
| 071416f6ef | |||
| d675b07089 | |||
| 3975fd592a | |||
| 34a9a4dac6 | |||
| 78e419b790 | |||
| 8d8769ba4e | |||
| d89fb3371b | |||
| 8410972cff | |||
| 2c8d041987 | |||
| 94519c8250 | |||
| add8a1b1a7 | |||
| 516e7656f3 | |||
| d7cdee37bf | |||
| 5c5b8c517f | |||
| 7dbd0c5e0a | |||
| ba35eb8f8c | |||
| 163ad1f4c4 | |||
| 0f0244224d | |||
| 6eecadce60 | |||
| bc88cf5002 | |||
| 571be9c0fe | |||
| 5d925c7d3f | |||
| 45c520341f | |||
| c6ffc06541 | |||
| ff29780760 | |||
| 463857ad9a | |||
| be2da9539e | |||
| bb7829ef90 | |||
| 1104f6891e | |||
| a97e172070 | |||
| 39d45afc06 | |||
| 00c6940851 | |||
| 982cba2035 | |||
| 54739cd2df | |||
| 75aeaa6c38 | |||
| bea4a1e066 | |||
| e8b462cc31 | |||
| c86c176e10 | |||
| b09c11bb14 | |||
| 7199d13f48 | |||
| 7d1fddc144 | |||
| 5da3207633 | |||
| 8c9786e026 | |||
| f0f13ed694 | |||
| 850d8eb47e | |||
| f287f9c002 | |||
| 78df5bc852 | |||
| f0073c7e26 | |||
| fa7a313412 | |||
| 8b3f236cd8 | |||
| 621812d0eb | |||
| d607249205 | |||
| df28c3299f | |||
| b00a57b4be | |||
| 9277e839db | |||
| 0d5d60944f | |||
| 489a652d73 | |||
| 2b85d96121 | |||
| 6ce535d3a4 | |||
| da43cf5635 | |||
| 603ecfba34 | |||
| a589708737 | |||
| 4df401d012 | |||
| b2c4552416 | |||
| 5cae218f1b | |||
| 4be726d405 | |||
| 99623334d1 | |||
| 685abac81a | |||
| 9581c45522 | |||
| 0749d2c1f3 | |||
| 8787f3dc60 | |||
| 5fabc57277 | |||
| e7cbb859f0 | |||
| aa860251c7 | |||
| 380aaa30e6 | |||
| 2e61fec7a6 | |||
| 3c295559c7 | |||
| 55d3287abf | |||
| e3e967421e | |||
| 77aae63006 | |||
| ee64a7e264 | |||
| 097362662d | |||
| 038e9c13dd | |||
| bc8ba08ad0 | |||
| f861a9fdd0 | |||
| 62303b8a22 | |||
| 9cc741ab3a | |||
| 2d279cbb02 | |||
| 57ea4fdf9a | |||
| 44402f42bf | |||
| bdead4d164 | |||
| bfc0656475 | |||
| a33a3334f7 | |||
| 969d3c694a | |||
| 5cd921549a | |||
| 437afcbea4 |
@@ -1,5 +0,0 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
|
||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
|
||||||
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"}'
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
|
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_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
|
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
|
||||||
|
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"}'
|
||||||
@@ -1 +1,11 @@
|
|||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
|
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_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
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ package-lock.json
|
|||||||
firebase/
|
firebase/
|
||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
|
src/packages/excalidraw/types
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off"
|
"import/no-anonymous-default-export": "off",
|
||||||
|
"no-restricted-globals": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ updates:
|
|||||||
- lipis
|
- lipis
|
||||||
assignees:
|
assignees:
|
||||||
- lipis
|
- lipis
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/excalidraw/
|
directory: /src/packages/excalidraw/
|
||||||
@@ -21,6 +22,7 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/utils/
|
directory: /src/packages/utils/
|
||||||
@@ -32,3 +34,4 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: docker/build-push-action@v1
|
- uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.history
|
||||||
.idea
|
.idea
|
||||||
.vercel
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
|
.yarn
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
build
|
build
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
yarn lint-staged
|
||||||
+1
-1
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.17-alpine
|
FROM nginx:1.21-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ The first set of digits is the room. This is visible from the server that’s go
|
|||||||
|
|
||||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||||
|
|
||||||
|
> Note: Please ensure that the encryption key is 22 characters long.
|
||||||
|
|
||||||
## Shape libraries
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
@@ -93,7 +95,7 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
- [Node.js](https://nodejs.org/en/)
|
||||||
- [Yarn](https://yarnpkg.com/getting-started/install)
|
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
#### Clone the repo
|
#### Clone the repo
|
||||||
|
|||||||
+31
-24
@@ -21,21 +21,26 @@
|
|||||||
"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.11.10",
|
"@testing-library/jest-dom": "5.15.1",
|
||||||
"@testing-library/react": "11.2.6",
|
"@testing-library/react": "12.1.2",
|
||||||
"@types/jest": "26.0.22",
|
"@tldraw/vec": "1.1.5",
|
||||||
"@types/react": "17.0.3",
|
"@types/jest": "27.0.3",
|
||||||
"@types/react-dom": "17.0.3",
|
"@types/pica": "5.1.3",
|
||||||
|
"@types/react": "17.0.37",
|
||||||
|
"@types/react-dom": "17.0.11",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.16.4",
|
"browser-fs-access": "0.23.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
|
"fake-indexeddb": "3.1.7",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.0",
|
"i18next-browser-languagedetector": "6.1.2",
|
||||||
|
"idb-keyval": "6.0.3",
|
||||||
|
"image-blob-reduce": "3.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.22",
|
"nanoid": "3.1.30",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "0.4.7",
|
"perfect-freehand": "1.0.16",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
@@ -44,36 +49,37 @@
|
|||||||
"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.4.1",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.32.10",
|
"sass": "1.43.5",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.2.4"
|
"typescript": "4.5.2"
|
||||||
},
|
},
|
||||||
"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/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.2",
|
||||||
"@types/resize-observer-browser": "0.1.5",
|
"@types/resize-observer-browser": "0.1.6",
|
||||||
|
"chai": "4.3.4",
|
||||||
|
"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.9.0",
|
"firebase-tools": "9.23.0",
|
||||||
"husky": "4.3.8",
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "10.5.4",
|
"lint-staged": "12.1.2",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.5.0",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@typescript-eslint/typescript-estree": "5.3.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||||
@@ -95,6 +101,7 @@
|
|||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capture_links": "new_client",
|
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ 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`);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,9 +31,11 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|||||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||||
|
|
||||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
return (
|
||||||
|
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
||||||
|
!filesToIgnoreRegex.test(file)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!excalidrawPackageFiles.length) {
|
if (!excalidrawPackageFiles.length) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,5 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|||||||
// update readme
|
// update readme
|
||||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
|
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
const { readdirSync, writeFileSync } = require("fs");
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||||
|
|
||||||
const flatten = (object) =>
|
const flatten = (object = {}, result = {}, extraKey = "") => {
|
||||||
Object.keys(object).reduce(
|
for (const key in object) {
|
||||||
(initial, current) => ({ ...initial, ...object[current] }),
|
if (typeof object[key] !== "object") {
|
||||||
{},
|
result[extraKey + key] = object[key];
|
||||||
);
|
} else {
|
||||||
|
flatten(object[key], result, `${extraKey}${key}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const locales = files.filter(
|
const locales = files.filter(
|
||||||
(file) => file !== "README.md" && file !== "percentages.json",
|
(file) => file !== "README.md" && file !== "percentages.json",
|
||||||
@@ -19,10 +24,8 @@ for (let index = 0; index < locales.length; index++) {
|
|||||||
|
|
||||||
const allKeys = Object.keys(data);
|
const allKeys = Object.keys(data);
|
||||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||||
|
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
|
||||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
percentages[currentLocale.replace(".json", "")] = percentage;
|
||||||
|
|
||||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ const THRESSHOLD = 85;
|
|||||||
const crowdinMap = {
|
const crowdinMap = {
|
||||||
"ar-SA": "en-ar",
|
"ar-SA": "en-ar",
|
||||||
"bg-BG": "en-bg",
|
"bg-BG": "en-bg",
|
||||||
|
"bn-BD": "en-bn",
|
||||||
"ca-ES": "en-ca",
|
"ca-ES": "en-ca",
|
||||||
|
"da-DK": "en-da",
|
||||||
"de-DE": "en-de",
|
"de-DE": "en-de",
|
||||||
"el-GR": "en-el",
|
"el-GR": "en-el",
|
||||||
"es-ES": "en-es",
|
"es-ES": "en-es",
|
||||||
@@ -31,20 +33,27 @@ const crowdinMap = {
|
|||||||
"pt-PT": "en-pt",
|
"pt-PT": "en-pt",
|
||||||
"ro-RO": "en-ro",
|
"ro-RO": "en-ro",
|
||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
|
"si-LK": "en-silk",
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
|
"ta-IN": "en-ta",
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
"uk-UA": "en-uk",
|
"uk-UA": "en-uk",
|
||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-HK": "en-zhhk",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "cs-cz",
|
"cs-CZ": "en-cs",
|
||||||
|
"kk-KZ": "en-kk",
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
"ar-SA": "🇸🇦",
|
"ar-SA": "🇸🇦",
|
||||||
"bg-BG": "🇧🇬",
|
"bg-BG": "🇧🇬",
|
||||||
|
"bn-BD": "🇧🇩",
|
||||||
"ca-ES": "🏳",
|
"ca-ES": "🏳",
|
||||||
|
"cs-CZ": "🇨🇿",
|
||||||
|
"da-DK": "🇩🇰",
|
||||||
"de-DE": "🇩🇪",
|
"de-DE": "🇩🇪",
|
||||||
"el-GR": "🇬🇷",
|
"el-GR": "🇬🇷",
|
||||||
"es-ES": "🇪🇸",
|
"es-ES": "🇪🇸",
|
||||||
@@ -58,7 +67,9 @@ const flags = {
|
|||||||
"it-IT": "🇮🇹",
|
"it-IT": "🇮🇹",
|
||||||
"ja-JP": "🇯🇵",
|
"ja-JP": "🇯🇵",
|
||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
|
"kk-KZ": "🇰🇿",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
|
"lv-LV": "🇱🇻",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
"nl-NL": "🇳🇱",
|
"nl-NL": "🇳🇱",
|
||||||
@@ -70,20 +81,24 @@ const flags = {
|
|||||||
"pt-PT": "🇵🇹",
|
"pt-PT": "🇵🇹",
|
||||||
"ro-RO": "🇷🇴",
|
"ro-RO": "🇷🇴",
|
||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
|
"si-LK": "🇱🇰",
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
|
"ta-IN": "🇮🇳",
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
"uk-UA": "🇺🇦",
|
"uk-UA": "🇺🇦",
|
||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-HK": "🇭🇰",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
"lv-LV": "🇱🇻",
|
|
||||||
"cs-CZ": "🇨🇿",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
"ar-SA": "العربية",
|
"ar-SA": "العربية",
|
||||||
"bg-BG": "Български",
|
"bg-BG": "Български",
|
||||||
|
"bn-BD": "Bengali",
|
||||||
"ca-ES": "Català",
|
"ca-ES": "Català",
|
||||||
|
"cs-CZ": "Česky",
|
||||||
|
"da-DK": "Dansk",
|
||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
@@ -97,7 +112,9 @@ const languages = {
|
|||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
"kab-KAB": "Taqbaylit",
|
"kab-KAB": "Taqbaylit",
|
||||||
|
"kk-KZ": "Қазақ тілі",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
|
"lv-LV": "Latviešu",
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
"nl-NL": "Nederlands",
|
"nl-NL": "Nederlands",
|
||||||
@@ -109,14 +126,15 @@ const languages = {
|
|||||||
"pt-PT": "Português",
|
"pt-PT": "Português",
|
||||||
"ro-RO": "Română",
|
"ro-RO": "Română",
|
||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
|
"si-LK": "සිංහල",
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
|
"ta-IN": "Tamil",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
|
"zh-HK": "繁體中文 (香港)",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
"lv-LV": "Latviešu",
|
|
||||||
"cs-CZ": "Česky",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const util = require("util");
|
||||||
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
const updateReadme = require("./updateReadme");
|
||||||
|
const updateChangelog = require("./updateChangelog");
|
||||||
|
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
|
||||||
|
const updatePackageVersion = (nextVersion) => {
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
pkg.version = nextVersion;
|
||||||
|
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||||
|
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = async (nextVersion) => {
|
||||||
|
try {
|
||||||
|
updateReadme();
|
||||||
|
await updateChangelog(nextVersion);
|
||||||
|
updatePackageVersion(nextVersion);
|
||||||
|
await exec(`git add -u`);
|
||||||
|
await exec(
|
||||||
|
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||||
|
);
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.log("Done!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextVersion = process.argv.slice(2)[0];
|
||||||
|
if (!nextVersion) {
|
||||||
|
console.error("Pass the next version to release!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
release(nextVersion);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const util = require("util");
|
||||||
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
const lastVersion = pkg.version;
|
||||||
|
const existingChangeLog = fs.readFileSync(
|
||||||
|
`${excalidrawDir}/CHANGELOG.md`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
||||||
|
const headerForType = {
|
||||||
|
feat: "Features",
|
||||||
|
fix: "Fixes",
|
||||||
|
style: "Styles",
|
||||||
|
refactor: " Refactor",
|
||||||
|
perf: "Performance",
|
||||||
|
build: "Build",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommitHashForLastVersion = async () => {
|
||||||
|
try {
|
||||||
|
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||||
|
const { stdout } = await exec(
|
||||||
|
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLibraryCommitsSinceLastRelease = async () => {
|
||||||
|
const commitHash = await getCommitHashForLastVersion();
|
||||||
|
const { stdout } = await exec(
|
||||||
|
`git log --pretty=format:%s ${commitHash}...master`,
|
||||||
|
);
|
||||||
|
const commitsSinceLastRelease = stdout.split("\n");
|
||||||
|
const commitList = {};
|
||||||
|
supportedTypes.forEach((type) => {
|
||||||
|
commitList[type] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
commitsSinceLastRelease.forEach((commit) => {
|
||||||
|
const indexOfColon = commit.indexOf(":");
|
||||||
|
const type = commit.slice(0, indexOfColon);
|
||||||
|
if (!supportedTypes.includes(type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||||
|
const messageWithCapitalizeFirst =
|
||||||
|
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||||
|
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
||||||
|
|
||||||
|
// return if the changelog already contains the pr number which would happen for package updates
|
||||||
|
if (existingChangeLog.includes(prNumber)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||||
|
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||||
|
/\(#[0-9]*\)/,
|
||||||
|
prMarkdown,
|
||||||
|
);
|
||||||
|
commitList[type].push(messageWithPRLink);
|
||||||
|
});
|
||||||
|
return commitList;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChangelog = async (nextVersion) => {
|
||||||
|
const commitList = await getLibraryCommitsSinceLastRelease();
|
||||||
|
let changelogForLibrary =
|
||||||
|
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
||||||
|
supportedTypes.forEach((type) => {
|
||||||
|
if (commitList[type].length) {
|
||||||
|
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
||||||
|
const commits = commitList[type];
|
||||||
|
commits.forEach((commit) => {
|
||||||
|
changelogForLibrary += `- ${commit}\n\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
changelogForLibrary += "---\n";
|
||||||
|
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
||||||
|
let updatedContent =
|
||||||
|
existingChangeLog.slice(0, lastVersionIndex) +
|
||||||
|
changelogForLibrary +
|
||||||
|
existingChangeLog.slice(lastVersionIndex);
|
||||||
|
const currentDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const newVersion = `## ${nextVersion} (${currentDate})`;
|
||||||
|
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = updateChangelog;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const updateReadme = () => {
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||||
|
|
||||||
|
// remove note for unstable release
|
||||||
|
data = data.replace(
|
||||||
|
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
// replace "excalidraw-next" with "excalidraw"
|
||||||
|
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||||
|
data = data.trim();
|
||||||
|
|
||||||
|
const demoIndex = data.indexOf("### Demo");
|
||||||
|
const excalidrawNextNote =
|
||||||
|
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||||
|
// Add excalidraw next note to try out for unreleased changes
|
||||||
|
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = updateReadme;
|
||||||
@@ -2,6 +2,8 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@@ -9,15 +11,49 @@ export const actionAddToLibrary = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
if (selectedElements.some((element) => element.type === "image")) {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.library.loadLibrary().then((items) => {
|
return app.library
|
||||||
app.library.saveLibrary([
|
.loadLibrary()
|
||||||
|
.then((items) => {
|
||||||
|
return app.library.saveLibrary([
|
||||||
|
{
|
||||||
|
id: randomId(),
|
||||||
|
status: "unpublished",
|
||||||
|
elements: selectedElements.map(deepCopyElement),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
...items,
|
...items,
|
||||||
selectedElements.map(deepCopyElement),
|
|
||||||
]);
|
]);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
toastMessage: t("toast.addedToLibrary"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { alignElements, Alignment } from "../align";
|
import { alignElements, Alignment } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
@@ -9,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 = (
|
||||||
@@ -35,9 +34,11 @@ 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({
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import React from "react";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
import { zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { ZOOM_STEP } from "../constants";
|
import { THEME, ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
|
||||||
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";
|
||||||
@@ -17,6 +13,10 @@ import { getNewZoom } 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 { newElementWith } from "../element/mutateElement";
|
||||||
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import ClearCanvas from "../components/ClearCanvas";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@@ -47,13 +47,15 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
|
app.imageCache.clear();
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
|
files: {},
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
elementLocked: appState.elementLocked,
|
elementLocked: appState.elementLocked,
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
@@ -65,21 +67,8 @@ export const actionClearCanvas = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<ToolButton
|
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||||
type="button"
|
|
||||||
icon={trash}
|
|
||||||
title={t("buttons.clearReset")}
|
|
||||||
aria-label={t("buttons.clearReset")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
|
||||||
updateData(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-testid="clear-canvas-button"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
@@ -108,6 +97,7 @@ export const actionZoomIn = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -142,6 +132,7 @@ export const actionZoomOut = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -168,16 +159,21 @@ export const actionResetZoom = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
|
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={resetZoom}
|
className="reset-zoom-button"
|
||||||
title={t("buttons.resetZoom")}
|
title={t("buttons.resetZoom")}
|
||||||
aria-label={t("buttons.resetZoom")}
|
aria-label={t("buttons.resetZoom")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
/>
|
size="small"
|
||||||
|
>
|
||||||
|
{(appState.zoom.value * 100).toFixed(0)}%
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
@@ -271,7 +267,8 @@ export const actionToggleTheme = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
theme: value || (appState.theme === "light" ? "dark" : "light"),
|
theme:
|
||||||
|
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -42,6 +42,7 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -50,12 +51,13 @@ export const actionCopyAsSvg = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -80,6 +82,7 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -88,6 +91,7 @@ export const actionCopyAsPng = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -104,7 +108,7 @@ export const actionCopyAsPng = register({
|
|||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import React from "react";
|
|
||||||
import { trash } from "../components/icons";
|
import { trash } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -12,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[],
|
||||||
@@ -22,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: {
|
||||||
@@ -56,7 +62,7 @@ export const actionDeleteSelected = register({
|
|||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
activePointIndex,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
@@ -66,8 +72,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
|
||||||
) {
|
) {
|
||||||
@@ -87,15 +92,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(
|
||||||
|
element.points.length - 1,
|
||||||
|
)
|
||||||
? null
|
? null
|
||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -104,17 +111,17 @@ 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 {
|
deleteSelectedElements(elements, appState);
|
||||||
elements: nextElements,
|
|
||||||
appState: nextAppState,
|
|
||||||
} = deleteSelectedElements(elements, appState);
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} 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 } 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 = (
|
||||||
@@ -31,9 +30,11 @@ 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({
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React from "react";
|
|
||||||
import { KEYS } from "../keys";
|
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,
|
||||||
@@ -19,41 +17,23 @@ 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",
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,9 +87,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
|
||||||
@@ -131,7 +114,11 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
bindTextToShapeAfterDuplication(
|
||||||
|
finalElements,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -141,7 +128,9 @@ const duplicateElements = (
|
|||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce((acc, element) => {
|
||||||
|
if (!isBoundToContainer(element)) {
|
||||||
acc[element.id] = true;
|
acc[element.id] = true;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import React from "react";
|
|
||||||
import { trackEvent } from "../analytics";
|
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";
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
|
import { resaveAsImageWithScene } from "../data/resave";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { supported as fsSupported } from "browser-fs-access";
|
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getExportSize } from "../scene/export";
|
||||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ActiveFile } from "../components/ActiveFile";
|
import { ActiveFile } from "../components/ActiveFile";
|
||||||
|
import { isImageFileHandle } from "../data/blob";
|
||||||
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
@@ -68,7 +70,7 @@ export const actionChangeExportScale = register({
|
|||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
key={s}
|
key={s}
|
||||||
size="s"
|
size="small"
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={`${s}x`}
|
icon={`${s}x`}
|
||||||
name="export-canvas-scale"
|
name="export-canvas-scale"
|
||||||
@@ -118,7 +120,7 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
>
|
>
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||||
<div className="Tooltip-icon">{questionCircle}</div>
|
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CheckboxItem>
|
</CheckboxItem>
|
||||||
),
|
),
|
||||||
@@ -126,17 +128,21 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
|
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||||
|
: await saveAsJSON(elements, appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toastMessage: fileHandleExists
|
||||||
? fileHandle.name
|
? fileHandle?.name
|
||||||
? t("toast.fileSavedToFilename").replace(
|
? t("toast.fileSavedToFilename").replace(
|
||||||
"{filename}",
|
"{filename}",
|
||||||
`"${fileHandle.name}"`,
|
`"${fileHandle.name}"`,
|
||||||
@@ -145,9 +151,11 @@ export const actionSaveToActiveFile = register({
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -164,16 +172,22 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
const { fileHandle } = await saveAsJSON(
|
||||||
|
elements,
|
||||||
|
{
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
});
|
},
|
||||||
|
app.files,
|
||||||
|
);
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -187,7 +201,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
hidden={!fsSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
/>
|
/>
|
||||||
@@ -196,24 +210,28 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: async (elements, appState) => {
|
perform: async (elements, appState, _, app) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
} = await loadFromJSON(appState);
|
files,
|
||||||
|
} = await loadFromJSON(appState, elements);
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
|
files,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
|
console.warn(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
|
files: app.files,
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -250,9 +268,9 @@ export const actionExportWithDarkMode = register({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||||
onChange={(theme: Appearence) => {
|
onChange={(theme: Theme) => {
|
||||||
updateData(theme === "dark");
|
updateData(theme === THEME.DARK);
|
||||||
}}
|
}}
|
||||||
title={t("labels.toggleExportColorScheme")}
|
title={t("labels.toggleExportColorScheme")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { resetCursor } from "../utils";
|
||||||
import React from "react";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -15,16 +14,63 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement } from "../element/typeChecks";
|
||||||
|
import { ExcalidrawImageElement } from "../element/types";
|
||||||
|
import { imageFromImageData } from "../element/image";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
perform: (
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
_,
|
||||||
|
{ canvas, focusContainer, imageCache, addFiles },
|
||||||
|
) => {
|
||||||
|
if (appState.editingImageElement) {
|
||||||
|
const { elementId, imageData } = appState.editingImageElement;
|
||||||
|
const editingImageElement = elements.find((el) => el.id === elementId) as
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| undefined;
|
||||||
|
if (editingImageElement?.fileId) {
|
||||||
|
const cachedImageData = imageCache.get(editingImageElement.fileId);
|
||||||
|
if (cachedImageData) {
|
||||||
|
const { image, dataURL } = imageFromImageData(imageData);
|
||||||
|
|
||||||
|
imageCache.set(editingImageElement.fileId, {
|
||||||
|
...cachedImageData,
|
||||||
|
image,
|
||||||
|
});
|
||||||
|
|
||||||
|
addFiles([
|
||||||
|
{
|
||||||
|
id: editingImageElement.fileId,
|
||||||
|
dataURL,
|
||||||
|
mimeType: cachedImageData.mimeType,
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
elementId,
|
appState.editingLinearElement;
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -50,6 +96,11 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
|
if (appState.pendingImageElement) {
|
||||||
|
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
@@ -153,6 +204,7 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
|
pendingImageElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "freedraw",
|
commitToHistory: appState.elementType === "freedraw",
|
||||||
};
|
};
|
||||||
@@ -160,6 +212,7 @@ export const actionFinalize = register({
|
|||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.editingLinearElement !== null ||
|
||||||
|
appState.editingImageElement !== null ||
|
||||||
(!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),
|
||||||
|
|||||||
+13
-11
@@ -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[],
|
||||||
@@ -83,9 +84,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 = (
|
||||||
@@ -93,13 +96,13 @@ const flipElements = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
for (let i = 0; i < elements.length; i++) {
|
elements.forEach((element) => {
|
||||||
flipElement(elements[i], appState);
|
flipElement(element, appState);
|
||||||
// If vertical flip, rotate an extra 180
|
// If vertical flip, rotate an extra 180
|
||||||
if (flipDirection === "vertical") {
|
if (flipDirection === "vertical") {
|
||||||
rotateElement(elements[i], Math.PI);
|
rotateElement(element, Math.PI);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return elements;
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,10 +145,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);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
|
||||||
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";
|
||||||
@@ -45,6 +44,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)
|
||||||
@@ -57,6 +57,7 @@ export const actionGroup = register({
|
|||||||
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
|
||||||
@@ -84,8 +85,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, {
|
||||||
@@ -100,9 +102,8 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
const lastGroupElementIndex =
|
||||||
lastElementInGroup,
|
updatedElements.lastIndexOf(lastElementInGroup);
|
||||||
);
|
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = updatedElements
|
const elementsBeforeGroup = updatedElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import { Action, ActionResult } from "./types";
|
||||||
import React from "react";
|
|
||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -7,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[],
|
||||||
@@ -28,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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -69,12 +68,13 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={undo}
|
icon={undo}
|
||||||
aria-label={t("buttons.undo")}
|
aria-label={t("buttons.undo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
@@ -89,12 +89,13 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={redo}
|
icon={redo}
|
||||||
aria-label={t("buttons.redo")}
|
aria-label={t("buttons.redo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { backgroundIcon } from "../components/icons";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { isInitializedImageElement } from "../element/typeChecks";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
|
||||||
|
export const actionEditImageAlpha = register({
|
||||||
|
name: "editImageAlpha",
|
||||||
|
perform: async (elements, appState, _, app) => {
|
||||||
|
if (appState.editingImageElement) {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
const selectedElement = selectedElements[0];
|
||||||
|
if (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isInitializedImageElement(selectedElement)
|
||||||
|
) {
|
||||||
|
const imgData = app.imageCache.get(selectedElement.fileId);
|
||||||
|
if (!imgData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await imgData.image;
|
||||||
|
const { width, height } = image;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.width = width;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
|
||||||
|
const imageData = context.getImageData(0, 0, width, height);
|
||||||
|
|
||||||
|
Scene.mapElementToScene(selectedElement.id, app.scene);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: {
|
||||||
|
editorType: "alpha",
|
||||||
|
elementId: selectedElement.id,
|
||||||
|
origImageData: imageData,
|
||||||
|
imageData,
|
||||||
|
pointerDownState: { screenX: 0, screenY: 0, sampledPixel: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={backgroundIcon}
|
||||||
|
label="Edit Image Alpha"
|
||||||
|
className={appState.editingImageElement ? "active" : ""}
|
||||||
|
title={"Edit image alpha"}
|
||||||
|
aria-label={"Edit image alpha"}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { menu, palette } from "../components/icons";
|
import { menu, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -30,8 +29,8 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, id }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const clientId = id;
|
const clientId: string | undefined = data?.id;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
|
ArrowheadTriangleIcon,
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
@@ -104,11 +105,13 @@ export const actionChangeStrokeColor = register({
|
|||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemStrokeColor && {
|
...(value.currentItemStrokeColor && {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
newElementWith(el, {
|
return hasStrokeColor(el.type)
|
||||||
|
? newElementWith(el, {
|
||||||
strokeColor: value.currentItemStrokeColor,
|
strokeColor: value.currentItemStrokeColor,
|
||||||
|
})
|
||||||
|
: el;
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -736,6 +739,14 @@ export const actionChangeArrowhead = register({
|
|||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
@@ -778,6 +789,14 @@ export const actionChangeArrowhead = register({
|
|||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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",
|
||||||
@@ -15,7 +15,10 @@ 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)
|
||||||
|
) {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export const actionToggleViewMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,3 +80,4 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
|
|||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
export { actionEditImageAlpha } from "./actionImageEditing";
|
||||||
|
|||||||
+9
-19
@@ -5,20 +5,11 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
|
PanelComponentProps,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppProps, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { MODES } from "../constants";
|
||||||
import Library from "../data/library";
|
|
||||||
|
|
||||||
// This is the <App> component, but for now we don't care about anything but its
|
|
||||||
// `canvas` state.
|
|
||||||
type App = {
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
focusContainer: () => void;
|
|
||||||
props: AppProps;
|
|
||||||
library: Library;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@@ -27,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
app: App;
|
app: AppClassProperties;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
app: App,
|
app: AppClassProperties,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
@@ -107,11 +98,10 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Id is an attribute that we can use to pass in data like keys.
|
/**
|
||||||
// This is needed for dynamically generated action components
|
* @param data additional data sent to the PanelComponent
|
||||||
// like the user list. We can use this key to extract more
|
*/
|
||||||
// data from app state. This is an alternative to generic prop hell!
|
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||||
renderAction = (name: ActionName, id?: string) => {
|
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -139,8 +129,8 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
id={id}
|
|
||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
|
data={data}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-15
@@ -1,7 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import {
|
||||||
import Library from "../data/library";
|
AppClassProperties,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
BinaryFiles,
|
||||||
|
} from "../types";
|
||||||
|
import { ToolButtonSize } from "../components/ToolButton";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@@ -11,22 +16,18 @@ export type ActionResult =
|
|||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
|
files?: BinaryFiles | null;
|
||||||
commitToHistory: boolean;
|
commitToHistory: boolean;
|
||||||
syncHistory?: boolean;
|
syncHistory?: boolean;
|
||||||
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
type AppAPI = {
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
focusContainer(): void;
|
|
||||||
library: Library;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: AppAPI,
|
app: AppClassProperties,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
@@ -100,17 +101,20 @@ export type ActionName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme";
|
| "toggleTheme"
|
||||||
|
| "editImageAlpha";
|
||||||
|
|
||||||
export interface Action {
|
export type PanelComponentProps = {
|
||||||
name: ActionName;
|
|
||||||
PanelComponent?: React.FC<{
|
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
id?: string;
|
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||||
}>;
|
};
|
||||||
|
|
||||||
|
export interface Action {
|
||||||
|
name: ActionName;
|
||||||
|
PanelComponent?: React.FC<PanelComponentProps>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
|
|||||||
+1
-13
@@ -1,13 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { getCommonBounds } from "./element";
|
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||||
|
|
||||||
interface Box {
|
|
||||||
minX: number;
|
|
||||||
minY: number;
|
|
||||||
maxX: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -88,8 +81,3 @@ const calculateTranslation = (
|
|||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
||||||
return { minX, minY, maxX, maxY };
|
|
||||||
};
|
|
||||||
|
|||||||
+90
-72
@@ -4,6 +4,7 @@ import {
|
|||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
@@ -18,7 +19,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
theme: "light",
|
theme: THEME.LIGHT,
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
currentChartType: "bar",
|
currentChartType: "bar",
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
@@ -40,6 +41,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
editingImageElement: null,
|
||||||
elementLocked: false,
|
elementLocked: false,
|
||||||
elementType: "selection",
|
elementType: "selection",
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -78,6 +80,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
|
pendingImageElement: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,78 +94,87 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
browser: boolean;
|
browser: boolean;
|
||||||
/** whether to keep when exporting to file/database */
|
/** whether to keep when exporting to file/database */
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
/** server (shareLink/collab/...) */
|
||||||
|
server: boolean;
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>
|
T extends Record<keyof AppState, Values>,
|
||||||
>(
|
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config)({
|
||||||
) => config)({
|
theme: { browser: true, export: false, server: false },
|
||||||
theme: { browser: true, export: false },
|
collaborators: { browser: false, export: false, server: false },
|
||||||
collaborators: { browser: false, export: false },
|
currentChartType: { browser: true, export: false, server: false },
|
||||||
currentChartType: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontSize: { browser: true, export: false, server: false },
|
||||||
currentItemFontSize: { browser: true, export: false },
|
currentItemLinearStrokeSharpness: {
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
browser: true,
|
||||||
currentItemOpacity: { browser: true, export: false },
|
export: false,
|
||||||
currentItemRoughness: { browser: true, export: false },
|
server: false,
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
},
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false },
|
draggingElement: { browser: false, export: false, server: false },
|
||||||
elementLocked: { browser: true, export: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
elementType: { browser: true, export: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
exportBackground: { browser: true, export: false },
|
editingImageElement: { browser: false, export: false, server: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
elementLocked: { browser: true, export: false, server: false },
|
||||||
exportScale: { browser: true, export: false },
|
elementType: { browser: true, export: false, server: false },
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false },
|
exportBackground: { browser: true, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true },
|
exportEmbedScene: { browser: true, export: false, server: false },
|
||||||
height: { browser: false, export: false },
|
exportScale: { browser: true, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
isLibraryOpen: { browser: false, export: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
isLoading: { browser: false, export: false },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
isResizing: { browser: false, export: false },
|
height: { browser: false, export: false, server: false },
|
||||||
isRotating: { browser: false, export: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
lastPointerDownWith: { browser: true, export: false },
|
isLibraryOpen: { browser: false, export: false, server: false },
|
||||||
multiElement: { browser: false, export: false },
|
isLoading: { browser: false, export: false, server: false },
|
||||||
name: { browser: true, export: false },
|
isResizing: { browser: false, export: false, server: false },
|
||||||
offsetLeft: { browser: false, export: false },
|
isRotating: { browser: false, export: false, server: false },
|
||||||
offsetTop: { browser: false, export: false },
|
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||||
openMenu: { browser: true, export: false },
|
multiElement: { browser: false, export: false, server: false },
|
||||||
openPopup: { browser: false, export: false },
|
name: { browser: true, export: false, server: false },
|
||||||
pasteDialog: { browser: false, export: false },
|
offsetLeft: { browser: false, export: false, server: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
offsetTop: { browser: false, export: false, server: false },
|
||||||
resizingElement: { browser: false, export: false },
|
openMenu: { browser: true, export: false, server: false },
|
||||||
scrolledOutside: { browser: true, export: false },
|
openPopup: { browser: false, export: false, server: false },
|
||||||
scrollX: { browser: true, export: false },
|
pasteDialog: { browser: false, export: false, server: false },
|
||||||
scrollY: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||||
selectedElementIds: { browser: true, export: false },
|
resizingElement: { browser: false, export: false, server: false },
|
||||||
selectedGroupIds: { browser: true, export: false },
|
scrolledOutside: { browser: true, export: false, server: false },
|
||||||
selectionElement: { browser: false, export: false },
|
scrollX: { browser: true, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
showHelpDialog: { browser: false, export: false },
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false },
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
toastMessage: { browser: false, export: false },
|
showHelpDialog: { browser: false, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
showStats: { browser: true, export: false, server: false },
|
||||||
width: { browser: false, export: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
zoom: { browser: true, export: false },
|
toastMessage: { browser: false, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false },
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
|
width: { browser: false, export: false, server: false },
|
||||||
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
|
zoom: { browser: true, export: false, server: false },
|
||||||
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
|
pendingImageElement: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <
|
||||||
|
ExportType extends "export" | "browser" | "server",
|
||||||
|
>(
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
) => {
|
) => {
|
||||||
@@ -175,8 +187,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
|||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
const nextValue = appState[key];
|
||||||
stateForExport[key] = appState[key];
|
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/31445
|
||||||
|
(stateForExport as any)[key] = nextValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stateForExport;
|
return stateForExport;
|
||||||
@@ -189,3 +203,7 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
|||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "export");
|
return _clearAppStateForStorage(appState, "export");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||||
|
return _clearAppStateForStorage(appState, "server");
|
||||||
|
};
|
||||||
|
|||||||
+25
-10
@@ -3,19 +3,22 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState } from "./types";
|
import { AppState, BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClipboardData {
|
export interface ClipboardData {
|
||||||
spreadsheet?: Spreadsheet;
|
spreadsheet?: Spreadsheet;
|
||||||
elements?: readonly ExcalidrawElement[];
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
files?: BinaryFiles;
|
||||||
text?: string;
|
text?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob =
|
|||||||
|
|
||||||
const clipboardContainsElements = (
|
const clipboardContainsElements = (
|
||||||
contents: any,
|
contents: any,
|
||||||
): contents is { elements: ExcalidrawElement[] } => {
|
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
EXPORT_DATA_TYPES.excalidraw,
|
EXPORT_DATA_TYPES.excalidraw,
|
||||||
@@ -53,17 +56,26 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
|
// 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: getSelectedElements(elements, appState),
|
elements: selectedElements,
|
||||||
|
files: selectedElements.reduce((acc, element) => {
|
||||||
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
|
acc[element.fileId] = files[element.fileId];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as BinaryFiles),
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -76,7 +88,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
return JSON.parse(CLIPBOARD);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -138,7 +150,10 @@ export const parseClipboard = async (
|
|||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
if (clipboardContainsElements(systemClipboardData)) {
|
||||||
return { elements: systemClipboardData.elements };
|
return {
|
||||||
|
elements: systemClipboardData.elements,
|
||||||
|
files: systemClipboardData.files,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return appClipboardData;
|
return appClipboardData;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,7 +168,7 @@ export const parseClipboard = async (
|
|||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +180,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +220,7 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.setSelectionRange(0, textarea.value.length);
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
|
||||||
success = document.execCommand("copy");
|
success = document.execCommand("copy");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-12
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,8 @@ import { AppState, Zoom } from "../types";
|
|||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
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 { isImageElement } from "../element/typeChecks";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -48,9 +50,22 @@ export const SelectedShapeActions = ({
|
|||||||
hasBackground(elementType) ||
|
hasBackground(elementType) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
|
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||||
|
|
||||||
|
for (const element of targetElements) {
|
||||||
|
if (element.type !== commonSelectedType) {
|
||||||
|
commonSelectedType = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{renderAction("changeStrokeColor")}
|
{((hasStrokeColor(elementType) &&
|
||||||
|
elementType !== "image" &&
|
||||||
|
commonSelectedType !== "image") ||
|
||||||
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
|
renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
@@ -91,6 +106,13 @@ export const SelectedShapeActions = ({
|
|||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div className="buttonList">
|
||||||
|
{targetElements.some((element) => isImageElement(element)) &&
|
||||||
|
renderAction("editImageAlpha")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -155,18 +177,20 @@ export const ShapesSwitcher = ({
|
|||||||
canvas,
|
canvas,
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
onImageAction,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
const shortcut = letter
|
||||||
index + 1
|
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||||
}`;
|
: `${index + 1}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
@@ -180,14 +204,16 @@ export const ShapesSwitcher = ({
|
|||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={() => {
|
onChange={({ pointerType }) => {
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, value);
|
setCursorForShape(canvas, value);
|
||||||
setAppState({});
|
if (value === "image") {
|
||||||
|
onImageAction({ pointerType });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -204,12 +230,9 @@ export const ZoomActions = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1}>
|
<Stack.Col gap={1}>
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomIn")}
|
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomOut")}
|
||||||
|
{renderAction("zoomIn")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
<div style={{ marginInlineStart: 4 }}>
|
|
||||||
{(zoom.value * 100).toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import Stack from "../components/Stack";
|
import Stack from "../components/Stack";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { save, file } from "../components/icons";
|
import { save, file } from "../components/icons";
|
||||||
|
|||||||
+1081
-160
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Tooltip-icon {
|
.excalidraw-tooltip-icon {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { checkIcon } from "./icons";
|
import { checkIcon } from "./icons";
|
||||||
|
|
||||||
@@ -5,16 +6,19 @@ 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;
|
||||||
}> = ({ children, checked, onChange }) => {
|
className?: string;
|
||||||
|
}> = ({ children, checked, onChange, className }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("Checkbox", { "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",
|
||||||
) as HTMLButtonElement).focus();
|
) as HTMLButtonElement
|
||||||
|
).focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { useIsMobile } from "./App";
|
||||||
|
import { trash } from "./icons";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
|
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const toggleDialog = () => {
|
||||||
|
setShowDialog(!showDialog);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={trash}
|
||||||
|
title={t("buttons.clearReset")}
|
||||||
|
aria-label={t("buttons.clearReset")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
onClick={toggleDialog}
|
||||||
|
data-testid="clear-canvas-button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
onConfirm();
|
||||||
|
toggleDialog();
|
||||||
|
}}
|
||||||
|
onCancel={toggleDialog}
|
||||||
|
title={t("clearCanvasDialog.title")}
|
||||||
|
>
|
||||||
|
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClearCanvas;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
import { isTransparent } from "../utils";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
@@ -14,7 +15,7 @@ const isValidColor = (color: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (color === "transparent") {
|
if (isTransparent(color)) {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,15 +138,19 @@ const Picker = ({
|
|||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => (
|
{colors.map((_color, i) => {
|
||||||
|
const _colorWithoutHash = _color.replace("#", "");
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
className="color-picker-swatch"
|
className="color-picker-swatch"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
onChange(_color);
|
onChange(_color);
|
||||||
}}
|
}}
|
||||||
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
title={`${t(`colors.${_colorWithoutHash}`)}${
|
||||||
aria-label={_color}
|
!isTransparent(_color) ? ` (${_color})` : ""
|
||||||
|
} — ${keyBindings[i].toUpperCase()}`}
|
||||||
|
aria-label={t(`colors.${_colorWithoutHash}`)}
|
||||||
aria-keyshortcuts={keyBindings[i]}
|
aria-keyshortcuts={keyBindings[i]}
|
||||||
style={{ color: _color }}
|
style={{ color: _color }}
|
||||||
key={_color}
|
key={_color}
|
||||||
@@ -161,12 +166,13 @@ const Picker = ({
|
|||||||
onChange(_color);
|
onChange(_color);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{_color === "transparent" ? (
|
{isTransparent(_color) ? (
|
||||||
<div className="color-picker-transparent"></div>
|
<div className="color-picker-transparent"></div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.confirm-dialog {
|
||||||
|
&-buttons {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ToolIcon__icon {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_button {
|
||||||
|
margin-left: 0.8rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm.ToolIcon_type_button {
|
||||||
|
background-color: $oc-red-6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-red-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { t } from "../i18n";
|
||||||
|
import { Dialog, DialogProps } from "./Dialog";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import "./ConfirmDialog.scss";
|
||||||
|
|
||||||
|
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
const ConfirmDialog = (props: Props) => {
|
||||||
|
const {
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
children,
|
||||||
|
confirmText = t("buttons.confirm"),
|
||||||
|
cancelText = t("buttons.cancel"),
|
||||||
|
className = "",
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={onCancel}
|
||||||
|
small={true}
|
||||||
|
{...rest}
|
||||||
|
className={`confirm-dialog ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div className="confirm-dialog-buttons">
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={cancelText}
|
||||||
|
aria-label={cancelText}
|
||||||
|
label={cancelText}
|
||||||
|
onClick={onCancel}
|
||||||
|
className="confirm-dialog--cancel"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={confirmText}
|
||||||
|
aria-label={confirmText}
|
||||||
|
label={confirmText}
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="confirm-dialog--confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ConfirmDialog;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { THEME } from "../constants";
|
||||||
export type Appearence = "light" | "dark";
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
// We chose to use only explicit toggle and not a third option for system value,
|
// We chose to use only explicit toggle and not a third option for system value,
|
||||||
// but this could be added in the future.
|
// but this could be added in the future.
|
||||||
export const DarkModeToggle = (props: {
|
export const DarkModeToggle = (props: {
|
||||||
value: Appearence;
|
value: Theme;
|
||||||
onChange: (value: Appearence) => void;
|
onChange: (value: Theme) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const title =
|
const title =
|
||||||
@@ -20,10 +19,12 @@ export const DarkModeToggle = (props: {
|
|||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="icon"
|
type="icon"
|
||||||
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
|
onClick={() =>
|
||||||
|
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
||||||
|
}
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Island } from "./Island";
|
|||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export interface DialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
@@ -18,7 +18,10 @@ export const Dialog = (props: {
|
|||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
}) => {
|
closeOnClickOutside?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dialog = (props: DialogProps) => {
|
||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
const [lastActiveElement] = useState(document.activeElement);
|
const [lastActiveElement] = useState(document.activeElement);
|
||||||
const { id } = useExcalidrawContainer();
|
const { id } = useExcalidrawContainer();
|
||||||
@@ -81,6 +84,7 @@ export const Dialog = (props: {
|
|||||||
maxWidth={props.small ? 550 : 800}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={onClose}
|
onCloseRequest={onClose}
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
|
closeOnClickOutside={props.closeOnClickOutside}
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={["Shift+P", "7"]}
|
shortcuts={["Shift+P", "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.library")} shortcuts={["0"]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.editSelectedShape")}
|
label={t("helpDialog.editSelectedShape")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { questionCircle } from "../components/icons";
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
import {
|
||||||
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextBindableContainer,
|
||||||
|
isTextElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface HintViewerProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements }: Hint) => {
|
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (elementType === "arrow" || elementType === "line") {
|
if (elementType === "arrow" || elementType === "line") {
|
||||||
if (!multiMode) {
|
if (!multiMode) {
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElement");
|
||||||
@@ -31,7 +37,12 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||||
|
return t("hints.placeImage");
|
||||||
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
lastPointerDownWith === "mouse" &&
|
lastPointerDownWith === "mouse" &&
|
||||||
@@ -41,21 +52,28 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return t("hints.lockAngle");
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return isImageElement(targetElement)
|
||||||
|
? t("hints.resizeImage")
|
||||||
|
: t("hints.resize");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRotating && lastPointerDownWith === "mouse") {
|
if (isRotating && lastPointerDownWith === "mouse") {
|
||||||
return t("hints.rotate");
|
return t("hints.rotate");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
if (selectedElements.length === 1) {
|
||||||
|
if (isLinearElement(selectedElements[0])) {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return appState.editingLinearElement.activePointIndex
|
return appState.editingLinearElement.selectedPointsIndices
|
||||||
? t("hints.lineEditor_pointSelected")
|
? t("hints.lineEditor_pointSelected")
|
||||||
: t("hints.lineEditor_nothingSelected");
|
: t("hints.lineEditor_nothingSelected");
|
||||||
}
|
}
|
||||||
return t("hints.lineEditor_info");
|
return t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
|
if (isTextBindableContainer(selectedElements[0])) {
|
||||||
|
return t("hints.bindTextToElement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||||
return t("hints.text_selected");
|
return t("hints.text_selected");
|
||||||
@@ -65,13 +83,22 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (elementType === "selection" && !selectedElements.length && !isMobile) {
|
||||||
|
return t("hints.canvasPanning");
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
export const HintViewer = ({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
isMobile,
|
||||||
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
if (!hint) {
|
||||||
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 {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
.picker-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-template-columns: repeat(3, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import { t } from "../i18n";
|
|||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas } from "../scene/export";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { clipboard, exportImage } from "./icons";
|
import { clipboard, exportImage } from "./icons";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
import { supported as fsSupported } from "browser-fs-access";
|
|
||||||
import OpenColor from "open-color";
|
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";
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -79,6 +79,7 @@ const ExportButton: React.FC<{
|
|||||||
const ImageExportModal = ({
|
const ImageExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
@@ -87,6 +88,7 @@ const ImageExportModal = ({
|
|||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
@@ -100,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(() => {
|
||||||
@@ -112,29 +114,25 @@ const ImageExportModal = ({
|
|||||||
if (!previewNode) {
|
if (!previewNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
exportToCanvas(exportedElements, appState, files, {
|
||||||
const canvas = exportToCanvas(exportedElements, appState, {
|
|
||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
});
|
})
|
||||||
|
.then((canvas) => {
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
canvasToBlob(canvas)
|
return canvasToBlob(canvas).then(() => {
|
||||||
.then(() => {
|
|
||||||
renderPreview(canvas, previewNode);
|
renderPreview(canvas, previewNode);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
renderPreview(new CanvasError(), previewNode);
|
renderPreview(new CanvasError(), previewNode);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
renderPreview(new CanvasError(), previewNode);
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
@@ -182,7 +180,8 @@ const ImageExportModal = ({
|
|||||||
margin: ".6em 0",
|
margin: ".6em 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
{!nativeFileSystemSupported &&
|
||||||
|
actionManager.renderAction("changeProjectName")}
|
||||||
</div>
|
</div>
|
||||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
@@ -219,6 +218,7 @@ const ImageExportModal = ({
|
|||||||
export const ImageExportDialog = ({
|
export const ImageExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
@@ -227,6 +227,7 @@ export const ImageExportDialog = ({
|
|||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
@@ -257,6 +258,7 @@ export const ImageExportDialog = ({
|
|||||||
<ImageExportModal
|
<ImageExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
exportPadding={exportPadding}
|
exportPadding={exportPadding}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={onExportToPng}
|
onExportToPng={onExportToPng}
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
|
children: React.ReactElement;
|
||||||
}
|
}
|
||||||
interface State {
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
export class InitializeApp extends React.Component<Props, State> {
|
|
||||||
public state: { isLoading: boolean } = {
|
|
||||||
isLoading: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
async componentDidMount() {
|
export const InitializeApp = (props: Props) => {
|
||||||
const currentLang =
|
const [loading, setLoading] = useState(true);
|
||||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
|
||||||
defaultLang;
|
useEffect(() => {
|
||||||
|
const updateLang = async () => {
|
||||||
await setLanguage(currentLang);
|
await setLanguage(currentLang);
|
||||||
this.setState({
|
};
|
||||||
isLoading: false,
|
const currentLang =
|
||||||
});
|
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||||
}
|
updateLang();
|
||||||
|
setLoading(false);
|
||||||
|
}, [props.langCode]);
|
||||||
|
|
||||||
public render() {
|
return loading ? <LoadingMessage /> : props.children;
|
||||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { useIsMobile } from "./App";
|
||||||
import { AppState, ExportOpts } 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";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
@@ -11,7 +11,7 @@ import { actionSaveFileToDisk } from "../actions/actionExport";
|
|||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
import { supported as fsSupported } from "browser-fs-access";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
|
|
||||||
export type ExportCB = (
|
export type ExportCB = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -21,11 +21,13 @@ export type ExportCB = (
|
|||||||
const JSONExportModal = ({
|
const JSONExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
exportOpts,
|
||||||
canvas,
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
@@ -42,7 +44,8 @@ const JSONExportModal = ({
|
|||||||
<h2>{t("exportDialog.disk_title")}</h2>
|
<h2>{t("exportDialog.disk_title")}</h2>
|
||||||
<div className="Card-details">
|
<div className="Card-details">
|
||||||
{t("exportDialog.disk_details")}
|
{t("exportDialog.disk_details")}
|
||||||
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
{!nativeFileSystemSupported &&
|
||||||
|
actionManager.renderAction("changeProjectName")}
|
||||||
</div>
|
</div>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Card-button"
|
className="Card-button"
|
||||||
@@ -67,12 +70,14 @@ 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={() => onExportToBackend(elements, appState, canvas)}
|
onClick={() =>
|
||||||
|
onExportToBackend(elements, appState, files, canvas)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{exportOpts.renderCustomUI &&
|
{exportOpts.renderCustomUI &&
|
||||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,12 +86,14 @@ const JSONExportModal = ({
|
|||||||
export const JSONExportDialog = ({
|
export const JSONExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
exportOpts,
|
||||||
canvas,
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
@@ -115,6 +122,7 @@ export const JSONExportDialog = ({
|
|||||||
<JSONExportModal
|
<JSONExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
exportOpts={exportOpts}
|
exportOpts={exportOpts}
|
||||||
|
|||||||
+14
-39
@@ -1,42 +1,6 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__library {
|
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.layer-ui__library-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin: 2px 0;
|
|
||||||
|
|
||||||
button {
|
|
||||||
// 2px from the left to account for focus border of left-most button
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
|
||||||
padding-inline-end: 18px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__library-message {
|
|
||||||
padding: 10px 20px;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__library-items {
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
@@ -73,10 +37,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(-92px, 0);
|
transform: translate(-76px, 0);
|
||||||
}
|
}
|
||||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(92px, 0);
|
transform: translate(76px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||||
@@ -116,8 +80,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.layer-ui__wrapper__footer-left,
|
.layer-ui__wrapper__footer-left,
|
||||||
.layer-ui__wrapper__footer-right {
|
.layer-ui__wrapper__footer-right,
|
||||||
|
.disable-zen-mode--visible {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layer-ui__wrapper__footer-left {
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__wrapper__footer-right {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
margin-inline-end: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-320
@@ -1,28 +1,15 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, {
|
import React, { useCallback } from "react";
|
||||||
RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
|
||||||
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 { 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 {
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
AppProps,
|
|
||||||
AppState,
|
|
||||||
ExcalidrawProps,
|
|
||||||
LibraryItem,
|
|
||||||
LibraryItems,
|
|
||||||
} from "../types";
|
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
@@ -31,10 +18,7 @@ import { ErrorDialog } from "./ErrorDialog";
|
|||||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { exportFile, load, trash } from "./icons";
|
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@@ -42,16 +26,21 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
|
import { isImageFileHandle } from "../data/blob";
|
||||||
|
import { LibraryMenu } from "./LibraryMenu";
|
||||||
|
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import "./Toolbar.scss";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@@ -64,7 +53,10 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderTopRightUI?: (
|
||||||
|
isMobile: boolean,
|
||||||
|
appState: AppState,
|
||||||
|
) => JSX.Element | null;
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
@@ -72,295 +64,13 @@ interface LayerUIProps {
|
|||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
|
||||||
ref: RefObject<HTMLElement>,
|
|
||||||
cb: (event: MouseEvent) => void,
|
|
||||||
) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const listener = (event: MouseEvent) => {
|
|
||||||
if (!ref.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.target instanceof Element &&
|
|
||||||
(ref.current.contains(event.target) ||
|
|
||||||
!document.body.contains(event.target))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(event);
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", listener, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", listener);
|
|
||||||
};
|
|
||||||
}, [ref, cb]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
|
||||||
libraryItems,
|
|
||||||
onRemoveFromLibrary,
|
|
||||||
onAddToLibrary,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
theme,
|
|
||||||
setAppState,
|
|
||||||
setLibraryItems,
|
|
||||||
libraryReturnUrl,
|
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
pendingElements: LibraryItem;
|
|
||||||
onRemoveFromLibrary: (index: number) => void;
|
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
|
||||||
theme: AppState["theme"];
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
setLibraryItems: (library: LibraryItems) => void;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
}) => {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
|
||||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
|
||||||
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
|
||||||
const rows = [];
|
|
||||||
let addedPendingElements = false;
|
|
||||||
|
|
||||||
const referrer =
|
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={() => {
|
|
||||||
importLibraryFromJSON(library)
|
|
||||||
.then(() => {
|
|
||||||
// Close and then open to get the libraries updated
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
setAppState({ isLibraryOpen: true });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!!libraryItems.length && (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportFile}
|
|
||||||
onClick={() => {
|
|
||||||
saveLibraryAsJSON(library)
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
key="reset"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.resetLibrary")}
|
|
||||||
aria-label={t("buttons.resetLibrary")}
|
|
||||||
icon={trash}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
|
||||||
library.resetLibrary();
|
|
||||||
setLibraryItems([]);
|
|
||||||
focusContainer();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={`https://libraries.excalidraw.com?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let row = 0; row < numRows; row++) {
|
|
||||||
const y = CELLS_PER_ROW * row;
|
|
||||||
const children = [];
|
|
||||||
for (let x = 0; x < CELLS_PER_ROW; x++) {
|
|
||||||
const shouldAddPendingElements: boolean =
|
|
||||||
pendingElements.length > 0 &&
|
|
||||||
!addedPendingElements &&
|
|
||||||
y + x >= libraryItems.length;
|
|
||||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
<Stack.Col key={x}>
|
|
||||||
<LibraryUnit
|
|
||||||
elements={libraryItems[y + x]}
|
|
||||||
pendingElements={
|
|
||||||
shouldAddPendingElements ? pendingElements : undefined
|
|
||||||
}
|
|
||||||
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
|
||||||
onClick={
|
|
||||||
shouldAddPendingElements
|
|
||||||
? onAddToLibrary.bind(null, pendingElements)
|
|
||||||
: onInsertShape.bind(null, libraryItems[y + x])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Stack.Col>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
rows.push(
|
|
||||||
<Stack.Row align="center" gap={1} key={row}>
|
|
||||||
{children}
|
|
||||||
</Stack.Row>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
|
|
||||||
{rows}
|
|
||||||
</Stack.Col>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LibraryMenu = ({
|
|
||||||
onClickOutside,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
onAddToLibrary,
|
|
||||||
theme,
|
|
||||||
setAppState,
|
|
||||||
libraryReturnUrl,
|
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
pendingElements: LibraryItem;
|
|
||||||
onClickOutside: (event: MouseEvent) => void;
|
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
|
||||||
onAddToLibrary: () => void;
|
|
||||||
theme: AppState["theme"];
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
}) => {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useOnClickOutside(ref, (event) => {
|
|
||||||
// If click on the library icon, do nothing.
|
|
||||||
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClickOutside(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
|
||||||
|
|
||||||
const [loadingState, setIsLoading] = useState<
|
|
||||||
"preloading" | "loading" | "ready"
|
|
||||||
>("preloading");
|
|
||||||
|
|
||||||
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.race([
|
|
||||||
new Promise((resolve) => {
|
|
||||||
loadingTimerRef.current = 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 (indexToRemove) => {
|
|
||||||
const items = await library.loadLibrary();
|
|
||||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
|
||||||
});
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
|
||||||
[library, setAppState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
|
||||||
async (elements: LibraryItem) => {
|
|
||||||
const items = await library.loadLibrary();
|
|
||||||
const nextItems = [...items, elements];
|
|
||||||
onAddToLibrary();
|
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
|
||||||
});
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
|
||||||
[onAddToLibrary, library, setAppState],
|
|
||||||
);
|
|
||||||
|
|
||||||
return loadingState === "preloading" ? null : (
|
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
|
||||||
{loadingState === "loading" ? (
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
{t("labels.libraryLoadingMessage")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LibraryMenuItems
|
|
||||||
libraryItems={libraryItems}
|
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
|
||||||
onAddToLibrary={addToLibrary}
|
|
||||||
onInsertShape={onInsertShape}
|
|
||||||
pendingElements={pendingElements}
|
|
||||||
setAppState={setAppState}
|
|
||||||
setLibraryItems={setLibraryItems}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
focusContainer={focusContainer}
|
|
||||||
library={library}
|
|
||||||
theme={theme}
|
|
||||||
id={id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
@@ -380,6 +90,7 @@ const LayerUI = ({
|
|||||||
focusContainer,
|
focusContainer,
|
||||||
library,
|
library,
|
||||||
id,
|
id,
|
||||||
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -392,6 +103,7 @@ const LayerUI = ({
|
|||||||
<JSONExportDialog
|
<JSONExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
exportOpts={UIOptions.canvasActions.export}
|
exportOpts={UIOptions.canvasActions.export}
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
@@ -404,25 +116,40 @@ const LayerUI = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createExporter = (type: ExportType): ExportCB => async (
|
const createExporter =
|
||||||
|
(type: ExportType): ExportCB =>
|
||||||
|
async (exportedElements) => {
|
||||||
|
const fileHandle = await exportCanvas(
|
||||||
|
type,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
) => {
|
appState,
|
||||||
await exportCanvas(type, exportedElements, appState, {
|
files,
|
||||||
|
{
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
appState.exportEmbedScene &&
|
||||||
|
fileHandle &&
|
||||||
|
isImageFileHandle(fileHandle)
|
||||||
|
) {
|
||||||
|
setAppState({ fileHandle });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageExportDialog
|
<ImageExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
@@ -456,6 +183,7 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCanvasActions = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
@@ -523,12 +251,15 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLibrary = useCallback(
|
const closeLibrary = useCallback(() => {
|
||||||
(event) => {
|
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||||
|
|
||||||
|
// Prevent closing if any dialog is open
|
||||||
|
if (isDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAppState({ isLibraryOpen: false });
|
setAppState({ isLibraryOpen: false });
|
||||||
},
|
}, [setAppState]);
|
||||||
[setAppState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
const deselectItems = useCallback(() => {
|
||||||
setAppState({
|
setAppState({
|
||||||
@@ -539,8 +270,8 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
const libraryMenu = appState.isLibraryOpen ? (
|
||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClickOutside={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertShape={onInsertElements}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
@@ -548,7 +279,9 @@ const LayerUI = ({
|
|||||||
focusContainer={focusContainer}
|
focusContainer={focusContainer}
|
||||||
library={library}
|
library={library}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
|
files={files}
|
||||||
id={id}
|
id={id}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -574,7 +307,12 @@ 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,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<LockButton
|
<LockButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={zenModeEnabled}
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
@@ -583,15 +321,26 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
className={clsx("App-toolbar", {
|
||||||
|
"zen-mode": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer appState={appState} elements={elements} />
|
<HintViewer
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
onImageAction={({ pointerType }) => {
|
||||||
|
onImageAction({
|
||||||
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
@@ -623,7 +372,9 @@ const LayerUI = ({
|
|||||||
label={client.username || "Unknown user"}
|
label={client.username || "Unknown user"}
|
||||||
key={clientId}
|
key={clientId}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
{actionManager.renderAction("goToCollaborator", {
|
||||||
|
id: clientId,
|
||||||
|
})}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
@@ -656,6 +407,17 @@ const LayerUI = ({
|
|||||||
zoom={appState.zoom}
|
zoom={appState.zoom}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
|
{!viewModeEnabled && (
|
||||||
|
<div
|
||||||
|
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||||
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
|
zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{actionManager.renderAction("undo", { size: "small" })}
|
||||||
|
{actionManager.renderAction("redo", { size: "small" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,7 +425,8 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||||
{
|
{
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
|
zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -740,6 +503,8 @@ const LayerUI = ({
|
|||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
viewModeEnabled={viewModeEnabled}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
|
onImageAction={onImageAction}
|
||||||
|
renderTopRightUI={renderTopRightUI}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -787,6 +552,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||||
prev.langCode === next.langCode &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
|
prev.files === next.files &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
@@ -15,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_m`,
|
`ToolIcon_size_medium`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
"is-mobile": isMobile,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
@@ -37,7 +38,7 @@ export const LibraryButton: React.FC<{
|
|||||||
}}
|
}}
|
||||||
checked={appState.isLibraryOpen}
|
checked={appState.isLibraryOpen}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
aria-keyshortcuts="9"
|
aria-keyshortcuts="0"
|
||||||
/>
|
/>
|
||||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@import "open-color/open-color";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.layer-ui__library {
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.layer-ui__library-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2px 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
|
padding-inline-end: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__library-message {
|
||||||
|
padding: 10px 20px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-library-success {
|
||||||
|
.Dialog__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-close.ToolIcon_type_button {
|
||||||
|
background-color: $oc-blue-6;
|
||||||
|
align-self: flex-end;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-blue-8;
|
||||||
|
}
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $oc-white;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import {
|
||||||
|
LibraryItems,
|
||||||
|
LibraryItem,
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawProps,
|
||||||
|
} from "../types";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import PublishLibrary from "./PublishLibrary";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import "./LibraryMenu.scss";
|
||||||
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
|
import { EVENT } from "../constants";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
const useOnClickOutside = (
|
||||||
|
ref: RefObject<HTMLElement>,
|
||||||
|
cb: (event: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
(ref.current.contains(event.target) ||
|
||||||
|
!document.body.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(event);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", listener, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", listener);
|
||||||
|
};
|
||||||
|
}, [ref, cb]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedItems = (
|
||||||
|
libraryItems: LibraryItems,
|
||||||
|
selectedItems: LibraryItem["id"][],
|
||||||
|
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
|
export const LibraryMenu = ({
|
||||||
|
onClose,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
onAddToLibrary,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
files,
|
||||||
|
libraryReturnUrl,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
appState,
|
||||||
|
}: {
|
||||||
|
pendingElements: LibraryItem["elements"];
|
||||||
|
onClose: () => void;
|
||||||
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
|
onAddToLibrary: () => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
appState: AppState;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === KEYS.ESCAPE) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||||
|
|
||||||
|
const [loadingState, setIsLoading] = useState<
|
||||||
|
"preloading" | "loading" | "ready"
|
||||||
|
>("preloading");
|
||||||
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
|
useState(false);
|
||||||
|
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
}>(null);
|
||||||
|
const loadingTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.race([
|
||||||
|
new Promise((resolve) => {
|
||||||
|
loadingTimerRef.current = window.setTimeout(() => {
|
||||||
|
resolve("loading");
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
library.loadLibrary().then((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setIsLoading("ready");
|
||||||
|
}),
|
||||||
|
]).then((data) => {
|
||||||
|
if (data === "loading") {
|
||||||
|
setIsLoading("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(loadingTimerRef.current!);
|
||||||
|
};
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
|
const removeFromLibrary = useCallback(async () => {
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
|
||||||
|
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
|
});
|
||||||
|
setSelectedItems([]);
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||||
|
|
||||||
|
const resetLibrary = useCallback(() => {
|
||||||
|
library.resetLibrary();
|
||||||
|
setLibraryItems([]);
|
||||||
|
focusContainer();
|
||||||
|
}, [library, focusContainer]);
|
||||||
|
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
async (elements: LibraryItem["elements"]) => {
|
||||||
|
if (elements.some((element) => element.type === "image")) {
|
||||||
|
return setAppState({
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
const nextItems: LibraryItems = [
|
||||||
|
{
|
||||||
|
status: "unpublished",
|
||||||
|
elements,
|
||||||
|
id: randomId(),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
];
|
||||||
|
onAddToLibrary();
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
|
});
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
},
|
||||||
|
[onAddToLibrary, library, setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPublishSuccess = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||||
|
title={t("publishSuccessDialog.title")}
|
||||||
|
className="publish-library-success"
|
||||||
|
small={true}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{t("publishSuccessDialog.content", {
|
||||||
|
authorName: publishLibSuccess!.authorName,
|
||||||
|
})}{" "}
|
||||||
|
<a
|
||||||
|
href={publishLibSuccess?.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishSuccessDialog.link")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.close")}
|
||||||
|
aria-label={t("buttons.close")}
|
||||||
|
label={t("buttons.close")}
|
||||||
|
onClick={() => setPublishLibSuccess(null)}
|
||||||
|
data-testid="publish-library-success-close"
|
||||||
|
className="publish-library-success-close"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
|
const onPublishLibSuccess = useCallback(
|
||||||
|
(data) => {
|
||||||
|
setShowPublishLibraryDialog(false);
|
||||||
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
|
const nextLibItems = libraryItems.slice();
|
||||||
|
nextLibItems.forEach((libItem) => {
|
||||||
|
if (selectedItems.includes(libItem.id)) {
|
||||||
|
libItem.status = "published";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
library.saveLibrary(nextLibItems);
|
||||||
|
setLibraryItems(nextLibItems);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setShowPublishLibraryDialog,
|
||||||
|
setPublishLibSuccess,
|
||||||
|
libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
library,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
|
LibraryItem["id"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
return loadingState === "preloading" ? null : (
|
||||||
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
|
{showPublishLibraryDialog && (
|
||||||
|
<PublishLibrary
|
||||||
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
|
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||||
|
appState={appState}
|
||||||
|
onSuccess={onPublishLibSuccess}
|
||||||
|
onError={(error) => window.alert(error)}
|
||||||
|
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||||
|
onRemove={(id: string) =>
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
|
|
||||||
|
{loadingState === "loading" ? (
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
{t("labels.libraryLoadingMessage")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LibraryMenuItems
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
|
onAddToLibrary={addToLibrary}
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (shouldSelect) {
|
||||||
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
|
const rangeStart = libraryItems.findIndex(
|
||||||
|
(item) => item.id === lastSelectedItem,
|
||||||
|
);
|
||||||
|
const rangeEnd = libraryItems.findIndex(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rangeStart === -1 || rangeEnd === -1) {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
|
const nextSelectedIds = libraryItems.reduce(
|
||||||
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
|
if (
|
||||||
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
|
selectedItemsMap.has(item.id)
|
||||||
|
) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedItems(nextSelectedIds);
|
||||||
|
} else {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
}
|
||||||
|
setLastSelectedItem(id);
|
||||||
|
} else {
|
||||||
|
setLastSelectedItem(null);
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
@import "open-color/open-color";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.library-menu-items-container {
|
||||||
|
.library-actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
button .library-actions-counter {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--remove {
|
||||||
|
background-color: $oc-red-7;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-red-8;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-red-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-red-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--export {
|
||||||
|
background-color: $oc-lime-5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-lime-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-lime-8;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-lime-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--publish {
|
||||||
|
background-color: $oc-cyan-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-cyan-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-cyan-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
margin-left: -0.2em;
|
||||||
|
margin-right: 1.1em;
|
||||||
|
color: $oc-white;
|
||||||
|
font-size: 0.86em;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-cyan-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--load {
|
||||||
|
background-color: $oc-blue-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-blue-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-blue-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__items {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.6em 0.2em;
|
||||||
|
color: var(--text-primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { chunk } from "lodash";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import {
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawProps,
|
||||||
|
LibraryItem,
|
||||||
|
LibraryItems,
|
||||||
|
} from "../types";
|
||||||
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { useIsMobile } from "./App";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
|
import "./LibraryMenuItems.scss";
|
||||||
|
import { VERSIONS } from "../constants";
|
||||||
|
|
||||||
|
const LibraryMenuItems = ({
|
||||||
|
libraryItems,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
onAddToLibrary,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
libraryReturnUrl,
|
||||||
|
library,
|
||||||
|
files,
|
||||||
|
id,
|
||||||
|
selectedItems,
|
||||||
|
onToggle,
|
||||||
|
onPublish,
|
||||||
|
resetLibrary,
|
||||||
|
}: {
|
||||||
|
libraryItems: LibraryItems;
|
||||||
|
pendingElements: LibraryItem["elements"];
|
||||||
|
onRemoveFromLibrary: () => void;
|
||||||
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
resetLibrary: () => void;
|
||||||
|
}) => {
|
||||||
|
const renderRemoveLibAlert = useCallback(() => {
|
||||||
|
const content = selectedItems.length
|
||||||
|
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||||
|
: t("alerts.resetLibrary");
|
||||||
|
const title = selectedItems.length
|
||||||
|
? t("confirmDialog.removeItemsFromLib")
|
||||||
|
: t("confirmDialog.resetLibrary");
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedItems.length) {
|
||||||
|
onRemoveFromLibrary();
|
||||||
|
} else {
|
||||||
|
resetLibrary();
|
||||||
|
}
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<p>{content}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||||
|
|
||||||
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const renderLibraryActions = () => {
|
||||||
|
const itemsSelected = !!selectedItems.length;
|
||||||
|
const items = itemsSelected
|
||||||
|
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||||
|
: libraryItems;
|
||||||
|
const resetLabel = itemsSelected
|
||||||
|
? t("buttons.remove")
|
||||||
|
: t("buttons.resetLibrary");
|
||||||
|
return (
|
||||||
|
<div className="library-actions">
|
||||||
|
{(!itemsSelected || !isMobile) && (
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={() => {
|
||||||
|
importLibraryFromJSON(library)
|
||||||
|
.then(() => {
|
||||||
|
// Close and then open to get the libraries updated
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
setAppState({ isLibraryOpen: true });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="library-actions--load"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!items.length && (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportToFileIcon}
|
||||||
|
onClick={async () => {
|
||||||
|
const libraryItems = itemsSelected
|
||||||
|
? items
|
||||||
|
: await library.loadLibrary();
|
||||||
|
saveLibraryAsJSON(libraryItems)
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="library-actions--export"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={resetLabel}
|
||||||
|
aria-label={resetLabel}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => setShowRemoveLibAlert(true)}
|
||||||
|
className="library-actions--remove"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{itemsSelected && !isPublished && (
|
||||||
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
aria-label={t("buttons.publishLibrary")}
|
||||||
|
label={t("buttons.publishLibrary")}
|
||||||
|
icon={publishIcon}
|
||||||
|
className="library-actions--publish"
|
||||||
|
onClick={onPublish}
|
||||||
|
>
|
||||||
|
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
|
||||||
|
const referrer =
|
||||||
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
|
const isPublished = selectedItems.some(
|
||||||
|
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLibraryItemCompo = (params: {
|
||||||
|
item:
|
||||||
|
| LibraryItem
|
||||||
|
| /* pending library item */ {
|
||||||
|
id: null;
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
onClick?: () => void;
|
||||||
|
key: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Stack.Col key={params.key}>
|
||||||
|
<LibraryUnit
|
||||||
|
elements={params.item?.elements}
|
||||||
|
files={files}
|
||||||
|
isPending={!params.item?.id && !!params.item?.elements}
|
||||||
|
onClick={params.onClick || (() => {})}
|
||||||
|
id={params.item?.id || null}
|
||||||
|
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||||
|
onToggle={(id, event) => {
|
||||||
|
onToggle(id, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLibrarySection = (
|
||||||
|
items: (
|
||||||
|
| LibraryItem
|
||||||
|
| /* pending library item */ {
|
||||||
|
id: null;
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
}
|
||||||
|
)[],
|
||||||
|
) => {
|
||||||
|
const _items = items.map((item) => {
|
||||||
|
if (item.id) {
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
item,
|
||||||
|
onClick: () => onInsertShape(item.elements),
|
||||||
|
key: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
key: "__pending__item__",
|
||||||
|
item,
|
||||||
|
onClick: () => onAddToLibrary(pendingElements),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ensure we render all empty cells if no items are present
|
||||||
|
let rows = chunk(_items, CELLS_PER_ROW);
|
||||||
|
if (!rows.length) {
|
||||||
|
rows = [[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((rowItems, index, rows) => {
|
||||||
|
if (index === rows.length - 1) {
|
||||||
|
// pad row with empty cells
|
||||||
|
rowItems = rowItems.concat(
|
||||||
|
new Array(CELLS_PER_ROW - rowItems.length)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => {
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
key: `empty_${index}`,
|
||||||
|
item: null,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack.Row align="center" gap={1} key={index}>
|
||||||
|
{rowItems}
|
||||||
|
</Stack.Row>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishedItems = libraryItems.filter(
|
||||||
|
(item) => item.status === "published",
|
||||||
|
);
|
||||||
|
const unpublishedItems = [
|
||||||
|
// append pending library item
|
||||||
|
...(pendingElements.length
|
||||||
|
? [{ id: null, elements: pendingElements }]
|
||||||
|
: []),
|
||||||
|
...libraryItems.filter((item) => item.status !== "published"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-menu-items-container">
|
||||||
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
|
{renderLibraryActions()}
|
||||||
|
<a
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
|
VERSIONS.excalidrawLibrary
|
||||||
|
}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Stack.Col
|
||||||
|
className="library-menu-items-container__items"
|
||||||
|
align="start"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="separator">{t("labels.personalLib")}</div>
|
||||||
|
{renderLibrarySection(unpublishedItems)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||||
|
|
||||||
|
{renderLibrarySection(publishedItems)}
|
||||||
|
</>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryMenuItems;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-unit {
|
.library-unit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -7,6 +9,20 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 63px;
|
width: 63px;
|
||||||
height: 63px; // match width
|
height: 63px; // match width
|
||||||
|
|
||||||
|
&--hover {
|
||||||
|
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
|
||||||
|
border-color: $oc-blue-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
|
||||||
|
border-color: $oc-blue-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme--dark .library-unit {
|
||||||
|
border-color: rgb(48, 48, 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__dragger {
|
.library-unit__dragger {
|
||||||
@@ -22,9 +38,9 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__removeFromLibrary,
|
.library-unit__checkbox-container,
|
||||||
.library-unit__removeFromLibrary:hover,
|
.library-unit__checkbox-container:hover,
|
||||||
.library-unit__removeFromLibrary:active {
|
.library-unit__checkbox-container:active {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -32,10 +48,35 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0.5rem;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
left: 2rem;
|
||||||
top: 5px;
|
bottom: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__checkbox {
|
||||||
|
position: absolute;
|
||||||
|
left: 2.3rem;
|
||||||
|
bottom: 2.3rem;
|
||||||
|
|
||||||
|
.Checkbox-box {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0.5em 0.5em 0.2em 0.2em;
|
||||||
|
background-color: $oc-blue-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Checkbox:hover {
|
||||||
|
.Checkbox-box {
|
||||||
|
background-color: $oc-blue-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__removeFromLibrary > svg {
|
.library-unit__removeFromLibrary > svg {
|
||||||
@@ -43,29 +84,37 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__pulse {
|
.library-unit__adder {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
animation: library-unit__pulse-animation 1s ease-in infinite;
|
animation: library-unit__adder-animation 1s ease-in infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__adder {
|
.library-unit__adder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 40%;
|
||||||
top: 50%;
|
top: 40%;
|
||||||
width: 20px;
|
width: 2rem;
|
||||||
height: 20px;
|
height: 2rem;
|
||||||
margin-left: -10px;
|
margin-left: -10px;
|
||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.library-unit:hover .library-unit__adder {
|
||||||
|
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 {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes library-unit__pulse-animation {
|
@keyframes library-unit__adder-animation {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.95);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
@@ -73,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(0.95);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,103 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { close } from "../components/icons";
|
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { LibraryItem } from "../types";
|
import { BinaryFiles, LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
|
id,
|
||||||
elements,
|
elements,
|
||||||
pendingElements,
|
files,
|
||||||
onRemoveFromLibrary,
|
isPending,
|
||||||
onClick,
|
onClick,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
elements?: LibraryItem;
|
id: LibraryItem["id"] | /** for pending item */ null;
|
||||||
pendingElements?: LibraryItem;
|
elements?: LibraryItem["elements"];
|
||||||
onRemoveFromLibrary: () => void;
|
files: BinaryFiles;
|
||||||
|
isPending?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const elementsToRender = elements || pendingElements;
|
const node = ref.current;
|
||||||
if (!elementsToRender) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const svg = exportToSvg(elementsToRender, {
|
|
||||||
|
(async () => {
|
||||||
|
if (!elements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const svg = await exportToSvg(
|
||||||
|
elements,
|
||||||
|
{
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
});
|
},
|
||||||
for (const child of ref.current!.children) {
|
files,
|
||||||
if (child.tagName !== "svg") {
|
);
|
||||||
continue;
|
node.innerHTML = svg.outerHTML;
|
||||||
}
|
})();
|
||||||
ref.current!.removeChild(child);
|
|
||||||
}
|
|
||||||
ref.current!.appendChild(svg);
|
|
||||||
|
|
||||||
const current = ref.current!;
|
|
||||||
return () => {
|
return () => {
|
||||||
current.removeChild(svg);
|
node.innerHTML = "";
|
||||||
};
|
};
|
||||||
}, [elements, pendingElements]);
|
}, [elements, files]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const adder = isPending && (
|
||||||
const adder = (isHovered || isMobile) && pendingElements && (
|
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("library-unit", {
|
className={clsx("library-unit", {
|
||||||
"library-unit__active": elements || pendingElements,
|
"library-unit__active": elements,
|
||||||
|
"library-unit--hover": elements && isHovered,
|
||||||
|
"library-unit--selected": selected,
|
||||||
})}
|
})}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx("library-unit__dragger", {
|
className={clsx("library-unit__dragger", {
|
||||||
"library-unit__pulse": !!pendingElements,
|
"library-unit__pulse": !!isPending,
|
||||||
})}
|
})}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
draggable={!!elements}
|
draggable={!!elements}
|
||||||
onClick={!!elements || !!pendingElements ? 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(
|
||||||
@@ -85,14 +107,12 @@ export const LibraryUnit = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adder}
|
{adder}
|
||||||
{elements && (isHovered || isMobile) && (
|
{id && elements && (isHovered || isMobile || selected) && (
|
||||||
<button
|
<CheckboxItem
|
||||||
className="library-unit__removeFromLibrary"
|
checked={selected}
|
||||||
aria-label={t("labels.removeFromLibrary")}
|
onChange={(checked, event) => onToggle(id, event)}
|
||||||
onClick={onRemoveFromLibrary}
|
className="library-unit__checkbox"
|
||||||
>
|
/>
|
||||||
{close}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const LoadingMessage = () => {
|
export const LoadingMessage = () => {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import "./ToolIcon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ToolButtonSize } from "./ToolButton";
|
||||||
type LockIconSize = "s" | "m";
|
|
||||||
|
|
||||||
type LockIconProps = {
|
type LockIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -11,9 +10,10 @@ type LockIconProps = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
zenModeEnabled?: boolean;
|
zenModeEnabled?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SIZE: LockIconSize = "m";
|
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
CHECKED: (
|
CHECKED: (
|
||||||
@@ -43,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`}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ type MobileMenuProps = {
|
|||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
|
renderTopRightUI?: (
|
||||||
|
isMobile: boolean,
|
||||||
|
appState: AppState,
|
||||||
|
) => JSX.Element | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -50,6 +55,8 @@ export const MobileMenu = ({
|
|||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
|
onImageAction,
|
||||||
|
renderTopRightUI,
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@@ -57,29 +64,40 @@ 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
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
onImageAction={({ pointerType }) => {
|
||||||
|
onImageAction({
|
||||||
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
|
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||||
<LockButton
|
<LockButton
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
<LibraryButton
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
isMobile
|
||||||
/>
|
/>
|
||||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
<HintViewer appState={appState} elements={elements} />
|
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -168,10 +186,9 @@ export const MobileMenu = ({
|
|||||||
)
|
)
|
||||||
.map(([clientId, client]) => (
|
.map(([clientId, client]) => (
|
||||||
<React.Fragment key={clientId}>
|
<React.Fragment key={clientId}>
|
||||||
{actionManager.renderAction(
|
{actionManager.renderAction("goToCollaborator", {
|
||||||
"goToCollaborator",
|
id: clientId,
|
||||||
clientId,
|
})}
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import clsx from "clsx";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { THEME } from "../constants";
|
||||||
|
|
||||||
export const Modal = (props: {
|
export const Modal = (props: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -14,8 +15,9 @@ export const Modal = (props: {
|
|||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
|
closeOnClickOutside?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { theme = "light" } = props;
|
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||||
const modalRoot = useBodyRoot(theme);
|
const modalRoot = useBodyRoot(theme);
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
@@ -38,7 +40,10 @@ export const Modal = (props: {
|
|||||||
onKeyDown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
aria-labelledby={props.labelledBy}
|
aria-labelledby={props.labelledBy}
|
||||||
>
|
>
|
||||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
<div
|
||||||
|
className="Modal__background"
|
||||||
|
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
className="Modal__content"
|
className="Modal__content"
|
||||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||||
|
|||||||
@@ -34,19 +34,25 @@ const ChartPreviewBtn = (props: {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
setChartElements(elements);
|
setChartElements(elements);
|
||||||
|
let svg: SVGSVGElement;
|
||||||
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
const svg = exportToSvg(elements, {
|
(async () => {
|
||||||
|
svg = await exportToSvg(
|
||||||
|
elements,
|
||||||
|
{
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
});
|
},
|
||||||
|
null, // files
|
||||||
const previewNode = previewRef.current!;
|
);
|
||||||
|
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
(previewNode.parentNode as HTMLDivElement).focus();
|
(previewNode.parentNode as HTMLDivElement).focus();
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previewNode.removeChild(svg);
|
previewNode.removeChild(svg);
|
||||||
@@ -76,7 +82,7 @@ export const PasteChartDialog = ({
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onInsertChart: (elements: LibraryItem) => void;
|
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
|
|||||||
</label>
|
</label>
|
||||||
{props.isNameEditable ? (
|
{props.isNameEditable ? (
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
className="TextInput"
|
className="TextInput"
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.publish-library {
|
||||||
|
&__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
label {
|
||||||
|
padding: 1em;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
span {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $oc-gray-6;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 70%;
|
||||||
|
padding: 0.6em;
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: $oc-red-8;
|
||||||
|
margin: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_button {
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm.ToolIcon_type_button {
|
||||||
|
background-color: $oc-blue-6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-blue-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cancel.ToolIcon_type_button {
|
||||||
|
background-color: $oc-gray-5;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-gray-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
color: $oc-white;
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: #fff;
|
||||||
|
svg {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-library-items {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.single-library-item-wrapper {
|
||||||
|
width: 9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-note {
|
||||||
|
padding: 1em;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
|
import OpenColor from "open-color";
|
||||||
|
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||||
|
import { exportToCanvas } from "../packages/utils";
|
||||||
|
import {
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
EXPORT_SOURCE,
|
||||||
|
MIME_TYPES,
|
||||||
|
VERSIONS,
|
||||||
|
} from "../constants";
|
||||||
|
import { ExportedLibraryData } from "../data/types";
|
||||||
|
|
||||||
|
import "./PublishLibrary.scss";
|
||||||
|
import SingleLibraryItem from "./SingleLibraryItem";
|
||||||
|
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||||
|
import { chunk } from "../utils";
|
||||||
|
|
||||||
|
interface PublishLibraryDataParams {
|
||||||
|
authorName: string;
|
||||||
|
githubHandle: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
twitterHandle: string;
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
|
||||||
|
|
||||||
|
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
|
||||||
|
JSON.stringify(data),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Unable to access window.localStorage
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPublishLibDataFromStorage = () => {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Unable to access localStorage
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ({
|
||||||
|
onClose,
|
||||||
|
libraryItems,
|
||||||
|
appState,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
updateItemsInStorage,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
libraryItems: LibraryItems;
|
||||||
|
appState: AppState;
|
||||||
|
onSuccess: (data: {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
items: LibraryItems;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
updateItemsInStorage: (items: LibraryItems) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
|
||||||
|
authorName: "",
|
||||||
|
githubHandle: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
twitterHandle: "",
|
||||||
|
website: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = importPublishLibDataFromStorage();
|
||||||
|
if (data) {
|
||||||
|
setLibraryData(data);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
|
||||||
|
libraryItems.slice(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setClonedLibItems(libraryItems.slice());
|
||||||
|
}, [libraryItems]);
|
||||||
|
|
||||||
|
const onInputChange = (event: any) => {
|
||||||
|
setLibraryData({
|
||||||
|
...libraryData,
|
||||||
|
[event.target.name]: event.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const erroredLibItems: LibraryItem[] = [];
|
||||||
|
let isError = false;
|
||||||
|
clonedLibItems.forEach((libItem) => {
|
||||||
|
let error = "";
|
||||||
|
if (!libItem.name) {
|
||||||
|
error = t("publishDialog.errors.required");
|
||||||
|
isError = true;
|
||||||
|
}
|
||||||
|
erroredLibItems.push({ ...libItem, error });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
setClonedLibItems(erroredLibItems);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewImage = await generatePreviewImage(clonedLibItems);
|
||||||
|
|
||||||
|
const libContent: ExportedLibraryData = {
|
||||||
|
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||||
|
version: VERSIONS.excalidrawLibrary,
|
||||||
|
source: EXPORT_SOURCE,
|
||||||
|
libraryItems: clonedLibItems,
|
||||||
|
};
|
||||||
|
const content = JSON.stringify(libContent, null, 2);
|
||||||
|
const lib = new Blob([content], { type: "application/json" });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("excalidrawLib", lib);
|
||||||
|
formData.append("previewImage", previewImage);
|
||||||
|
formData.append("previewImageType", previewImage.type);
|
||||||
|
formData.append("title", libraryData.name);
|
||||||
|
formData.append("authorName", libraryData.authorName);
|
||||||
|
formData.append("githubHandle", libraryData.githubHandle);
|
||||||
|
formData.append("name", libraryData.name);
|
||||||
|
formData.append("description", libraryData.description);
|
||||||
|
formData.append("twitterHandle", libraryData.twitterHandle);
|
||||||
|
formData.append("website", libraryData.website);
|
||||||
|
|
||||||
|
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
||||||
|
method: "post",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json().then(({ url }) => {
|
||||||
|
// flush data from local storage
|
||||||
|
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||||
|
onSuccess({
|
||||||
|
url,
|
||||||
|
authorName: libraryData.authorName,
|
||||||
|
items: clonedLibItems,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
.json()
|
||||||
|
.catch(() => {
|
||||||
|
throw new Error(response.statusText || "something went wrong");
|
||||||
|
})
|
||||||
|
.then((error) => {
|
||||||
|
throw new Error(
|
||||||
|
error.message || response.statusText || "something went wrong",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error(err);
|
||||||
|
onError(err);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
onError(err);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLibraryItems = () => {
|
||||||
|
const items: ReactNode[] = [];
|
||||||
|
clonedLibItems.forEach((libItem, index) => {
|
||||||
|
items.push(
|
||||||
|
<div className="single-library-item-wrapper" key={index}>
|
||||||
|
<SingleLibraryItem
|
||||||
|
libItem={libItem}
|
||||||
|
appState={appState}
|
||||||
|
index={index}
|
||||||
|
onChange={(val, index) => {
|
||||||
|
const items = clonedLibItems.slice();
|
||||||
|
items[index].name = val;
|
||||||
|
setClonedLibItems(items);
|
||||||
|
}}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return <div className="selected-library-items">{items}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDialogClose = useCallback(() => {
|
||||||
|
updateItemsInStorage(clonedLibItems);
|
||||||
|
savePublishLibDataToStorage(libraryData);
|
||||||
|
onClose();
|
||||||
|
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||||
|
|
||||||
|
const shouldRenderForm = !!libraryItems.length;
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={onDialogClose}
|
||||||
|
title={t("publishDialog.title")}
|
||||||
|
className="publish-library"
|
||||||
|
>
|
||||||
|
{shouldRenderForm ? (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="publish-library-note">
|
||||||
|
{t("publishDialog.noteDescription.pre")}
|
||||||
|
<a
|
||||||
|
href="https://libraries.excalidraw.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishDialog.noteDescription.link")}
|
||||||
|
</a>{" "}
|
||||||
|
{t("publishDialog.noteDescription.post")}
|
||||||
|
</div>
|
||||||
|
<span className="publish-library-note">
|
||||||
|
{t("publishDialog.noteGuidelines.pre")}
|
||||||
|
<a
|
||||||
|
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishDialog.noteGuidelines.link")}
|
||||||
|
</a>
|
||||||
|
{t("publishDialog.noteGuidelines.post")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="publish-library-note">
|
||||||
|
{t("publishDialog.noteItems")}
|
||||||
|
</div>
|
||||||
|
{renderLibraryItems()}
|
||||||
|
<div className="publish-library__fields">
|
||||||
|
<label>
|
||||||
|
<div>
|
||||||
|
<span>{t("publishDialog.libraryName")}</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
value={libraryData.name}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.libraryName")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<span>{t("publishDialog.libraryDesc")}</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
value={libraryData.description}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.libraryDesc")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div>
|
||||||
|
<span>{t("publishDialog.authorName")}</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="authorName"
|
||||||
|
required
|
||||||
|
value={libraryData.authorName}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.authorName")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("publishDialog.githubUsername")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="githubHandle"
|
||||||
|
value={libraryData.githubHandle}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.githubHandle")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("publishDialog.twitterUsername")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="twitterHandle"
|
||||||
|
value={libraryData.twitterHandle}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.twitterHandle")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("publishDialog.website")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
pattern="https?://.+"
|
||||||
|
title={t("publishDialog.errors.website")}
|
||||||
|
value={libraryData.website}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.website")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="publish-library-note">
|
||||||
|
{t("publishDialog.noteLicense.pre")}
|
||||||
|
<a
|
||||||
|
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishDialog.noteLicense.link")}
|
||||||
|
</a>
|
||||||
|
{t("publishDialog.noteLicense.post")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="publish-library__buttons">
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.cancel")}
|
||||||
|
aria-label={t("buttons.cancel")}
|
||||||
|
label={t("buttons.cancel")}
|
||||||
|
onClick={onDialogClose}
|
||||||
|
data-testid="cancel-clear-canvas-button"
|
||||||
|
className="publish-library__buttons--cancel"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
type="submit"
|
||||||
|
title={t("buttons.submit")}
|
||||||
|
aria-label={t("buttons.submit")}
|
||||||
|
label={t("buttons.submit")}
|
||||||
|
className="publish-library__buttons--confirm"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
|
||||||
|
{t("publishDialog.atleastOneLibItem")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublishLibrary;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.single-library-item {
|
||||||
|
position: relative;
|
||||||
|
&__svg {
|
||||||
|
width: 7.5rem;
|
||||||
|
height: 7.5rem;
|
||||||
|
border: 1px solid var(--button-gray-2);
|
||||||
|
margin: 0.3rem;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
background-color: $oc-white;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
.ToolIcon,
|
||||||
|
.ToolIcon_type_button:hover {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.required,
|
||||||
|
.error {
|
||||||
|
color: $oc-red-8;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.2rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.2rem;
|
||||||
|
right: 1.3rem;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ToolIcon__icon {
|
||||||
|
background-color: $oc-red-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-red-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-red-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
padding: 0.26rem;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import oc from "open-color";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { exportToSvg } from "../packages/utils";
|
||||||
|
import { AppState, LibraryItem } from "../types";
|
||||||
|
import { close } from "./icons";
|
||||||
|
|
||||||
|
import "./SingleLibraryItem.scss";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
const SingleLibraryItem = ({
|
||||||
|
libItem,
|
||||||
|
appState,
|
||||||
|
index,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
libItem: LibraryItem;
|
||||||
|
appState: AppState;
|
||||||
|
index: number;
|
||||||
|
onChange: (val: string, index: number) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) => {
|
||||||
|
const svgRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = svgRef.current;
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: libItem.elements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
exportBackground: true,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
|
});
|
||||||
|
node.innerHTML = svg.outerHTML;
|
||||||
|
})();
|
||||||
|
}, [libItem.elements, appState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="single-library-item">
|
||||||
|
<div ref={svgRef} className="single-library-item__svg" />
|
||||||
|
<ToolButton
|
||||||
|
aria-label={t("buttons.remove")}
|
||||||
|
type="button"
|
||||||
|
icon={close}
|
||||||
|
className="single-library-item--remove"
|
||||||
|
onClick={onRemove.bind(null, libItem.id)}
|
||||||
|
title={t("buttons.remove")}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
margin: "0.8rem 0.3rem",
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: "0.5em 0" }}>
|
||||||
|
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
|
||||||
|
{t("publishDialog.itemName")}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
|
style={{ width: "80%", padding: "0.2rem" }}
|
||||||
|
defaultValue={libItem.name}
|
||||||
|
placeholder="Item name"
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange(event.target.value, index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="error">{libItem.error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SingleLibraryItem;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
|
$duration: 1.6s;
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.Spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
--spinner-color: var(--icon-fill-color);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
animation: rotate $duration linear infinite;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle {
|
||||||
|
stroke: var(--spinner-color);
|
||||||
|
animation: dash $duration linear 0s infinite;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dash {
|
||||||
|
0% {
|
||||||
|
stroke-dasharray: 1, 300;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-dasharray: 150, 300;
|
||||||
|
stroke-dashoffset: -200;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dasharray: 1, 300;
|
||||||
|
stroke-dashoffset: -280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import "./Spinner.scss";
|
||||||
|
|
||||||
|
const Spinner = ({
|
||||||
|
size = "1em",
|
||||||
|
circleWidth = 8,
|
||||||
|
}: {
|
||||||
|
size?: string | number;
|
||||||
|
circleWidth?: number;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="Spinner">
|
||||||
|
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={50 - circleWidth / 2}
|
||||||
|
strokeWidth={circleWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Spinner;
|
||||||
@@ -2,24 +2,6 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.TextInput {
|
.TextInput {
|
||||||
color: var(--text-primary-color);
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1.5px solid var(--button-gray-1);
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.75rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
border-radius: var(--space-factor);
|
|
||||||
background-color: var(--input-bg-color);
|
|
||||||
|
|
||||||
&:not(:focus) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--input-hover-bg-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { TOAST_TIMEOUT } from "../constants";
|
import { TOAST_TIMEOUT } from "../constants";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawContainer } from "./App";
|
import { useExcalidrawContainer } from "./App";
|
||||||
|
import { AbortError } from "../errors";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
import { PointerType } from "../element/types";
|
||||||
|
|
||||||
type ToolIconSize = "s" | "m";
|
export type ToolButtonSize = "small" | "medium";
|
||||||
|
|
||||||
type ToolButtonBaseProps = {
|
type ToolButtonBaseProps = {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
@@ -15,20 +18,26 @@ type ToolButtonBaseProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
size?: ToolIconSize;
|
size?: ToolButtonSize;
|
||||||
keyBindingLabel?: string;
|
keyBindingLabel?: string;
|
||||||
showAriaLabel?: boolean;
|
showAriaLabel?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolButtonProps =
|
type ToolButtonProps =
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "button";
|
type: "button";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?(): void;
|
onClick?(event: React.MouseEvent): void;
|
||||||
|
})
|
||||||
|
| (ToolButtonBaseProps & {
|
||||||
|
type: "submit";
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?(event: React.MouseEvent): void;
|
||||||
})
|
})
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "icon";
|
type: "icon";
|
||||||
@@ -38,18 +47,57 @@ type ToolButtonProps =
|
|||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "radio";
|
type: "radio";
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(data: { pointerType: PointerType | null }): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolIconSize = "m";
|
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
const { id: excalId } = useExcalidrawContainer();
|
const { id: excalId } = useExcalidrawContainer();
|
||||||
const innerRef = React.useRef(null);
|
const innerRef = React.useRef(null);
|
||||||
React.useImperativeHandle(ref, () => innerRef.current);
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||||
|
|
||||||
if (props.type === "button" || props.type === "icon") {
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
const onClick = async (event: React.MouseEvent) => {
|
||||||
|
const ret = "onClick" in props && props.onClick?.(event);
|
||||||
|
|
||||||
|
if (ret && "then" in ret) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await ret;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!(error instanceof AbortError)) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.type === "button" ||
|
||||||
|
props.type === "icon" ||
|
||||||
|
props.type === "submit"
|
||||||
|
) {
|
||||||
|
const type = (props.type === "icon" ? "button" : props.type) as
|
||||||
|
| "button"
|
||||||
|
| "submit";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -69,9 +117,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
hidden={props.hidden}
|
hidden={props.hidden}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
type="button"
|
type={type}
|
||||||
onClick={props.onClick}
|
onClick={onClick}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
|
disabled={isLoading || props.isLoading}
|
||||||
>
|
>
|
||||||
{(props.icon || props.label) && (
|
{(props.icon || props.label) && (
|
||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
<div className="ToolIcon__icon" aria-hidden="true">
|
||||||
@@ -81,10 +130,13 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
{props.keyBindingLabel}
|
{props.keyBindingLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{props.isLoading && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.showAriaLabel && (
|
{props.showAriaLabel && (
|
||||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
<div className="ToolIcon__label">
|
||||||
|
{props["aria-label"]} {isLoading && <Spinner />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.children}
|
{props.children}
|
||||||
</button>
|
</button>
|
||||||
@@ -92,7 +144,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={clsx("ToolIcon", props.className)} title={props.title}>
|
<label
|
||||||
|
className={clsx("ToolIcon", props.className)}
|
||||||
|
title={props.title}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
lastPointerTypeRef.current = event.pointerType || null;
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
lastPointerTypeRef.current = null;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -101,7 +164,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
id={`${excalId}-${props.id}`}
|
id={`${excalId}-${props.id}`}
|
||||||
onChange={props.onChange}
|
onChange={() => {
|
||||||
|
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||||
|
}}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
/>
|
/>
|
||||||
@@ -118,4 +183,5 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
ToolButton.defaultProps = {
|
ToolButton.defaultProps = {
|
||||||
visible: true,
|
visible: true,
|
||||||
className: "",
|
className: "",
|
||||||
|
size: "medium",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,12 +6,22 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
font-family: Cascadia;
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon--plain {
|
||||||
|
background-color: transparent;
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
& + .ToolIcon__icon {
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -21,13 +31,6 @@
|
|||||||
background-color: var(--button-gray-3);
|
background-color: var(--button-gray-3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon--plain {
|
|
||||||
background-color: transparent;
|
|
||||||
.ToolIcon__icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
@@ -39,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;
|
||||||
@@ -47,22 +54,24 @@
|
|||||||
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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
margin: 0 0.8em;
|
margin: 0 0.8em;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
margin-left: 0.6em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon_size_s .ToolIcon__icon {
|
.ToolIcon_size_small .ToolIcon__icon {
|
||||||
width: 1.4rem;
|
width: 2rem;
|
||||||
height: 1.4rem;
|
height: 2rem;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,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 {
|
||||||
@@ -154,13 +159,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
|
||||||
margin-left: 0.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__keybinding {
|
.ToolIcon__keybinding {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
@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.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-chubb);
|
||||||
|
--keybinding-color: var(--color-primary-chubb);
|
||||||
|
}
|
||||||
|
&: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};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
// container in body where the actual tooltip is appended to
|
||||||
.excalidraw-tooltip {
|
.excalidraw-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -24,8 +26,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw {
|
// wraps the element we want to apply the tooltip to
|
||||||
.Tooltip-icon {
|
.excalidraw-tooltip-wrapper {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excalidraw-tooltip-icon {
|
||||||
width: 0.9em;
|
width: 0.9em;
|
||||||
height: 0.9em;
|
height: 0.9em;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -36,4 +42,3 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ const updateTooltip = (
|
|||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
} = item.getBoundingClientRect();
|
} = item.getBoundingClientRect();
|
||||||
|
|
||||||
const {
|
const { width: labelWidth, height: labelHeight } =
|
||||||
width: labelWidth,
|
tooltip.getBoundingClientRect();
|
||||||
height: labelHeight,
|
|
||||||
} = tooltip.getBoundingClientRect();
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
@@ -64,9 +62,15 @@ 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");
|
||||||
@@ -74,6 +78,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="excalidraw-tooltip-wrapper"
|
||||||
onPointerEnter={(event) =>
|
onPointerEnter={(event) =>
|
||||||
updateTooltip(
|
updateTooltip(
|
||||||
event.currentTarget as HTMLDivElement,
|
event.currentTarget as HTMLDivElement,
|
||||||
@@ -85,6 +90,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>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
for (const [key, value] of Object.entries({ ...localStorage })) {
|
for (const [key, value] of Object.entries({ ...localStorage })) {
|
||||||
try {
|
try {
|
||||||
_localStorage[key] = JSON.parse(value);
|
_localStorage[key] = JSON.parse(value);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
_localStorage[key] = value;
|
_localStorage[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
)
|
)
|
||||||
).default;
|
).default;
|
||||||
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 > * {
|
||||||
|
|||||||
+87
-76
@@ -10,13 +10,16 @@ import React from "react";
|
|||||||
|
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Theme } from "../element/types";
|
||||||
|
import { THEME } from "../constants";
|
||||||
|
|
||||||
const activeElementColor = (theme: "light" | "dark") =>
|
const activeElementColor = (theme: Theme) =>
|
||||||
theme === "light" ? oc.orange[4] : oc.orange[9];
|
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
||||||
const iconFillColor = (theme: "light" | "dark") =>
|
|
||||||
theme === "light" ? oc.black : oc.gray[4];
|
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||||
const handlerColor = (theme: "light" | "dark") =>
|
|
||||||
theme === "light" ? oc.white : "#1e1e1e";
|
const handlerColor = (theme: Theme) =>
|
||||||
|
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||||
|
|
||||||
type Opts = {
|
type Opts = {
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -28,8 +31,12 @@ export const createIcon = (
|
|||||||
d: string | React.ReactNode,
|
d: string | React.ReactNode,
|
||||||
opts: number | Opts = 512,
|
opts: number | Opts = 512,
|
||||||
) => {
|
) => {
|
||||||
const { width = 512, height = width, mirror, style } =
|
const {
|
||||||
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
width = 512,
|
||||||
|
height = width,
|
||||||
|
mirror,
|
||||||
|
style,
|
||||||
|
} = typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -79,8 +86,17 @@ export const clipboard = createIcon(
|
|||||||
|
|
||||||
export const trash = createIcon(
|
export const trash = createIcon(
|
||||||
"M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
|
"M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
|
||||||
|
|
||||||
{ width: 448, height: 512 },
|
{ width: 448, height: 512 },
|
||||||
);
|
);
|
||||||
|
export const backgroundIcon = createIcon(
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M512 320s-64 92.65-64 128c0 35.35 28.66 64 64 64s64-28.65 64-64-64-128-64-128zm-9.37-102.94L294.94 9.37C288.69 3.12 280.5 0 272.31 0s-16.38 3.12-22.62 9.37l-81.58 81.58L81.93 4.76c-6.25-6.25-16.38-6.25-22.62 0L36.69 27.38c-6.24 6.25-6.24 16.38 0 22.62l86.19 86.18-94.76 94.76c-37.49 37.48-37.49 98.26 0 135.75l117.19 117.19c18.74 18.74 43.31 28.12 67.87 28.12 24.57 0 49.13-9.37 67.87-28.12l221.57-221.57c12.5-12.5 12.5-32.75.01-45.25zm-116.22 70.97H65.93c1.36-3.84 3.57-7.98 7.43-11.83l13.15-13.15 81.61-81.61 58.6 58.6c12.49 12.49 32.75 12.49 45.24 0s12.49-32.75 0-45.24l-58.6-58.6 58.95-58.95 162.44 162.44-48.34 48.34z"
|
||||||
|
></path>,
|
||||||
|
|
||||||
|
{ width: 576, height: 512 },
|
||||||
|
);
|
||||||
|
|
||||||
export const palette = createIcon(
|
export const palette = createIcon(
|
||||||
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
|
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
|
||||||
@@ -175,8 +191,7 @@ export const resetZoom = createIcon(
|
|||||||
{ width: 1024 },
|
{ width: 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const BringForwardIcon = React.memo(
|
export const BringForwardIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -196,8 +211,7 @@ export const BringForwardIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SendBackwardIcon = React.memo(
|
export const SendBackwardIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -217,8 +231,7 @@ export const SendBackwardIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const BringToFrontIcon = React.memo(
|
export const BringToFrontIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -238,8 +251,7 @@ export const BringToFrontIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SendToBackIcon = React.memo(
|
export const SendToBackIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -265,8 +277,7 @@ export const SendToBackIcon = React.memo(
|
|||||||
// first one the user sees. Horizontal align icons should not be flipped since
|
// first one the user sees. Horizontal align icons should not be flipped since
|
||||||
// that would make them lie about their function.
|
// that would make them lie about their function.
|
||||||
//
|
//
|
||||||
export const AlignTopIcon = React.memo(
|
export const AlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -287,8 +298,7 @@ export const AlignTopIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AlignBottomIcon = React.memo(
|
export const AlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -309,8 +319,7 @@ export const AlignBottomIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AlignLeftIcon = React.memo(
|
export const AlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -331,8 +340,7 @@ export const AlignLeftIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AlignRightIcon = React.memo(
|
export const AlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -354,7 +362,7 @@ export const AlignRightIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const DistributeHorizontallyIcon = React.memo(
|
export const DistributeHorizontallyIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -375,7 +383,7 @@ export const DistributeHorizontallyIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const DistributeVerticallyIcon = React.memo(
|
export const DistributeVerticallyIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -396,8 +404,7 @@ export const DistributeVerticallyIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CenterVerticallyIcon = React.memo(
|
export const CenterVerticallyIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -420,7 +427,7 @@ export const CenterVerticallyIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const CenterHorizontallyIcon = React.memo(
|
export const CenterHorizontallyIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -482,7 +489,7 @@ export const file = createIcon(
|
|||||||
{ width: 384, height: 512 },
|
{ width: 384, height: 512 },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
export const GroupIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||||
@@ -512,8 +519,7 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const UngroupIcon = React.memo(
|
export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||||
@@ -545,8 +551,7 @@ export const UngroupIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FillHachureIcon = React.memo(
|
export const FillHachureIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@@ -558,8 +563,7 @@ export const FillHachureIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FillCrossHatchIcon = React.memo(
|
export const FillCrossHatchIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<g fill={iconFillColor(theme)} fillRule="evenodd" clipRule="evenodd">
|
<g fill={iconFillColor(theme)} fillRule="evenodd" clipRule="evenodd">
|
||||||
<path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
|
<path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
|
||||||
@@ -569,8 +573,7 @@ export const FillCrossHatchIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FillSolidIcon = React.memo(
|
export const FillSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
|
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -578,7 +581,7 @@ export const FillSolidIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const StrokeWidthIcon = React.memo(
|
export const StrokeWidthIcon = React.memo(
|
||||||
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
|
({ theme, strokeWidth }: { theme: Theme; strokeWidth: number }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M6 10H32"
|
d="M6 10H32"
|
||||||
@@ -591,8 +594,7 @@ export const StrokeWidthIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const StrokeStyleSolidIcon = React.memo(
|
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M6 10H34"
|
d="M6 10H34"
|
||||||
@@ -608,8 +610,7 @@ export const StrokeStyleSolidIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const StrokeStyleDashedIcon = React.memo(
|
export const StrokeStyleDashedIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M6 10H34"
|
d="M6 10H34"
|
||||||
@@ -623,8 +624,7 @@ export const StrokeStyleDashedIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const StrokeStyleDottedIcon = React.memo(
|
export const StrokeStyleDottedIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M6 10H36"
|
d="M6 10H36"
|
||||||
@@ -639,7 +639,7 @@ export const StrokeStyleDottedIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const SloppinessArchitectIcon = React.memo(
|
export const SloppinessArchitectIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
|
d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
|
||||||
@@ -652,8 +652,7 @@ export const SloppinessArchitectIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SloppinessArtistIcon = React.memo(
|
export const SloppinessArtistIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
|
d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
|
||||||
@@ -667,7 +666,7 @@ export const SloppinessArtistIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const SloppinessCartoonistIcon = React.memo(
|
export const SloppinessCartoonistIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
|
d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
|
||||||
@@ -680,8 +679,7 @@ export const SloppinessCartoonistIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EdgeSharpIcon = React.memo(
|
export const EdgeSharpIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M10 17L10 5L35 5"
|
d="M10 17L10 5L35 5"
|
||||||
@@ -694,8 +692,7 @@ export const EdgeSharpIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EdgeRoundIcon = React.memo(
|
export const EdgeRoundIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
||||||
@@ -708,8 +705,7 @@ export const EdgeRoundIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadNoneIcon = React.memo(
|
export const ArrowheadNoneIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M6 10H34"
|
d="M6 10H34"
|
||||||
@@ -725,7 +721,7 @@ export const ArrowheadNoneIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadArrowIcon = React.memo(
|
export const ArrowheadArrowIcon = React.memo(
|
||||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<g
|
<g
|
||||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||||
@@ -741,7 +737,7 @@ export const ArrowheadArrowIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadDotIcon = React.memo(
|
export const ArrowheadDotIcon = React.memo(
|
||||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<g
|
<g
|
||||||
stroke={iconFillColor(theme)}
|
stroke={iconFillColor(theme)}
|
||||||
@@ -756,7 +752,7 @@ export const ArrowheadDotIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadBarIcon = React.memo(
|
export const ArrowheadBarIcon = React.memo(
|
||||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
|
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
|
||||||
<path
|
<path
|
||||||
@@ -770,8 +766,22 @@ export const ArrowheadBarIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FontSizeSmallIcon = React.memo(
|
export const ArrowheadTriangleIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke={iconFillColor(theme)}
|
||||||
|
fill={iconFillColor(theme)}
|
||||||
|
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||||
|
>
|
||||||
|
<path d="M32 10L6 10" strokeWidth={2} />
|
||||||
|
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
fill={iconFillColor(theme)}
|
fill={iconFillColor(theme)}
|
||||||
@@ -781,8 +791,7 @@ export const FontSizeSmallIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FontSizeMediumIcon = React.memo(
|
export const FontSizeMediumIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
fill={iconFillColor(theme)}
|
fill={iconFillColor(theme)}
|
||||||
@@ -792,8 +801,7 @@ export const FontSizeMediumIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FontSizeLargeIcon = React.memo(
|
export const FontSizeLargeIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
fill={iconFillColor(theme)}
|
fill={iconFillColor(theme)}
|
||||||
@@ -804,7 +812,7 @@ export const FontSizeLargeIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const FontSizeExtraLargeIcon = React.memo(
|
export const FontSizeExtraLargeIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
fill={iconFillColor(theme)}
|
fill={iconFillColor(theme)}
|
||||||
@@ -815,7 +823,7 @@ export const FontSizeExtraLargeIcon = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const FontFamilyHandDrawnIcon = React.memo(
|
export const FontFamilyHandDrawnIcon = React.memo(
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
fill={iconFillColor(theme)}
|
fill={iconFillColor(theme)}
|
||||||
@@ -825,8 +833,7 @@ export const FontFamilyHandDrawnIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FontFamilyNormalIcon = React.memo(
|
export const FontFamilyNormalIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -842,8 +849,7 @@ export const FontFamilyNormalIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FontFamilyCodeIcon = React.memo(
|
export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -855,8 +861,7 @@ export const FontFamilyCodeIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TextAlignLeftIcon = React.memo(
|
export const TextAlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
|
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
|
||||||
@@ -867,8 +872,7 @@ export const TextAlignLeftIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TextAlignCenterIcon = React.memo(
|
export const TextAlignCenterIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
|
d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
|
||||||
@@ -878,8 +882,7 @@ export const TextAlignCenterIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TextAlignRightIcon = React.memo(
|
export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
({ theme }: { theme: "light" | "dark" }) =>
|
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
|
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
|
||||||
@@ -889,3 +892,11 @@ export const TextAlignRightIcon = React.memo(
|
|||||||
{ width: 448, height: 512 },
|
{ width: 448, height: 512 },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const publishIcon = createIcon(
|
||||||
|
<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"
|
||||||
|
fill="currentColor"
|
||||||
|
/>,
|
||||||
|
{ width: 640, height: 512 },
|
||||||
|
);
|
||||||
|
|||||||
+37
-1
@@ -14,6 +14,7 @@ export const CURSOR_TYPE = {
|
|||||||
TEXT: "text",
|
TEXT: "text",
|
||||||
CROSSHAIR: "crosshair",
|
CROSSHAIR: "crosshair",
|
||||||
GRABBING: "grabbing",
|
GRABBING: "grabbing",
|
||||||
|
GRAB: "grab",
|
||||||
POINTER: "pointer",
|
POINTER: "pointer",
|
||||||
MOVE: "move",
|
MOVE: "move",
|
||||||
AUTO: "",
|
AUTO: "",
|
||||||
@@ -34,6 +35,7 @@ export enum EVENT {
|
|||||||
MOUSE_MOVE = "mousemove",
|
MOUSE_MOVE = "mousemove",
|
||||||
RESIZE = "resize",
|
RESIZE = "resize",
|
||||||
UNLOAD = "unload",
|
UNLOAD = "unload",
|
||||||
|
FOCUS = "focus",
|
||||||
BLUR = "blur",
|
BLUR = "blur",
|
||||||
DRAG_OVER = "dragover",
|
DRAG_OVER = "dragover",
|
||||||
DROP = "drop",
|
DROP = "drop",
|
||||||
@@ -68,6 +70,11 @@ export const FONT_FAMILY = {
|
|||||||
Cascadia: 3,
|
Cascadia: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const THEME = {
|
||||||
|
LIGHT: "light",
|
||||||
|
DARK: "dark",
|
||||||
|
};
|
||||||
|
|
||||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||||
|
|
||||||
export const DEFAULT_FONT_SIZE = 20;
|
export const DEFAULT_FONT_SIZE = 20;
|
||||||
@@ -83,7 +90,13 @@ export const GRID_SIZE = 20; // TODO make it configurable?
|
|||||||
export const MIME_TYPES = {
|
export const MIME_TYPES = {
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
};
|
json: "application/json",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
binary: "application/octet-stream",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_DATA_TYPES = {
|
export const EXPORT_DATA_TYPES = {
|
||||||
excalidraw: "excalidraw",
|
excalidraw: "excalidraw",
|
||||||
@@ -98,6 +111,7 @@ export const STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
|
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
export const TITLE_TIMEOUT = 10000;
|
export const TITLE_TIMEOUT = 10000;
|
||||||
@@ -147,3 +161,25 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
|||||||
|
|
||||||
export const EXPORT_SCALES = [1, 2, 3];
|
export const EXPORT_SCALES = [1, 2, 3];
|
||||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||||
|
|
||||||
|
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||||
|
|
||||||
|
export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||||
|
MIME_TYPES.png,
|
||||||
|
MIME_TYPES.jpg,
|
||||||
|
MIME_TYPES.svg,
|
||||||
|
MIME_TYPES.gif,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
export const ENCRYPTION_KEY_BITS = 128;
|
||||||
|
|
||||||
|
export const VERSIONS = {
|
||||||
|
excalidraw: 2,
|
||||||
|
excalidrawLibrary: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PADDING = 30;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user