mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Compare commits
	
		
			263 Commits
		
	
	
		
			kb/auto-sa
			...
			aakansha-w
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7f4b72010e | ||
| 
						 | 
					eff5871147 | ||
| 
						 | 
					4eb5ec70be | ||
| 
						 | 
					ae2ab5f03a | ||
| 
						 | 
					468f20ae58 | ||
| 
						 | 
					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 | ||
| 
						 | 
					6dee02e320 | ||
| 
						 | 
					74a2f16501 | ||
| 
						 | 
					fd4460be37 | ||
| 
						 | 
					e82d0493cf | ||
| 
						 | 
					083cb4c656 | ||
| 
						 | 
					d067365c1d | ||
| 
						 | 
					273cac6b60 | ||
| 
						 | 
					b9337b8a36 | ||
| 
						 | 
					0e0025921b | ||
| 
						 | 
					efc01ddab1 | ||
| 
						 | 
					7bce22b114 | ||
| 
						 | 
					aab4965bbb | ||
| 
						 | 
					486a9a3da8 | ||
| 
						 | 
					2425c06082 | ||
| 
						 | 
					79ea844901 | ||
| 
						 | 
					6690215cd1 | ||
| 
						 | 
					7f5e783fe8 | ||
| 
						 | 
					9325109836 | ||
| 
						 | 
					69b6fbb3f4 | ||
| 
						 | 
					6b6002baae | ||
| 
						 | 
					54dcb73701 | ||
| 
						 | 
					b595d3fcba | ||
| 
						 | 
					d0867d1c3b | ||
| 
						 | 
					0d19e9210c | ||
| 
						 | 
					4249de41d4 | ||
| 
						 | 
					15f02ba191 | ||
| 
						 | 
					a2e1199907 | ||
| 
						 | 
					c08e9c4172 | ||
| 
						 | 
					abfc58eb24 | ||
| 
						 | 
					035c7affff | ||
| 
						 | 
					c819b653bf | ||
| 
						 | 
					60cea7a0c2 | ||
| 
						 | 
					d63b6a3469 | ||
| 
						 | 
					0912fe1c93 | ||
| 
						 | 
					360310de31 | ||
| 
						 | 
					716c78e930 | ||
| 
						 | 
					ba48974351 | ||
| 
						 | 
					6c3e4417e1 | ||
| 
						 | 
					bc0b6e1888 | ||
| 
						 | 
					99a22e8445 | ||
| 
						 | 
					e6d9797167 | ||
| 
						 | 
					a1e8fdfb1b | ||
| 
						 | 
					1cce63b07b | ||
| 
						 | 
					e9c2a09c21 | ||
| 
						 | 
					55e0812680 | ||
| 
						 | 
					0f32278a7e | ||
| 
						 | 
					1bdb8da1c3 | ||
| 
						 | 
					9c9787e0a0 | ||
| 
						 | 
					c2fe24c562 | ||
| 
						 | 
					52faa52091 | ||
| 
						 | 
					dd12abc583 | ||
| 
						 | 
					abebf9aff8 | ||
| 
						 | 
					790c9fd02e | ||
| 
						 | 
					357266e9ab | ||
| 
						 | 
					0bbb4535cf | ||
| 
						 | 
					d201d0be1b | ||
| 
						 | 
					5662c5141d | ||
| 
						 | 
					044614dcf3 | ||
| 
						 | 
					9ec15989ab | ||
| 
						 | 
					08aafcd248 | ||
| 
						 | 
					ea5602457f | ||
| 
						 | 
					3c58d19d45 | ||
| 
						 | 
					fcfcdebc99 | ||
| 
						 | 
					aa97c074a7 | ||
| 
						 | 
					d65d2c5279 | ||
| 
						 | 
					6d40039f08 | ||
| 
						 | 
					f4e10c93e1 | ||
| 
						 | 
					82c6df0e1f | ||
| 
						 | 
					c37bd59ddd | ||
| 
						 | 
					198a5e3b53 | ||
| 
						 | 
					a78e1fa99b | ||
| 
						 | 
					fc5db9248c | ||
| 
						 | 
					ebf64036fd | ||
| 
						 | 
					6271a031a3 | ||
| 
						 | 
					78da4c075e | ||
| 
						 | 
					f1cf28a84e | ||
| 
						 | 
					3b9290831a | ||
| 
						 | 
					bec34f2d57 | ||
| 
						 | 
					07839f8d20 | ||
| 
						 | 
					8068d1f853 | ||
| 
						 | 
					92c7d3257f | ||
| 
						 | 
					a8a5e7b6ff | ||
| 
						 | 
					45a4a00b69 | ||
| 
						 | 
					436e539d3a | ||
| 
						 | 
					ff19167063 | ||
| 
						 | 
					3fc531ed6e | ||
| 
						 | 
					6f55c00814 | ||
| 
						 | 
					a7eb6e1168 | ||
| 
						 | 
					641bbdd2da | ||
| 
						 | 
					42b0f7a614 | ||
| 
						 | 
					c11e3818ac | ||
| 
						 | 
					4b6aa5c53b | ||
| 
						 | 
					ebd0408d7d | ||
| 
						 | 
					f4fefbcee8 | ||
| 
						 | 
					11b8cc2caa | ||
| 
						 | 
					6bebfe63be | ||
| 
						 | 
					91ab7f36e2 | ||
| 
						 | 
					5ee8e8249c | ||
| 
						 | 
					49c6bdd520 | ||
| 
						 | 
					198800136e | ||
| 
						 | 
					178ee04d82 | ||
| 
						 | 
					18cdafbcbe | ||
| 
						 | 
					286e9a1524 | ||
| 
						 | 
					bac76778ce | ||
| 
						 | 
					f28f7ffb6e | ||
| 
						 | 
					12e8cc853f | ||
| 
						 | 
					81108bf580 | ||
| 
						 | 
					23030a15f2 | ||
| 
						 | 
					4ef7cb7365 | ||
| 
						 | 
					5cc3f7db80 | ||
| 
						 | 
					5c42cb5be4 | ||
| 
						 | 
					004d3180b5 | ||
| 
						 | 
					c12119278a | ||
| 
						 | 
					4d628844de | ||
| 
						 | 
					946a209927 | ||
| 
						 | 
					811437724b | ||
| 
						 | 
					9dcde502aa | ||
| 
						 | 
					d3106495b2 | ||
| 
						 | 
					891ac82447 | ||
| 
						 | 
					354976e08e | ||
| 
						 | 
					5c73c5813c | ||
| 
						 | 
					3a0b6fb41b | ||
| 
						 | 
					37d513ad59 | ||
| 
						 | 
					46624cc953 | ||
| 
						 | 
					0d23c8dd76 | ||
| 
						 | 
					51ef4cd97b | ||
| 
						 | 
					b558d19d37 | ||
| 
						 | 
					b8fb6580ef | ||
| 
						 | 
					6730eb41c2 | ||
| 
						 | 
					87c42cb327 | ||
| 
						 | 
					8cfd05aa95 | ||
| 
						 | 
					3ed8271344 | ||
| 
						 | 
					73515b5a83 | ||
| 
						 | 
					63d3da9a54 | ||
| 
						 | 
					215fb5e357 | ||
| 
						 | 
					886177816b | ||
| 
						 | 
					7d29351d66 | ||
| 
						 | 
					c0047269c1 | ||
| 
						 | 
					793b69e592 | ||
| 
						 | 
					e0a449aa40 | ||
| 
						 | 
					d5a270f643 | ||
| 
						 | 
					d126d04d17 | ||
| 
						 | 
					153ca6a7c6 | ||
| 
						 | 
					2618ac9f6e | ||
| 
						 | 
					f64fd80493 | ||
| 
						 | 
					a884351137 | ||
| 
						 | 
					e546a85a8d | ||
| 
						 | 
					29e630086c | ||
| 
						 | 
					a82165cb50 | ||
| 
						 | 
					4dc0159a05 | ||
| 
						 | 
					458787d1d7 | ||
| 
						 | 
					815977296e | ||
| 
						 | 
					58f840aa93 | ||
| 
						 | 
					422149c249 | ||
| 
						 | 
					a7cbe68ae8 | ||
| 
						 | 
					c19c8ecd27 | ||
| 
						 | 
					d91950bd03 | ||
| 
						 | 
					89472c14ed | ||
| 
						 | 
					09dfd16b17 | ||
| 
						 | 
					016e69b9f2 | ||
| 
						 | 
					bb1f979718 | ||
| 
						 | 
					5fda8400f3 | ||
| 
						 | 
					96beaa4354 | ||
| 
						 | 
					7183f1c83e | ||
| 
						 | 
					24ae9dca2e | ||
| 
						 | 
					f6ac3ea7c6 | ||
| 
						 | 
					b88e0253cc | 
							
								
								
									
										2
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								.env
									
									
									
									
									
								
							@@ -1,5 +1,5 @@
 | 
			
		||||
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_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"}'
 | 
			
		||||
 
 | 
			
		||||
@@ -5,3 +5,4 @@ package-lock.json
 | 
			
		||||
firebase/
 | 
			
		||||
dist/
 | 
			
		||||
public/workbox
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
name: Auto release @excalidraw/excalidraw-next
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - master
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  Auto-release-excalidraw-next:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 2
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v2
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
      - name: Set up publish access
 | 
			
		||||
        run: |
 | 
			
		||||
          npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
 | 
			
		||||
        env:
 | 
			
		||||
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 | 
			
		||||
      - name: Auto release
 | 
			
		||||
        run: |
 | 
			
		||||
          yarn autorelease
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -5,9 +5,11 @@
 | 
			
		||||
.env.test.local
 | 
			
		||||
.envrc
 | 
			
		||||
.eslintcache
 | 
			
		||||
.history
 | 
			
		||||
.idea
 | 
			
		||||
.vercel
 | 
			
		||||
.vscode
 | 
			
		||||
.yarn
 | 
			
		||||
*.log
 | 
			
		||||
*.tgz
 | 
			
		||||
build
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
 | 
			
		||||
COPY . .
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								README.md
									
									
									
									
									
								
							@@ -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.
 | 
			
		||||
 | 
			
		||||
> Note: Please ensure that the encryption key is 22 characters long.
 | 
			
		||||
 | 
			
		||||
## Shape libraries
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
- [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)
 | 
			
		||||
 | 
			
		||||
#### Clone the repo
 | 
			
		||||
@@ -102,6 +104,20 @@ These instructions will get you a copy of the project up and running on your loc
 | 
			
		||||
git clone https://github.com/excalidraw/excalidraw.git
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Install the dependencies
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
yarn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Start the server
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
yarn start
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
 | 
			
		||||
 | 
			
		||||
#### Commands
 | 
			
		||||
 | 
			
		||||
| Command            | Description                       |
 | 
			
		||||
 
 | 
			
		||||
@@ -2,5 +2,8 @@
 | 
			
		||||
  "firestore": {
 | 
			
		||||
    "rules": "firestore.rules",
 | 
			
		||||
    "indexes": "firestore.indexes.json"
 | 
			
		||||
  },
 | 
			
		||||
  "storage": {
 | 
			
		||||
    "rules": "storage.rules"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								firebase-project/storage.rules
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								firebase-project/storage.rules
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
rules_version = '2';
 | 
			
		||||
service firebase.storage {
 | 
			
		||||
  match /b/{bucket}/o {
 | 
			
		||||
    match /{migrations} {
 | 
			
		||||
      match /{scenes}/{scene} {
 | 
			
		||||
      	allow get, write: if true;
 | 
			
		||||
        // redundant, but let's be explicit'
 | 
			
		||||
        allow list: if false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								package.json
									
									
									
									
									
								
							@@ -19,22 +19,28 @@
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@sentry/browser": "6.2.2",
 | 
			
		||||
    "@sentry/integrations": "6.2.1",
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.10",
 | 
			
		||||
    "@testing-library/react": "11.2.5",
 | 
			
		||||
    "@testing-library/react": "11.2.6",
 | 
			
		||||
    "@tldraw/vec": "0.0.106",
 | 
			
		||||
    "@types/jest": "26.0.22",
 | 
			
		||||
    "@types/pica": "5.1.3",
 | 
			
		||||
    "@types/react": "17.0.3",
 | 
			
		||||
    "@types/react-dom": "17.0.2",
 | 
			
		||||
    "@types/react-dom": "17.0.3",
 | 
			
		||||
    "@types/socket.io-client": "1.4.36",
 | 
			
		||||
    "browser-fs-access": "0.16.2",
 | 
			
		||||
    "browser-fs-access": "0.21.0",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "firebase": "8.2.10",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.0.1",
 | 
			
		||||
    "fake-indexeddb": "3.1.3",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.0",
 | 
			
		||||
    "idb-keyval": "5.1.3",
 | 
			
		||||
    "image-blob-reduce": "3.0.1",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "3.1.22",
 | 
			
		||||
    "open-color": "1.8.0",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "perfect-freehand": "1.0.15",
 | 
			
		||||
    "png-chunk-text": "1.0.0",
 | 
			
		||||
    "png-chunks-encode": "1.0.0",
 | 
			
		||||
    "png-chunks-extract": "1.0.0",
 | 
			
		||||
@@ -43,20 +49,22 @@
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react-scripts": "4.0.3",
 | 
			
		||||
    "roughjs": "4.3.1",
 | 
			
		||||
    "sass": "1.32.8",
 | 
			
		||||
    "roughjs": "4.4.1",
 | 
			
		||||
    "sass": "1.32.10",
 | 
			
		||||
    "socket.io-client": "2.3.1",
 | 
			
		||||
    "typescript": "4.2.3"
 | 
			
		||||
    "typescript": "4.2.4"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
			
		||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
			
		||||
    "@types/chai": "4.2.22",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/pako": "1.0.1",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.5",
 | 
			
		||||
    "eslint-config-prettier": "8.1.0",
 | 
			
		||||
    "chai": "4.3.4",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "firebase-tools": "9.6.1",
 | 
			
		||||
    "firebase-tools": "9.9.0",
 | 
			
		||||
    "husky": "4.3.8",
 | 
			
		||||
    "jest-canvas-mock": "2.3.1",
 | 
			
		||||
    "lint-staged": "10.5.4",
 | 
			
		||||
@@ -103,6 +111,7 @@
 | 
			
		||||
    "test:other": "yarn prettier --list-different",
 | 
			
		||||
    "test:typecheck": "tsc",
 | 
			
		||||
    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
 | 
			
		||||
    "test": "yarn test:app"
 | 
			
		||||
    "test": "yarn test:app",
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								public/BLOKKNeue-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/BLOKKNeue-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -11,3 +11,33 @@
 | 
			
		||||
  src: url("Cascadia.woff2");
 | 
			
		||||
  font-display: swap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "REDACTED_REGULAR";
 | 
			
		||||
  src: url("redacted-regular.woff2");
 | 
			
		||||
  font-display: swap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "REDACTED_SCRIPT_BOLD";
 | 
			
		||||
  src: url("redacted-script-bold.woff2");
 | 
			
		||||
  font-display: swap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "REDACTED_SCRIPT_REGULAR";
 | 
			
		||||
  src: url("redacted-script-regular.woff2");
 | 
			
		||||
  font-display: swap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "Scribble";
 | 
			
		||||
  src: url("scribble-webfont.woff2");
 | 
			
		||||
  font-display: swap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "Blokk";
 | 
			
		||||
  src: url("BLOKKNeue-Regular.woff2");
 | 
			
		||||
  font-display: swap;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,18 @@
 | 
			
		||||
 | 
			
		||||
    <meta name="theme-color" content="#000" />
 | 
			
		||||
 | 
			
		||||
    <!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
 | 
			
		||||
    <meta
 | 
			
		||||
      http-equiv="origin-trial"
 | 
			
		||||
      content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <!-- File Handling (https://web.dev/file-handling/) -->
 | 
			
		||||
    <meta
 | 
			
		||||
      http-equiv="origin-trial"
 | 
			
		||||
      content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <!-- General tags -->
 | 
			
		||||
    <meta
 | 
			
		||||
      name="description"
 | 
			
		||||
@@ -107,15 +119,17 @@
 | 
			
		||||
 | 
			
		||||
    <!-- FIXME: remove this when we update CRA (fix SW caching) -->
 | 
			
		||||
    <style>
 | 
			
		||||
      body {
 | 
			
		||||
      body,
 | 
			
		||||
      html {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
 | 
			
		||||
          Roboto, Helvetica, Arial, sans-serif;
 | 
			
		||||
        font-family: var(--ui-font);
 | 
			
		||||
        -webkit-text-size-adjust: 100%;
 | 
			
		||||
 | 
			
		||||
        width: 100vw;
 | 
			
		||||
        height: 100vh;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .visually-hidden {
 | 
			
		||||
@@ -125,6 +139,7 @@
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        clip: rect(1px, 1px, 1px, 1px);
 | 
			
		||||
        white-space: nowrap; /* added line */
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage {
 | 
			
		||||
@@ -149,6 +164,21 @@
 | 
			
		||||
      }
 | 
			
		||||
      #root {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        -webkit-touch-callout: none;
 | 
			
		||||
        -webkit-user-select: none;
 | 
			
		||||
        -khtml-user-select: none;
 | 
			
		||||
        -moz-user-select: none;
 | 
			
		||||
        -ms-user-select: none;
 | 
			
		||||
        user-select: none;
 | 
			
		||||
 | 
			
		||||
        @media screen and (min-width: 1200px) {
 | 
			
		||||
          -webkit-touch-callout: default;
 | 
			
		||||
          -webkit-user-select: auto;
 | 
			
		||||
          -khtml-user-select: auto;
 | 
			
		||||
          -moz-user-select: auto;
 | 
			
		||||
          -ms-user-select: auto;
 | 
			
		||||
          user-select: auto;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
  </head>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "capture_links": "new_client",
 | 
			
		||||
  "capture_links": "new-client",
 | 
			
		||||
  "share_target": {
 | 
			
		||||
    "action": "/web-share-target",
 | 
			
		||||
    "method": "POST",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								public/redacted-regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/redacted-regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/redacted-script-bold.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/redacted-script-bold.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/redacted-script-regular.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/redacted-script-regular.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/redacted-script-regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/redacted-script-regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/scribble-webfont.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/scribble-webfont.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										52
									
								
								scripts/autorelease.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								scripts/autorelease.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const { exec, execSync } = require("child_process");
 | 
			
		||||
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
const pkg = require(excalidrawPackage);
 | 
			
		||||
 | 
			
		||||
const getShortCommitHash = () => {
 | 
			
		||||
  return execSync("git rev-parse --short HEAD").toString().trim();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const publish = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    execSync(`yarn  --frozen-lockfile`);
 | 
			
		||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// get files changed between prev and head commit
 | 
			
		||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
			
		||||
  if (error || stderr) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const changedFiles = stdout.trim().split("\n");
 | 
			
		||||
  const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
 | 
			
		||||
 | 
			
		||||
  const excalidrawPackageFiles = changedFiles.filter((file) => {
 | 
			
		||||
    return (
 | 
			
		||||
      (file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
 | 
			
		||||
      !filesToIgnoreRegex.test(file)
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
  if (!excalidrawPackageFiles.length) {
 | 
			
		||||
    process.exit(0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // update package.json
 | 
			
		||||
  pkg.version = `${pkg.version}-${getShortCommitHash()}`;
 | 
			
		||||
  pkg.name = "@excalidraw/excalidraw-next";
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
  publish();
 | 
			
		||||
});
 | 
			
		||||
@@ -37,6 +37,9 @@ const crowdinMap = {
 | 
			
		||||
  "uk-UA": "en-uk",
 | 
			
		||||
  "zh-CN": "en-zhcn",
 | 
			
		||||
  "zh-TW": "en-zhtw",
 | 
			
		||||
  "lv-LV": "en-lv",
 | 
			
		||||
  "cs-CZ": "en-cs",
 | 
			
		||||
  "kk-KZ": "en-kk",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flags = {
 | 
			
		||||
@@ -74,6 +77,9 @@ const flags = {
 | 
			
		||||
  "uk-UA": "🇺🇦",
 | 
			
		||||
  "zh-CN": "🇨🇳",
 | 
			
		||||
  "zh-TW": "🇹🇼",
 | 
			
		||||
  "lv-LV": "🇱🇻",
 | 
			
		||||
  "cs-CZ": "🇨🇿",
 | 
			
		||||
  "kk-KZ": "🇰🇿",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const languages = {
 | 
			
		||||
@@ -111,6 +117,9 @@ const languages = {
 | 
			
		||||
  "uk-UA": "Українська",
 | 
			
		||||
  "zh-CN": "简体中文",
 | 
			
		||||
  "zh-TW": "繁體中文",
 | 
			
		||||
  "lv-LV": "Latviešu",
 | 
			
		||||
  "cs-CZ": "Česky",
 | 
			
		||||
  "kk-KZ": "Қазақ тілі",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const percentages = fs.readFileSync(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										39
									
								
								scripts/release.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								scripts/release.js
									
									
									
									
									
										Normal file
									
								
							@@ -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 (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
    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);
 | 
			
		||||
							
								
								
									
										97
									
								
								scripts/updateChangelog.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								scripts/updateChangelog.js
									
									
									
									
									
										Normal file
									
								
							@@ -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 (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
							
								
								
									
										27
									
								
								scripts/updateReadme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								scripts/updateReadme.js
									
									
									
									
									
										Normal file
									
								
							@@ -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,18 +2,20 @@ import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    Library.loadLibrary().then((items) => {
 | 
			
		||||
      Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
 | 
			
		||||
    app.library.loadLibrary().then((items) => {
 | 
			
		||||
      app.library.saveLibrary([
 | 
			
		||||
        ...items,
 | 
			
		||||
        selectedElements.map(deepCopyElement),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { alignElements, Alignment } from "../align";
 | 
			
		||||
import {
 | 
			
		||||
  AlignBottomIcon,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,11 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
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 { ZOOM_STEP } from "../constants";
 | 
			
		||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { THEME, ZOOM_STEP } from "../constants";
 | 
			
		||||
import { getCommonBounds, getNonDeletedElements } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
@@ -16,13 +13,17 @@ import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
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({
 | 
			
		||||
  name: "changeViewBackgroundColor",
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, viewBackgroundColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
      appState: { ...appState, ...value },
 | 
			
		||||
      commitToHistory: !!value.viewBackgroundColor,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => {
 | 
			
		||||
@@ -32,7 +33,11 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
          label={t("labels.canvasBackground")}
 | 
			
		||||
          type="canvasBackground"
 | 
			
		||||
          color={appState.viewBackgroundColor}
 | 
			
		||||
          onChange={(color) => updateData(color)}
 | 
			
		||||
          onChange={(color) => updateData({ viewBackgroundColor: color })}
 | 
			
		||||
          isActive={appState.openPopup === "canvasColorPicker"}
 | 
			
		||||
          setActive={(active) =>
 | 
			
		||||
            updateData({ openPopup: active ? "canvasColorPicker" : null })
 | 
			
		||||
          }
 | 
			
		||||
          data-testid="canvas-background-picker"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -42,40 +47,28 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionClearCanvas = register({
 | 
			
		||||
  name: "clearCanvas",
 | 
			
		||||
  perform: (elements, appState: AppState) => {
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    app.imageCache.clear();
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) =>
 | 
			
		||||
        newElementWith(element, { isDeleted: true }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...getDefaultAppState(),
 | 
			
		||||
        files: {},
 | 
			
		||||
        theme: appState.theme,
 | 
			
		||||
        elementLocked: appState.elementLocked,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        shouldAddWatermark: appState.shouldAddWatermark,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      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"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
 | 
			
		||||
  PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionZoomIn = register({
 | 
			
		||||
@@ -104,6 +97,7 @@ export const actionZoomIn = register({
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size="small"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -138,6 +132,7 @@ export const actionZoomOut = register({
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size="small"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -164,16 +159,21 @@ export const actionResetZoom = register({
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={resetZoom}
 | 
			
		||||
      title={t("buttons.resetZoom")}
 | 
			
		||||
      aria-label={t("buttons.resetZoom")}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <Tooltip label={t("buttons.resetZoom")}>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        type="button"
 | 
			
		||||
        className="reset-zoom-button"
 | 
			
		||||
        title={t("buttons.resetZoom")}
 | 
			
		||||
        aria-label={t("buttons.resetZoom")}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          updateData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        size="small"
 | 
			
		||||
      >
 | 
			
		||||
        {(appState.zoom.value * 100).toFixed(0)}%
 | 
			
		||||
      </ToolButton>
 | 
			
		||||
    </Tooltip>
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
 | 
			
		||||
@@ -260,3 +260,28 @@ export const actionZoomToFit = register({
 | 
			
		||||
    !event.altKey &&
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD],
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionToggleTheme = register({
 | 
			
		||||
  name: "toggleTheme",
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        theme:
 | 
			
		||||
          value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <div style={{ marginInlineStart: "0.25rem" }}>
 | 
			
		||||
      <DarkModeToggle
 | 
			
		||||
        value={appState.theme}
 | 
			
		||||
        onChange={(theme) => {
 | 
			
		||||
          updateData(theme);
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionCopy = register({
 | 
			
		||||
  name: "copy",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState);
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState, app.files);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
@@ -50,7 +50,7 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        app.files,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
@@ -89,7 +89,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        app.files,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { trash } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  DistributeHorizontallyIcon,
 | 
			
		||||
  DistributeVerticallyIcon,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,25 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { load, questionCircle, save, saveAs } from "../components/icons";
 | 
			
		||||
import { load, questionCircle, saveAs } from "../components/icons";
 | 
			
		||||
import { ProjectName } from "../components/ProjectName";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import "../components/ToolIcon.scss";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
 | 
			
		||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
			
		||||
import { resaveAsImageWithScene } from "../data/resave";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { supported } from "browser-fs-access";
 | 
			
		||||
import { CheckboxItem } from "../components/CheckboxItem";
 | 
			
		||||
import { getExportSize } from "../scene/export";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ActiveFile } from "../components/ActiveFile";
 | 
			
		||||
import { isImageFileHandle } from "../data/blob";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
import { Theme } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export const actionChangeProjectName = register({
 | 
			
		||||
  name: "changeProjectName",
 | 
			
		||||
@@ -31,6 +39,54 @@ export const actionChangeProjectName = register({
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportScale = register({
 | 
			
		||||
  name: "changeExportScale",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportScale: value },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements: allElements, appState, updateData }) => {
 | 
			
		||||
    const elements = getNonDeletedElements(allElements);
 | 
			
		||||
    const exportSelected = isSomeElementSelected(elements, appState);
 | 
			
		||||
    const exportedElements = exportSelected
 | 
			
		||||
      ? getSelectedElements(elements, appState)
 | 
			
		||||
      : elements;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {EXPORT_SCALES.map((s) => {
 | 
			
		||||
          const [width, height] = getExportSize(
 | 
			
		||||
            exportedElements,
 | 
			
		||||
            DEFAULT_EXPORT_PADDING,
 | 
			
		||||
            s,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          const scaleButtonTitle = `${t(
 | 
			
		||||
            "buttons.scale",
 | 
			
		||||
          )} ${s}x (${width}x${height})`;
 | 
			
		||||
 | 
			
		||||
          return (
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              key={s}
 | 
			
		||||
              size="small"
 | 
			
		||||
              type="radio"
 | 
			
		||||
              icon={`${s}x`}
 | 
			
		||||
              name="export-canvas-scale"
 | 
			
		||||
              title={scaleButtonTitle}
 | 
			
		||||
              aria-label={scaleButtonTitle}
 | 
			
		||||
              id="export-canvas-scale"
 | 
			
		||||
              checked={s === appState.exportScale}
 | 
			
		||||
              onChange={() => updateData(s)}
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportBackground = register({
 | 
			
		||||
  name: "changeExportBackground",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
@@ -40,14 +96,12 @@ export const actionChangeExportBackground = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <label>
 | 
			
		||||
      <input
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        checked={appState.exportBackground}
 | 
			
		||||
        onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
      />{" "}
 | 
			
		||||
    <CheckboxItem
 | 
			
		||||
      checked={appState.exportBackground}
 | 
			
		||||
      onChange={(checked) => updateData(checked)}
 | 
			
		||||
    >
 | 
			
		||||
      {t("labels.withBackground")}
 | 
			
		||||
    </label>
 | 
			
		||||
    </CheckboxItem>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -60,57 +114,35 @@ export const actionChangeExportEmbedScene = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <label style={{ display: "flex" }}>
 | 
			
		||||
      <input
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        checked={appState.exportEmbedScene}
 | 
			
		||||
        onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
      />{" "}
 | 
			
		||||
    <CheckboxItem
 | 
			
		||||
      checked={appState.exportEmbedScene}
 | 
			
		||||
      onChange={(checked) => updateData(checked)}
 | 
			
		||||
    >
 | 
			
		||||
      {t("labels.exportEmbedScene")}
 | 
			
		||||
      <Tooltip
 | 
			
		||||
        label={t("labels.exportEmbedScene_details")}
 | 
			
		||||
        position="above"
 | 
			
		||||
        long={true}
 | 
			
		||||
      >
 | 
			
		||||
        <div className="TooltipIcon">{questionCircle}</div>
 | 
			
		||||
      <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
 | 
			
		||||
        <div className="excalidraw-tooltip-icon">{questionCircle}</div>
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </label>
 | 
			
		||||
    </CheckboxItem>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeShouldAddWatermark = register({
 | 
			
		||||
  name: "changeShouldAddWatermark",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, shouldAddWatermark: value },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <label>
 | 
			
		||||
      <input
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        checked={appState.shouldAddWatermark}
 | 
			
		||||
        onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
      />{" "}
 | 
			
		||||
      {t("labels.addWatermark")}
 | 
			
		||||
    </label>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionSaveScene = register({
 | 
			
		||||
  name: "saveScene",
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
export const actionSaveToActiveFile = register({
 | 
			
		||||
  name: "saveToActiveFile",
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    const fileHandleExists = !!appState.fileHandle;
 | 
			
		||||
 | 
			
		||||
    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 {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          fileHandle,
 | 
			
		||||
          toastMessage: fileHandleExists
 | 
			
		||||
            ? fileHandle.name
 | 
			
		||||
            ? fileHandle?.name
 | 
			
		||||
              ? t("toast.fileSavedToFilename").replace(
 | 
			
		||||
                  "{filename}",
 | 
			
		||||
                  `"${fileHandle.name}"`,
 | 
			
		||||
@@ -128,27 +160,26 @@ export const actionSaveScene = register({
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={save}
 | 
			
		||||
      title={t("buttons.save")}
 | 
			
		||||
      aria-label={t("buttons.save")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-button"
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <ActiveFile
 | 
			
		||||
      onSave={() => updateData(null)}
 | 
			
		||||
      fileName={appState.fileHandle?.name}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionSaveAsScene = register({
 | 
			
		||||
  name: "saveAsScene",
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
export const actionSaveFileToDisk = register({
 | 
			
		||||
  name: "saveFileToDisk",
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(elements, {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        fileHandle: null,
 | 
			
		||||
      });
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(
 | 
			
		||||
        elements,
 | 
			
		||||
        {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          fileHandle: null,
 | 
			
		||||
        },
 | 
			
		||||
        app.files,
 | 
			
		||||
      );
 | 
			
		||||
      return { commitToHistory: false, appState: { ...appState, fileHandle } };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
@@ -166,7 +197,7 @@ export const actionSaveAsScene = register({
 | 
			
		||||
      title={t("buttons.saveAs")}
 | 
			
		||||
      aria-label={t("buttons.saveAs")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      hidden={!supported}
 | 
			
		||||
      hidden={!nativeFileSystemSupported}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-as-button"
 | 
			
		||||
    />
 | 
			
		||||
@@ -175,15 +206,17 @@ export const actionSaveAsScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  perform: async (elements, appState) => {
 | 
			
		||||
  perform: async (elements, appState, _, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
      } = await loadFromJSON(appState);
 | 
			
		||||
        files,
 | 
			
		||||
      } = await loadFromJSON(appState, elements);
 | 
			
		||||
      return {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
        files,
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
@@ -193,6 +226,7 @@ export const actionLoadScene = register({
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
        appState: { ...appState, errorMessage: error.message },
 | 
			
		||||
        files: app.files,
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
@@ -229,46 +263,12 @@ export const actionExportWithDarkMode = register({
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <DarkModeToggle
 | 
			
		||||
        value={appState.exportWithDarkMode ? "dark" : "light"}
 | 
			
		||||
        onChange={(theme: Appearence) => {
 | 
			
		||||
          updateData(theme === "dark");
 | 
			
		||||
        value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
 | 
			
		||||
        onChange={(theme: Theme) => {
 | 
			
		||||
          updateData(theme === THEME.DARK);
 | 
			
		||||
        }}
 | 
			
		||||
        title={t("labels.toggleExportColorScheme")}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionToggleAutosave = register({
 | 
			
		||||
  name: "toggleAutosave",
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("toggle", "autosave");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        autosave: !appState.autosave,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) =>
 | 
			
		||||
    supported && appState.fileHandle ? (
 | 
			
		||||
      <label style={{ display: "flex" }}>
 | 
			
		||||
        <input
 | 
			
		||||
          type="checkbox"
 | 
			
		||||
          checked={appState.autosave}
 | 
			
		||||
          onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
        />{" "}
 | 
			
		||||
        {t("labels.toggleAutosave")}
 | 
			
		||||
        <Tooltip
 | 
			
		||||
          label={t("labels.toggleAutosave_details")}
 | 
			
		||||
          position="above"
 | 
			
		||||
          long={true}
 | 
			
		||||
        >
 | 
			
		||||
          <div className="TooltipIcon">{questionCircle}</div>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </label>
 | 
			
		||||
    ) : (
 | 
			
		||||
      <></>
 | 
			
		||||
    ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { isInvisiblySmallElement } from "../element";
 | 
			
		||||
import { resetCursor } from "../utils";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { done } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -18,7 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionFinalize = register({
 | 
			
		||||
  name: "finalize",
 | 
			
		||||
  perform: (elements, appState, _, { canvas }) => {
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
        elementId,
 | 
			
		||||
@@ -50,20 +49,25 @@ export const actionFinalize = register({
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let newElements = elements;
 | 
			
		||||
 | 
			
		||||
    if (appState.pendingImageElement) {
 | 
			
		||||
      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (window.document.activeElement instanceof HTMLElement) {
 | 
			
		||||
      window.document.activeElement.blur();
 | 
			
		||||
      focusContainer();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const multiPointElement = appState.multiElement
 | 
			
		||||
      ? appState.multiElement
 | 
			
		||||
      : appState.editingElement?.type === "draw"
 | 
			
		||||
      : appState.editingElement?.type === "freedraw"
 | 
			
		||||
      ? appState.editingElement
 | 
			
		||||
      : null;
 | 
			
		||||
 | 
			
		||||
    if (multiPointElement) {
 | 
			
		||||
      // pen and mouse have hover
 | 
			
		||||
      if (
 | 
			
		||||
        multiPointElement.type !== "draw" &&
 | 
			
		||||
        multiPointElement.type !== "freedraw" &&
 | 
			
		||||
        appState.lastPointerDownWith !== "touch"
 | 
			
		||||
      ) {
 | 
			
		||||
        const { points, lastCommittedPoint } = multiPointElement;
 | 
			
		||||
@@ -86,7 +90,7 @@ export const actionFinalize = register({
 | 
			
		||||
      const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
 | 
			
		||||
      if (
 | 
			
		||||
        multiPointElement.type === "line" ||
 | 
			
		||||
        multiPointElement.type === "draw"
 | 
			
		||||
        multiPointElement.type === "freedraw"
 | 
			
		||||
      ) {
 | 
			
		||||
        if (isLoop) {
 | 
			
		||||
          const linePoints = multiPointElement.points;
 | 
			
		||||
@@ -118,22 +122,24 @@ export const actionFinalize = register({
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "draw") {
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "freedraw") {
 | 
			
		||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "draw") ||
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "freedraw") ||
 | 
			
		||||
      !multiPointElement
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor(canvas);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      elements: newElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        elementType:
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "draw") &&
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "freedraw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.elementType
 | 
			
		||||
            : "selection",
 | 
			
		||||
@@ -145,14 +151,15 @@ export const actionFinalize = register({
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "draw"
 | 
			
		||||
          appState.elementType !== "freedraw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
              }
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
        pendingImageElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.elementType === "draw",
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
			
		||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { getTransformHandles } from "../element/transformHandles";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { updateBoundElements } from "../element/binding";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
 | 
			
		||||
@@ -93,13 +93,13 @@ const flipElements = (
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  flipDirection: "horizontal" | "vertical",
 | 
			
		||||
): ExcalidrawElement[] => {
 | 
			
		||||
  for (let i = 0; i < elements.length; i++) {
 | 
			
		||||
    flipElement(elements[i], appState);
 | 
			
		||||
  elements.forEach((element) => {
 | 
			
		||||
    flipElement(element, appState);
 | 
			
		||||
    // If vertical flip, rotate an extra 180
 | 
			
		||||
    if (flipDirection === "vertical") {
 | 
			
		||||
      rotateElement(elements[i], Math.PI);
 | 
			
		||||
      rotateElement(element, Math.PI);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  });
 | 
			
		||||
  return elements;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -114,7 +114,7 @@ const flipElement = (
 | 
			
		||||
  const originalAngle = normalizeAngle(element.angle);
 | 
			
		||||
 | 
			
		||||
  let finalOffsetX = 0;
 | 
			
		||||
  if (isLinearElement(element)) {
 | 
			
		||||
  if (isLinearElement(element) || isFreeDrawElement(element)) {
 | 
			
		||||
    finalOffsetX =
 | 
			
		||||
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
 | 
			
		||||
      element.width;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,8 @@
 | 
			
		||||
import { Action, ActionResult } from "./types";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { undo, redo } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { SceneHistory, HistoryEntry } from "../history";
 | 
			
		||||
import History, { HistoryEntry } from "../history";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isWindows, KEYS } from "../keys";
 | 
			
		||||
@@ -59,7 +58,7 @@ const writeData = (
 | 
			
		||||
  return { commitToHistory };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ActionCreator = (history: SceneHistory) => Action;
 | 
			
		||||
type ActionCreator = (history: History) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
@@ -69,12 +68,13 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
    event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
    event.key.toLowerCase() === KEYS.Z &&
 | 
			
		||||
    !event.shiftKey,
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={undo}
 | 
			
		||||
      aria-label={t("buttons.undo")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  commitToHistory: () => false,
 | 
			
		||||
@@ -89,12 +89,13 @@ export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      event.key.toLowerCase() === KEYS.Z) ||
 | 
			
		||||
    (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={redo}
 | 
			
		||||
      aria-label={t("buttons.redo")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  commitToHistory: () => false,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { menu, palette } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -70,7 +69,10 @@ export const actionFullScreen = register({
 | 
			
		||||
 | 
			
		||||
export const actionShortcuts = register({
 | 
			
		||||
  name: "toggleShortcuts",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
  perform: (_elements, appState, _, { focusContainer }) => {
 | 
			
		||||
    if (appState.showHelpDialog) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { getClientColors, getClientInitials } from "../clients";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
@@ -30,8 +29,8 @@ export const actionGoToCollaborator = register({
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, id }) => {
 | 
			
		||||
    const clientId = id;
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => {
 | 
			
		||||
    const clientId: string | undefined = data?.id;
 | 
			
		||||
    if (!clientId) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AppState } from "../../src/types";
 | 
			
		||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
@@ -7,12 +6,20 @@ import {
 | 
			
		||||
  ArrowheadArrowIcon,
 | 
			
		||||
  ArrowheadBarIcon,
 | 
			
		||||
  ArrowheadDotIcon,
 | 
			
		||||
  ArrowheadTriangleIcon,
 | 
			
		||||
  ArrowheadNoneIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  FillCrossHatchIcon,
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  FillSolidIcon,
 | 
			
		||||
  // FontFamilyCodeIcon,
 | 
			
		||||
  // FontFamilyHandDrawnIcon,
 | 
			
		||||
  // FontFamilyNormalIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  SloppinessArchitectIcon,
 | 
			
		||||
  SloppinessArtistIcon,
 | 
			
		||||
  SloppinessCartoonistIcon,
 | 
			
		||||
@@ -20,18 +27,15 @@ import {
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  StrokeStyleSolidIcon,
 | 
			
		||||
  StrokeWidthIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  FontFamilyHandDrawnIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  TextAlignRightIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  //FONT_FAMILY,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
@@ -44,7 +48,7 @@ import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  FontFamily,
 | 
			
		||||
  //FontFamilyValues,
 | 
			
		||||
  TextAlign,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getLanguage, t } from "../i18n";
 | 
			
		||||
@@ -56,7 +60,9 @@ import {
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import FontsList from "../components/FontList";
 | 
			
		||||
 | 
			
		||||
const changeProperty = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -99,13 +105,20 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
          strokeColor: value,
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
        elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
          return hasStrokeColor(el.type)
 | 
			
		||||
            ? newElementWith(el, {
 | 
			
		||||
                strokeColor: value.currentItemStrokeColor,
 | 
			
		||||
              })
 | 
			
		||||
            : el;
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: { ...appState, currentItemStrokeColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...value,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: !!value.currentItemStrokeColor,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
@@ -120,7 +133,11 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
          (element) => element.strokeColor,
 | 
			
		||||
          appState.currentItemStrokeColor,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={updateData}
 | 
			
		||||
        onChange={(color) => updateData({ currentItemStrokeColor: color })}
 | 
			
		||||
        isActive={appState.openPopup === "strokeColorPicker"}
 | 
			
		||||
        setActive={(active) =>
 | 
			
		||||
          updateData({ openPopup: active ? "strokeColorPicker" : null })
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -130,13 +147,18 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
  name: "changeBackgroundColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
          backgroundColor: value,
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: { ...appState, currentItemBackgroundColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
      ...(value.currentItemBackgroundColor && {
 | 
			
		||||
        elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
          newElementWith(el, {
 | 
			
		||||
            backgroundColor: value.currentItemBackgroundColor,
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...value,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: !!value.currentItemBackgroundColor,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
@@ -151,7 +173,11 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
          (element) => element.backgroundColor,
 | 
			
		||||
          appState.currentItemBackgroundColor,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={updateData}
 | 
			
		||||
        onChange={(color) => updateData({ currentItemBackgroundColor: color })}
 | 
			
		||||
        isActive={appState.openPopup === "backgroundColorPicker"}
 | 
			
		||||
        setActive={(active) =>
 | 
			
		||||
          updateData({ openPopup: active ? "backgroundColorPicker" : null })
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -481,37 +507,43 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
 | 
			
		||||
      {
 | 
			
		||||
        value: 1,
 | 
			
		||||
        text: t("labels.handDrawn"),
 | 
			
		||||
        icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: 2,
 | 
			
		||||
        text: t("labels.normal"),
 | 
			
		||||
        icon: <FontFamilyNormalIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: 3,
 | 
			
		||||
        text: t("labels.code"),
 | 
			
		||||
        icon: <FontFamilyCodeIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
    // const options: {
 | 
			
		||||
    //   value: FontFamilyValues;
 | 
			
		||||
    //   text: string;
 | 
			
		||||
    //   icon: JSX.Element;
 | 
			
		||||
    // }[] = [
 | 
			
		||||
    //   {
 | 
			
		||||
    //     value: FONT_FAMILY.Virgil,
 | 
			
		||||
    //     text: t("labels.handDrawn"),
 | 
			
		||||
    //     icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
 | 
			
		||||
    //   },
 | 
			
		||||
    //   {
 | 
			
		||||
    //     value: FONT_FAMILY.Helvetica,
 | 
			
		||||
    //     text: t("labels.normal"),
 | 
			
		||||
    //     icon: <FontFamilyNormalIcon theme={appState.theme} />,
 | 
			
		||||
    //   },
 | 
			
		||||
    //   {
 | 
			
		||||
    //     value: FONT_FAMILY.Cascadia,
 | 
			
		||||
    //     text: t("labels.code"),
 | 
			
		||||
    //     icon: <FontFamilyCodeIcon theme={appState.theme} />,
 | 
			
		||||
    //   },
 | 
			
		||||
    // ];
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.fontFamily")}</legend>
 | 
			
		||||
        <ButtonIconSelect<FontFamily | false>
 | 
			
		||||
          group="font-family"
 | 
			
		||||
          options={options}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) => isTextElement(element) && element.fontFamily,
 | 
			
		||||
            appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
          )}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
        <FontsList
 | 
			
		||||
          onChange={(val) => {
 | 
			
		||||
            updateData(val);
 | 
			
		||||
          }}
 | 
			
		||||
          currentFontFamily={
 | 
			
		||||
            getFormValue(
 | 
			
		||||
              elements,
 | 
			
		||||
              appState,
 | 
			
		||||
              (element) => isTextElement(element) && element.fontFamily,
 | 
			
		||||
              appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
            ) || DEFAULT_FONT_FAMILY
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    );
 | 
			
		||||
@@ -710,6 +742,14 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
                icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
 | 
			
		||||
                keyBinding: "r",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "triangle",
 | 
			
		||||
                text: t("labels.arrowhead_triangle"),
 | 
			
		||||
                icon: (
 | 
			
		||||
                  <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
 | 
			
		||||
                ),
 | 
			
		||||
                keyBinding: "t",
 | 
			
		||||
              },
 | 
			
		||||
            ]}
 | 
			
		||||
            value={getFormValue<Arrowhead | null>(
 | 
			
		||||
              elements,
 | 
			
		||||
@@ -752,6 +792,14 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
                keyBinding: "r",
 | 
			
		||||
                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>(
 | 
			
		||||
              elements,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
@@ -13,4 +14,6 @@ export const actionToggleStats = register({
 | 
			
		||||
  },
 | 
			
		||||
  checked: (appState) => appState.showStats,
 | 
			
		||||
  contextItemLabel: "stats.title",
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,6 @@ export const actionToggleViewMode = register({
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        viewModeEnabled: !this.checked!(appState),
 | 
			
		||||
        selectedElementIds: {},
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,7 @@ export {
 | 
			
		||||
  actionZoomOut,
 | 
			
		||||
  actionResetZoom,
 | 
			
		||||
  actionZoomToFit,
 | 
			
		||||
  actionToggleTheme,
 | 
			
		||||
} from "./actionCanvas";
 | 
			
		||||
 | 
			
		||||
export { actionFinalize } from "./actionFinalize";
 | 
			
		||||
@@ -33,9 +34,8 @@ export { actionFinalize } from "./actionFinalize";
 | 
			
		||||
export {
 | 
			
		||||
  actionChangeProjectName,
 | 
			
		||||
  actionChangeExportBackground,
 | 
			
		||||
  actionToggleAutosave,
 | 
			
		||||
  actionSaveScene,
 | 
			
		||||
  actionSaveAsScene,
 | 
			
		||||
  actionSaveToActiveFile,
 | 
			
		||||
  actionSaveFileToDisk,
 | 
			
		||||
  actionLoadScene,
 | 
			
		||||
} from "./actionExport";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,15 +5,12 @@ import {
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
  ActionResult,
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppProps, AppState } from "../types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { MODES } from "../constants";
 | 
			
		||||
 | 
			
		||||
// This is the <App> component, but for now we don't care about anything but its
 | 
			
		||||
// `canvas` state.
 | 
			
		||||
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
 | 
			
		||||
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
 | 
			
		||||
@@ -21,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
 | 
			
		||||
  getAppState: () => Readonly<AppState>;
 | 
			
		||||
  getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
 | 
			
		||||
  app: App;
 | 
			
		||||
  app: AppClassProperties;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    updater: UpdaterFn,
 | 
			
		||||
    getAppState: () => AppState,
 | 
			
		||||
    getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
 | 
			
		||||
    app: App,
 | 
			
		||||
    app: AppClassProperties,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.updater = (actionResult) => {
 | 
			
		||||
      if (actionResult && "then" in actionResult) {
 | 
			
		||||
@@ -51,7 +48,7 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
    actions.forEach((action) => this.registerAction(action));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleKeyDown(event: KeyboardEvent) {
 | 
			
		||||
  handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
 | 
			
		||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
			
		||||
    const data = Object.values(this.actions)
 | 
			
		||||
      .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
 | 
			
		||||
@@ -101,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
 | 
			
		||||
  // 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, id?: string) => {
 | 
			
		||||
  /**
 | 
			
		||||
   * @param data additional data sent to the PanelComponent
 | 
			
		||||
   */
 | 
			
		||||
  renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
 | 
			
		||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
@@ -133,8 +129,8 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
          elements={this.getElementsIncludingDeleted()}
 | 
			
		||||
          appState={this.getAppState()}
 | 
			
		||||
          updateData={updateData}
 | 
			
		||||
          id={id}
 | 
			
		||||
          appProps={this.app.props}
 | 
			
		||||
          data={data}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -57,7 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
 | 
			
		||||
  gridMode: [getShortcutKey("CtrlOrCmd+'")],
 | 
			
		||||
  zenMode: [getShortcutKey("Alt+Z")],
 | 
			
		||||
  stats: [],
 | 
			
		||||
  stats: [getShortcutKey("Alt+/")],
 | 
			
		||||
  addToLibrary: [],
 | 
			
		||||
  flipHorizontal: [getShortcutKey("Shift+H")],
 | 
			
		||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,12 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState, ExcalidrawProps } from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  AppClassProperties,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
export type ActionResult =
 | 
			
		||||
@@ -10,8 +16,10 @@ export type ActionResult =
 | 
			
		||||
        AppState,
 | 
			
		||||
        "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
      > | null;
 | 
			
		||||
      files?: BinaryFiles | null;
 | 
			
		||||
      commitToHistory: boolean;
 | 
			
		||||
      syncHistory?: boolean;
 | 
			
		||||
      replaceFiles?: boolean;
 | 
			
		||||
    }
 | 
			
		||||
  | false;
 | 
			
		||||
 | 
			
		||||
@@ -19,7 +27,7 @@ type ActionFn = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  formData: any,
 | 
			
		||||
  app: { canvas: HTMLCanvasElement | null },
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
) => ActionResult | Promise<ActionResult>;
 | 
			
		||||
 | 
			
		||||
export type UpdaterFn = (res: ActionResult) => void;
 | 
			
		||||
@@ -45,13 +53,13 @@ export type ActionName =
 | 
			
		||||
  | "changeBackgroundColor"
 | 
			
		||||
  | "changeFillStyle"
 | 
			
		||||
  | "changeStrokeWidth"
 | 
			
		||||
  | "changeStrokeShape"
 | 
			
		||||
  | "changeSloppiness"
 | 
			
		||||
  | "changeStrokeStyle"
 | 
			
		||||
  | "changeArrowhead"
 | 
			
		||||
  | "changeOpacity"
 | 
			
		||||
  | "changeFontSize"
 | 
			
		||||
  | "toggleCanvasMenu"
 | 
			
		||||
  | "toggleAutosave"
 | 
			
		||||
  | "toggleEditMenu"
 | 
			
		||||
  | "undo"
 | 
			
		||||
  | "redo"
 | 
			
		||||
@@ -59,9 +67,9 @@ export type ActionName =
 | 
			
		||||
  | "changeProjectName"
 | 
			
		||||
  | "changeExportBackground"
 | 
			
		||||
  | "changeExportEmbedScene"
 | 
			
		||||
  | "changeShouldAddWatermark"
 | 
			
		||||
  | "saveScene"
 | 
			
		||||
  | "saveAsScene"
 | 
			
		||||
  | "changeExportScale"
 | 
			
		||||
  | "saveToActiveFile"
 | 
			
		||||
  | "saveFileToDisk"
 | 
			
		||||
  | "loadScene"
 | 
			
		||||
  | "duplicateSelection"
 | 
			
		||||
  | "deleteSelectedElements"
 | 
			
		||||
@@ -92,21 +100,24 @@ export type ActionName =
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "exportWithDarkMode";
 | 
			
		||||
  | "exportWithDarkMode"
 | 
			
		||||
  | "toggleTheme";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  updateData: (formData?: any) => void;
 | 
			
		||||
  appProps: ExcalidrawProps;
 | 
			
		||||
  data?: Partial<{ id: string; size: ToolButtonSize }>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface Action {
 | 
			
		||||
  name: ActionName;
 | 
			
		||||
  PanelComponent?: React.FC<{
 | 
			
		||||
    elements: readonly ExcalidrawElement[];
 | 
			
		||||
    appState: AppState;
 | 
			
		||||
    updateData: (formData?: any) => void;
 | 
			
		||||
    appProps: ExcalidrawProps;
 | 
			
		||||
    id?: string;
 | 
			
		||||
  }>;
 | 
			
		||||
  PanelComponent?: React.FC<PanelComponentProps>;
 | 
			
		||||
  perform: ActionFn;
 | 
			
		||||
  keyPriority?: number;
 | 
			
		||||
  keyTest?: (
 | 
			
		||||
    event: KeyboardEvent,
 | 
			
		||||
    event: React.KeyboardEvent | KeyboardEvent,
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
@@ -121,6 +132,7 @@ export interface Action {
 | 
			
		||||
export interface ActionsManagerInterface {
 | 
			
		||||
  actions: Record<ActionName, Action>;
 | 
			
		||||
  registerAction: (action: Action) => void;
 | 
			
		||||
  handleKeyDown: (event: KeyboardEvent) => boolean;
 | 
			
		||||
  handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
 | 
			
		||||
  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
			
		||||
  executeAction: (action: Action) => void;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										162
									
								
								src/appState.ts
									
									
									
									
									
								
							
							
						
						
									
										162
									
								
								src/appState.ts
									
									
									
									
									
								
							@@ -3,18 +3,23 @@ import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
  EXPORT_SCALES,
 | 
			
		||||
  THEME,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { t } from "./i18n";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "./types";
 | 
			
		||||
import { getDateTime } from "./utils";
 | 
			
		||||
 | 
			
		||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
 | 
			
		||||
  ? devicePixelRatio
 | 
			
		||||
  : 1;
 | 
			
		||||
 | 
			
		||||
export const getDefaultAppState = (): Omit<
 | 
			
		||||
  AppState,
 | 
			
		||||
  "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
> => {
 | 
			
		||||
  return {
 | 
			
		||||
    autosave: false,
 | 
			
		||||
    theme: "light",
 | 
			
		||||
    theme: THEME.LIGHT,
 | 
			
		||||
    collaborators: new Map(),
 | 
			
		||||
    currentChartType: "bar",
 | 
			
		||||
    currentItemBackgroundColor: "transparent",
 | 
			
		||||
@@ -40,6 +45,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
    exportBackground: true,
 | 
			
		||||
    exportScale: defaultExportScale,
 | 
			
		||||
    exportEmbedScene: false,
 | 
			
		||||
    exportWithDarkMode: false,
 | 
			
		||||
    fileHandle: null,
 | 
			
		||||
@@ -53,6 +59,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    multiElement: null,
 | 
			
		||||
    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
			
		||||
    openMenu: null,
 | 
			
		||||
    openPopup: null,
 | 
			
		||||
    pasteDialog: { shown: false, data: null },
 | 
			
		||||
    previousSelectedElementIds: {},
 | 
			
		||||
    resizingElement: null,
 | 
			
		||||
@@ -62,7 +69,6 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    selectedElementIds: {},
 | 
			
		||||
    selectedGroupIds: {},
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    shouldAddWatermark: false,
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showHelpDialog: false,
 | 
			
		||||
    showStats: false,
 | 
			
		||||
@@ -73,6 +79,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
 | 
			
		||||
    viewModeEnabled: false,
 | 
			
		||||
    pendingImageElement: null,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -86,78 +93,87 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
    browser: boolean;
 | 
			
		||||
    /** whether to keep when exporting to file/database */
 | 
			
		||||
    export: boolean;
 | 
			
		||||
    /** server (shareLink/collab/...) */
 | 
			
		||||
    server: boolean;
 | 
			
		||||
  },
 | 
			
		||||
  T extends Record<keyof AppState, Values>
 | 
			
		||||
>(
 | 
			
		||||
  config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
 | 
			
		||||
) => config)({
 | 
			
		||||
  autosave: { browser: true, export: false },
 | 
			
		||||
  theme: { browser: true, export: false },
 | 
			
		||||
  collaborators: { browser: false, export: false },
 | 
			
		||||
  currentChartType: { browser: true, export: false },
 | 
			
		||||
  currentItemBackgroundColor: { browser: true, export: false },
 | 
			
		||||
  currentItemEndArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemFillStyle: { browser: true, export: false },
 | 
			
		||||
  currentItemFontFamily: { browser: true, export: false },
 | 
			
		||||
  currentItemFontSize: { browser: true, export: false },
 | 
			
		||||
  currentItemLinearStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemOpacity: { browser: true, export: false },
 | 
			
		||||
  currentItemRoughness: { browser: true, export: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeColor: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeStyle: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeWidth: { browser: true, export: false },
 | 
			
		||||
  currentItemTextAlign: { browser: true, export: false },
 | 
			
		||||
  cursorButton: { browser: true, export: false },
 | 
			
		||||
  draggingElement: { browser: false, export: false },
 | 
			
		||||
  editingElement: { browser: false, export: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false },
 | 
			
		||||
  elementLocked: { browser: true, export: false },
 | 
			
		||||
  elementType: { browser: true, export: false },
 | 
			
		||||
  errorMessage: { browser: false, export: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false },
 | 
			
		||||
  exportWithDarkMode: { browser: true, export: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false },
 | 
			
		||||
  gridSize: { browser: true, export: true },
 | 
			
		||||
  height: { browser: false, export: false },
 | 
			
		||||
  isBindingEnabled: { browser: false, export: false },
 | 
			
		||||
  isLibraryOpen: { browser: false, export: false },
 | 
			
		||||
  isLoading: { browser: false, export: false },
 | 
			
		||||
  isResizing: { browser: false, export: false },
 | 
			
		||||
  isRotating: { browser: false, export: false },
 | 
			
		||||
  lastPointerDownWith: { browser: true, export: false },
 | 
			
		||||
  multiElement: { browser: false, export: false },
 | 
			
		||||
  name: { browser: true, export: false },
 | 
			
		||||
  offsetLeft: { browser: false, export: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false },
 | 
			
		||||
  openMenu: { browser: true, export: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false },
 | 
			
		||||
  scrolledOutside: { browser: true, export: false },
 | 
			
		||||
  scrollX: { browser: true, export: false },
 | 
			
		||||
  scrollY: { browser: true, export: false },
 | 
			
		||||
  selectedElementIds: { browser: true, export: false },
 | 
			
		||||
  selectedGroupIds: { browser: true, export: false },
 | 
			
		||||
  selectionElement: { browser: false, export: false },
 | 
			
		||||
  shouldAddWatermark: { browser: true, export: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false },
 | 
			
		||||
  showHelpDialog: { browser: false, export: false },
 | 
			
		||||
  showStats: { browser: true, export: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false },
 | 
			
		||||
  toastMessage: { browser: false, export: false },
 | 
			
		||||
  viewBackgroundColor: { browser: true, export: true },
 | 
			
		||||
  width: { browser: false, export: false },
 | 
			
		||||
  zenModeEnabled: { browser: true, export: false },
 | 
			
		||||
  zoom: { browser: true, export: false },
 | 
			
		||||
  viewModeEnabled: { browser: false, export: false },
 | 
			
		||||
  theme: { browser: true, export: false, server: false },
 | 
			
		||||
  collaborators: { browser: false, export: false, server: false },
 | 
			
		||||
  currentChartType: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemBackgroundColor: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemEndArrowhead: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemFillStyle: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemFontFamily: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemFontSize: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemLinearStrokeSharpness: {
 | 
			
		||||
    browser: true,
 | 
			
		||||
    export: false,
 | 
			
		||||
    server: false,
 | 
			
		||||
  },
 | 
			
		||||
  currentItemOpacity: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemRoughness: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeColor: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeSharpness: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeStyle: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeWidth: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemTextAlign: { browser: true, export: false, server: false },
 | 
			
		||||
  cursorButton: { browser: true, export: false, server: false },
 | 
			
		||||
  draggingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false, server: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false, server: false },
 | 
			
		||||
  elementLocked: { browser: true, export: false, server: false },
 | 
			
		||||
  elementType: { browser: true, export: false, server: false },
 | 
			
		||||
  errorMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false, server: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false, server: false },
 | 
			
		||||
  exportScale: { browser: true, export: false, server: false },
 | 
			
		||||
  exportWithDarkMode: { browser: true, export: false, server: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false, server: false },
 | 
			
		||||
  gridSize: { browser: true, export: true, server: true },
 | 
			
		||||
  height: { browser: false, export: false, server: false },
 | 
			
		||||
  isBindingEnabled: { browser: false, export: false, server: false },
 | 
			
		||||
  isLibraryOpen: { browser: false, export: false, server: false },
 | 
			
		||||
  isLoading: { browser: false, export: false, server: false },
 | 
			
		||||
  isResizing: { browser: false, export: false, server: false },
 | 
			
		||||
  isRotating: { browser: false, export: false, server: false },
 | 
			
		||||
  lastPointerDownWith: { browser: true, export: false, server: false },
 | 
			
		||||
  multiElement: { browser: false, export: false, server: false },
 | 
			
		||||
  name: { browser: true, export: false, server: false },
 | 
			
		||||
  offsetLeft: { browser: false, export: false, server: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false, server: false },
 | 
			
		||||
  openMenu: { browser: true, export: false, server: false },
 | 
			
		||||
  openPopup: { browser: false, export: false, server: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false, server: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  scrolledOutside: { browser: true, export: false, server: false },
 | 
			
		||||
  scrollX: { browser: true, export: false, server: false },
 | 
			
		||||
  scrollY: { browser: true, export: false, server: false },
 | 
			
		||||
  selectedElementIds: { browser: true, export: false, server: false },
 | 
			
		||||
  selectedGroupIds: { browser: true, export: false, server: false },
 | 
			
		||||
  selectionElement: { browser: false, export: false, server: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
 | 
			
		||||
  showHelpDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  showStats: { browser: true, export: false, server: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false, server: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false, server: false },
 | 
			
		||||
  toastMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  viewBackgroundColor: { browser: true, export: true, server: true },
 | 
			
		||||
  width: { browser: false, export: false, server: false },
 | 
			
		||||
  zenModeEnabled: { browser: true, export: false, server: false },
 | 
			
		||||
  zoom: { browser: true, export: false, server: false },
 | 
			
		||||
  viewModeEnabled: { browser: false, export: false, server: false },
 | 
			
		||||
  pendingImageElement: { browser: false, export: false, server: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
 | 
			
		||||
const _clearAppStateForStorage = <
 | 
			
		||||
  ExportType extends "export" | "browser" | "server"
 | 
			
		||||
>(
 | 
			
		||||
  appState: Partial<AppState>,
 | 
			
		||||
  exportType: ExportType,
 | 
			
		||||
) => {
 | 
			
		||||
@@ -170,8 +186,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
 | 
			
		||||
  for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
 | 
			
		||||
    const propConfig = APP_STATE_STORAGE_CONF[key];
 | 
			
		||||
    if (propConfig?.[exportType]) {
 | 
			
		||||
      // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
 | 
			
		||||
      stateForExport[key] = appState[key];
 | 
			
		||||
      const nextValue = appState[key];
 | 
			
		||||
 | 
			
		||||
      // https://github.com/microsoft/TypeScript/issues/31445
 | 
			
		||||
      (stateForExport as any)[key] = nextValue;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return stateForExport;
 | 
			
		||||
@@ -184,3 +202,7 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
 | 
			
		||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
 | 
			
		||||
  return _clearAppStateForStorage(appState, "export");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
 | 
			
		||||
  return _clearAppStateForStorage(appState, "server");
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -3,17 +3,26 @@ import {
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "./element/types";
 | 
			
		||||
import { getSelectedElements } from "./scene";
 | 
			
		||||
import { AppState } from "./types";
 | 
			
		||||
import { AppState, BinaryFiles } from "./types";
 | 
			
		||||
import { SVG_EXPORT_TAG } from "./scene/export";
 | 
			
		||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import { canvasToBlob } from "./data/blob";
 | 
			
		||||
import { EXPORT_DATA_TYPES } from "./constants";
 | 
			
		||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 | 
			
		||||
import { isInitializedImageElement } from "./element/typeChecks";
 | 
			
		||||
 | 
			
		||||
type ElementsClipboard = {
 | 
			
		||||
  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
			
		||||
  elements: ExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface ClipboardData {
 | 
			
		||||
  spreadsheet?: Spreadsheet;
 | 
			
		||||
  elements?: readonly ExcalidrawElement[];
 | 
			
		||||
  files?: BinaryFiles;
 | 
			
		||||
  text?: string;
 | 
			
		||||
  errorMessage?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let CLIPBOARD = "";
 | 
			
		||||
let PREFER_APP_CLIPBOARD = false;
 | 
			
		||||
 | 
			
		||||
@@ -31,7 +40,7 @@ export const probablySupportsClipboardBlob =
 | 
			
		||||
 | 
			
		||||
const clipboardContainsElements = (
 | 
			
		||||
  contents: any,
 | 
			
		||||
): contents is { elements: ExcalidrawElement[] } => {
 | 
			
		||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
 | 
			
		||||
  if (
 | 
			
		||||
    [
 | 
			
		||||
      EXPORT_DATA_TYPES.excalidraw,
 | 
			
		||||
@@ -47,10 +56,18 @@ const clipboardContainsElements = (
 | 
			
		||||
export const copyToClipboard = async (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  files: BinaryFiles,
 | 
			
		||||
) => {
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
  const contents: ElementsClipboard = {
 | 
			
		||||
    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);
 | 
			
		||||
  CLIPBOARD = json;
 | 
			
		||||
@@ -110,12 +127,7 @@ const getSystemClipboard = async (
 | 
			
		||||
 */
 | 
			
		||||
export const parseClipboard = async (
 | 
			
		||||
  event: ClipboardEvent | null,
 | 
			
		||||
): Promise<{
 | 
			
		||||
  spreadsheet?: Spreadsheet;
 | 
			
		||||
  elements?: readonly ExcalidrawElement[];
 | 
			
		||||
  text?: string;
 | 
			
		||||
  errorMessage?: string;
 | 
			
		||||
}> => {
 | 
			
		||||
): Promise<ClipboardData> => {
 | 
			
		||||
  const systemClipboard = await getSystemClipboard(event);
 | 
			
		||||
 | 
			
		||||
  // if system clipboard empty, couldn't be resolved, or contains previously
 | 
			
		||||
@@ -137,7 +149,10 @@ export const parseClipboard = async (
 | 
			
		||||
  try {
 | 
			
		||||
    const systemClipboardData = JSON.parse(systemClipboard);
 | 
			
		||||
    if (clipboardContainsElements(systemClipboardData)) {
 | 
			
		||||
      return { elements: systemClipboardData.elements };
 | 
			
		||||
      return {
 | 
			
		||||
        elements: systemClipboardData.elements,
 | 
			
		||||
        files: systemClipboardData.files,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return appClipboardData;
 | 
			
		||||
  } catch {
 | 
			
		||||
@@ -150,10 +165,9 @@ export const parseClipboard = async (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
 | 
			
		||||
  const blob = await canvasToBlob(canvas);
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
 | 
			
		||||
  await navigator.clipboard.write([
 | 
			
		||||
    new window.ClipboardItem({ "image/png": blob }),
 | 
			
		||||
    new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,16 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { ExcalidrawElement, PointerType } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  hasBackground,
 | 
			
		||||
  hasStroke,
 | 
			
		||||
  hasStrokeStyle,
 | 
			
		||||
  hasStrokeWidth,
 | 
			
		||||
  hasText,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
@@ -17,6 +18,7 @@ import { AppState, Zoom } from "../types";
 | 
			
		||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
 | 
			
		||||
export const SelectedShapeActions = ({
 | 
			
		||||
  appState,
 | 
			
		||||
@@ -47,16 +49,36 @@ export const SelectedShapeActions = ({
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    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 (
 | 
			
		||||
    <div className="panelColumn">
 | 
			
		||||
      {renderAction("changeStrokeColor")}
 | 
			
		||||
      {((hasStrokeColor(elementType) &&
 | 
			
		||||
        elementType !== "image" &&
 | 
			
		||||
        commonSelectedType !== "image") ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeColor")}
 | 
			
		||||
      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
			
		||||
      {showFillIcons && renderAction("changeFillStyle")}
 | 
			
		||||
 | 
			
		||||
      {(hasStroke(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStroke(element.type))) && (
 | 
			
		||||
      {(hasStrokeWidth(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeWidth(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeWidth")}
 | 
			
		||||
 | 
			
		||||
      {(elementType === "freedraw" ||
 | 
			
		||||
        targetElements.some((element) => element.type === "freedraw")) &&
 | 
			
		||||
        renderAction("changeStrokeShape")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeStyle(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeStyle(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeStrokeWidth")}
 | 
			
		||||
          {renderAction("changeStrokeStyle")}
 | 
			
		||||
          {renderAction("changeSloppiness")}
 | 
			
		||||
        </>
 | 
			
		||||
@@ -143,31 +165,24 @@ export const SelectedShapeActions = ({
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LIBRARY_ICON = (
 | 
			
		||||
  // fa-th-large
 | 
			
		||||
  <svg viewBox="0 0 512 512">
 | 
			
		||||
    <path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const ShapesSwitcher = ({
 | 
			
		||||
  canvas,
 | 
			
		||||
  elementType,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  isLibraryOpen,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
}: {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  isLibraryOpen: boolean;
 | 
			
		||||
  onImageAction: (data: { pointerType: PointerType | null }) => void;
 | 
			
		||||
}) => (
 | 
			
		||||
  <>
 | 
			
		||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
      const label = t(`toolBar.${value}`);
 | 
			
		||||
      const letter = typeof key === "string" ? key : key[0];
 | 
			
		||||
      const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
 | 
			
		||||
        index + 1
 | 
			
		||||
      }`;
 | 
			
		||||
      const letter = key && (typeof key === "string" ? key : key[0]);
 | 
			
		||||
      const shortcut = letter
 | 
			
		||||
        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
 | 
			
		||||
        : `${index + 1}`;
 | 
			
		||||
      return (
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          className="Shape"
 | 
			
		||||
@@ -181,31 +196,20 @@ export const ShapesSwitcher = ({
 | 
			
		||||
          aria-label={capitalizeString(label)}
 | 
			
		||||
          aria-keyshortcuts={shortcut}
 | 
			
		||||
          data-testid={value}
 | 
			
		||||
          onChange={() => {
 | 
			
		||||
          onChange={({ pointerType }) => {
 | 
			
		||||
            setAppState({
 | 
			
		||||
              elementType: value,
 | 
			
		||||
              multiElement: null,
 | 
			
		||||
              selectedElementIds: {},
 | 
			
		||||
            });
 | 
			
		||||
            setCursorForShape(canvas, value);
 | 
			
		||||
            setAppState({});
 | 
			
		||||
            if (value === "image") {
 | 
			
		||||
              onImageAction({ pointerType });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    })}
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      className="Shape ToolIcon_type_button__library"
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={LIBRARY_ICON}
 | 
			
		||||
      name="editor-library"
 | 
			
		||||
      keyBindingLabel="9"
 | 
			
		||||
      aria-keyshortcuts="9"
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 9`}
 | 
			
		||||
      aria-label={capitalizeString(t("toolBar.library"))}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        setAppState({ isLibraryOpen: !isLibraryOpen });
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  </>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@@ -218,12 +222,9 @@ export const ZoomActions = ({
 | 
			
		||||
}) => (
 | 
			
		||||
  <Stack.Col gap={1}>
 | 
			
		||||
    <Stack.Row gap={1} align="center">
 | 
			
		||||
      {renderAction("zoomIn")}
 | 
			
		||||
      {renderAction("zoomOut")}
 | 
			
		||||
      {renderAction("zoomIn")}
 | 
			
		||||
      {renderAction("resetZoom")}
 | 
			
		||||
      <div style={{ marginInlineStart: 4 }}>
 | 
			
		||||
        {(zoom.value * 100).toFixed(0)}%
 | 
			
		||||
      </div>
 | 
			
		||||
    </Stack.Row>
 | 
			
		||||
  </Stack.Col>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								src/components/ActiveFile.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/ActiveFile.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .ActiveFile {
 | 
			
		||||
    .ActiveFile__fileName {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
 | 
			
		||||
      span {
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
        width: 9.3em;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 1.15em;
 | 
			
		||||
        margin-inline-end: 0.3em;
 | 
			
		||||
        transform: scaleY(0.9);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										28
									
								
								src/components/ActiveFile.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/components/ActiveFile.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
import Stack from "../components/Stack";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { save, file } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import "./ActiveFile.scss";
 | 
			
		||||
 | 
			
		||||
type ActiveFileProps = {
 | 
			
		||||
  fileName?: string;
 | 
			
		||||
  onSave: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
 | 
			
		||||
  <Stack.Row className="ActiveFile" gap={1} align="center">
 | 
			
		||||
    <span className="ActiveFile__fileName">
 | 
			
		||||
      {file}
 | 
			
		||||
      <span>{fileName}</span>
 | 
			
		||||
    </span>
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="icon"
 | 
			
		||||
      icon={save}
 | 
			
		||||
      title={t("buttons.save")}
 | 
			
		||||
      aria-label={t("buttons.save")}
 | 
			
		||||
      onClick={onSave}
 | 
			
		||||
      data-testid="save-button"
 | 
			
		||||
    />
 | 
			
		||||
  </Stack.Row>
 | 
			
		||||
);
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,7 +1,6 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { DarkModeToggle } from "./DarkModeToggle";
 | 
			
		||||
 | 
			
		||||
export const BackgroundPickerAndDarkModeToggle = ({
 | 
			
		||||
  appState,
 | 
			
		||||
@@ -16,15 +15,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
 | 
			
		||||
}) => (
 | 
			
		||||
  <div style={{ display: "flex" }}>
 | 
			
		||||
    {actionManager.renderAction("changeViewBackgroundColor")}
 | 
			
		||||
    {showThemeBtn && (
 | 
			
		||||
      <div style={{ marginInlineStart: "0.25rem" }}>
 | 
			
		||||
        <DarkModeToggle
 | 
			
		||||
          value={appState.theme}
 | 
			
		||||
          onChange={(theme) => {
 | 
			
		||||
            setAppState({ theme });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    )}
 | 
			
		||||
    {showThemeBtn && actionManager.renderAction("toggleTheme")}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
// 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";
 | 
			
		||||
 | 
			
		||||
export const ButtonSelect = <T extends Object>({
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										57
									
								
								src/components/Card.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/components/Card.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Card {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
 | 
			
		||||
    max-width: 290px;
 | 
			
		||||
 | 
			
		||||
    margin: 1em;
 | 
			
		||||
 | 
			
		||||
    text-align: center;
 | 
			
		||||
 | 
			
		||||
    .Card-icon {
 | 
			
		||||
      font-size: 2.6em;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex: 0 0 auto;
 | 
			
		||||
      padding: 1.4rem;
 | 
			
		||||
      border-radius: 50%;
 | 
			
		||||
      background: var(--card-color);
 | 
			
		||||
      color: $oc-white;
 | 
			
		||||
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 2.8rem;
 | 
			
		||||
        height: 2.8rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .Card-details {
 | 
			
		||||
      font-size: 0.96em;
 | 
			
		||||
      min-height: 90px;
 | 
			
		||||
      padding: 0 1em;
 | 
			
		||||
      margin-bottom: auto;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & .Card-button.ToolIcon_type_button {
 | 
			
		||||
      height: 2.5rem;
 | 
			
		||||
      margin-top: 1em;
 | 
			
		||||
      margin-bottom: 0.3em;
 | 
			
		||||
      background-color: var(--card-color);
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: var(--card-color-darker);
 | 
			
		||||
      }
 | 
			
		||||
      &:active {
 | 
			
		||||
        background-color: var(--card-color-darkest);
 | 
			
		||||
      }
 | 
			
		||||
      .ToolIcon__label {
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .Spinner {
 | 
			
		||||
        --spinner-color: #fff;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/components/Card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/components/Card.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import OpenColor from "open-color";
 | 
			
		||||
 | 
			
		||||
import "./Card.scss";
 | 
			
		||||
 | 
			
		||||
export const Card: React.FC<{
 | 
			
		||||
  color: keyof OpenColor;
 | 
			
		||||
}> = ({ children, color }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className="Card"
 | 
			
		||||
      style={{
 | 
			
		||||
        ["--card-color" as any]: OpenColor[color][7],
 | 
			
		||||
        ["--card-color-darker" as any]: OpenColor[color][8],
 | 
			
		||||
        ["--card-color-darkest" as any]: OpenColor[color][9],
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										89
									
								
								src/components/CheckboxItem.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/components/CheckboxItem.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Checkbox {
 | 
			
		||||
    margin: 4px 0.3em;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    user-select: none;
 | 
			
		||||
 | 
			
		||||
    -webkit-tap-highlight-color: transparent;
 | 
			
		||||
 | 
			
		||||
    &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
 | 
			
		||||
      box-shadow: 0 0 0 2px #{$oc-blue-4};
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
 | 
			
		||||
      svg {
 | 
			
		||||
        display: block;
 | 
			
		||||
        opacity: 0.3;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:active {
 | 
			
		||||
      .Checkbox-box {
 | 
			
		||||
        box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      .Checkbox-box {
 | 
			
		||||
        background-color: fade-out($oc-blue-1, 0.8);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.is-checked {
 | 
			
		||||
      .Checkbox-box {
 | 
			
		||||
        background-color: #{$oc-blue-1};
 | 
			
		||||
        svg {
 | 
			
		||||
          display: block;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      &:hover .Checkbox-box {
 | 
			
		||||
        background-color: #{$oc-blue-2};
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .Checkbox-box {
 | 
			
		||||
      width: 22px;
 | 
			
		||||
      height: 22px;
 | 
			
		||||
      padding: 0;
 | 
			
		||||
      flex: 0 0 auto;
 | 
			
		||||
 | 
			
		||||
      margin: 0 1em;
 | 
			
		||||
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
 | 
			
		||||
      box-shadow: 0 0 0 2px #{$oc-blue-7};
 | 
			
		||||
      background-color: transparent;
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
 | 
			
		||||
      color: #{$oc-blue-7};
 | 
			
		||||
 | 
			
		||||
      &:focus {
 | 
			
		||||
        box-shadow: 0 0 0 3px #{$oc-blue-7};
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      svg {
 | 
			
		||||
        display: none;
 | 
			
		||||
        width: 16px;
 | 
			
		||||
        height: 16px;
 | 
			
		||||
        stroke-width: 3px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .Checkbox-label {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .excalidraw-tooltip-icon {
 | 
			
		||||
      width: 1em;
 | 
			
		||||
      height: 1em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/components/CheckboxItem.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/components/CheckboxItem.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { checkIcon } from "./icons";
 | 
			
		||||
 | 
			
		||||
import "./CheckboxItem.scss";
 | 
			
		||||
 | 
			
		||||
export const CheckboxItem: React.FC<{
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange: (checked: boolean) => void;
 | 
			
		||||
}> = ({ children, checked, onChange }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("Checkbox", { "is-checked": checked })}
 | 
			
		||||
      onClick={(event) => {
 | 
			
		||||
        onChange(!checked);
 | 
			
		||||
        ((event.currentTarget as HTMLDivElement).querySelector(
 | 
			
		||||
          ".Checkbox-box",
 | 
			
		||||
        ) as HTMLButtonElement).focus();
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
 | 
			
		||||
        {checkIcon}
 | 
			
		||||
      </button>
 | 
			
		||||
      <div className="Checkbox-label">{children}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										42
									
								
								src/components/ClearCanvas.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/components/ClearCanvas.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .clear-canvas {
 | 
			
		||||
    &-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: 1.5rem;
 | 
			
		||||
        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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--cancel.ToolIcon_type_button {
 | 
			
		||||
      background-color: $oc-gray-2;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										67
									
								
								src/components/ClearCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/ClearCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { trash } from "./icons";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
import "./ClearCanvas.scss";
 | 
			
		||||
 | 
			
		||||
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 && (
 | 
			
		||||
        <Dialog
 | 
			
		||||
          onCloseRequest={toggleDialog}
 | 
			
		||||
          title={t("clearCanvasDialog.title")}
 | 
			
		||||
          className="clear-canvas"
 | 
			
		||||
          small={true}
 | 
			
		||||
        >
 | 
			
		||||
          <>
 | 
			
		||||
            <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
 | 
			
		||||
            <div className="clear-canvas-buttons">
 | 
			
		||||
              <ToolButton
 | 
			
		||||
                type="button"
 | 
			
		||||
                title={t("buttons.clear")}
 | 
			
		||||
                aria-label={t("buttons.clear")}
 | 
			
		||||
                label={t("buttons.clear")}
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  onConfirm();
 | 
			
		||||
                  toggleDialog();
 | 
			
		||||
                }}
 | 
			
		||||
                data-testid="confirm-clear-canvas-button"
 | 
			
		||||
                className="clear-canvas--confirm"
 | 
			
		||||
              />
 | 
			
		||||
              <ToolButton
 | 
			
		||||
                type="button"
 | 
			
		||||
                title={t("buttons.cancel")}
 | 
			
		||||
                aria-label={t("buttons.cancel")}
 | 
			
		||||
                label={t("buttons.cancel")}
 | 
			
		||||
                onClick={toggleDialog}
 | 
			
		||||
                data-testid="cancel-clear-canvas-button"
 | 
			
		||||
                className="clear-canvas--cancel"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </>
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default ClearCanvas;
 | 
			
		||||
@@ -1,8 +1,7 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { users } from "./icons";
 | 
			
		||||
 | 
			
		||||
import "./CollabButton.scss";
 | 
			
		||||
 
 | 
			
		||||
@@ -160,7 +160,7 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-input {
 | 
			
		||||
    width: 12ch; /* length of `transparent` + 1 */
 | 
			
		||||
    width: 11ch; /* length of `transparent` */
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    background-color: var(--input-bg-color);
 | 
			
		||||
@@ -218,7 +218,7 @@
 | 
			
		||||
      left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Popover } from "./Popover";
 | 
			
		||||
import { isTransparent } from "../utils";
 | 
			
		||||
 | 
			
		||||
import "./ColorPicker.scss";
 | 
			
		||||
import { isArrowKey, KEYS } from "../keys";
 | 
			
		||||
@@ -14,7 +15,7 @@ const isValidColor = (color: string) => {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getColor = (color: string): string | null => {
 | 
			
		||||
  if (color === "transparent") {
 | 
			
		||||
  if (isTransparent(color)) {
 | 
			
		||||
    return color;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -115,6 +116,7 @@ const Picker = ({
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -136,36 +138,41 @@ const Picker = ({
 | 
			
		||||
        }}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
      >
 | 
			
		||||
        {colors.map((_color, i) => (
 | 
			
		||||
          <button
 | 
			
		||||
            className="color-picker-swatch"
 | 
			
		||||
            onClick={(event) => {
 | 
			
		||||
              (event.currentTarget as HTMLButtonElement).focus();
 | 
			
		||||
              onChange(_color);
 | 
			
		||||
            }}
 | 
			
		||||
            title={`${_color} — ${keyBindings[i].toUpperCase()}`}
 | 
			
		||||
            aria-label={_color}
 | 
			
		||||
            aria-keyshortcuts={keyBindings[i]}
 | 
			
		||||
            style={{ color: _color }}
 | 
			
		||||
            key={_color}
 | 
			
		||||
            ref={(el) => {
 | 
			
		||||
              if (el && i === 0) {
 | 
			
		||||
                firstItem.current = el;
 | 
			
		||||
              }
 | 
			
		||||
              if (el && _color === color) {
 | 
			
		||||
                activeItem.current = el;
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
            onFocus={() => {
 | 
			
		||||
              onChange(_color);
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {_color === "transparent" ? (
 | 
			
		||||
              <div className="color-picker-transparent"></div>
 | 
			
		||||
            ) : undefined}
 | 
			
		||||
            <span className="color-picker-keybinding">{keyBindings[i]}</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        ))}
 | 
			
		||||
        {colors.map((_color, i) => {
 | 
			
		||||
          const _colorWithoutHash = _color.replace("#", "");
 | 
			
		||||
          return (
 | 
			
		||||
            <button
 | 
			
		||||
              className="color-picker-swatch"
 | 
			
		||||
              onClick={(event) => {
 | 
			
		||||
                (event.currentTarget as HTMLButtonElement).focus();
 | 
			
		||||
                onChange(_color);
 | 
			
		||||
              }}
 | 
			
		||||
              title={`${t(`colors.${_colorWithoutHash}`)}${
 | 
			
		||||
                !isTransparent(_color) ? ` (${_color})` : ""
 | 
			
		||||
              } — ${keyBindings[i].toUpperCase()}`}
 | 
			
		||||
              aria-label={t(`colors.${_colorWithoutHash}`)}
 | 
			
		||||
              aria-keyshortcuts={keyBindings[i]}
 | 
			
		||||
              style={{ color: _color }}
 | 
			
		||||
              key={_color}
 | 
			
		||||
              ref={(el) => {
 | 
			
		||||
                if (el && i === 0) {
 | 
			
		||||
                  firstItem.current = el;
 | 
			
		||||
                }
 | 
			
		||||
                if (el && _color === color) {
 | 
			
		||||
                  activeItem.current = el;
 | 
			
		||||
                }
 | 
			
		||||
              }}
 | 
			
		||||
              onFocus={() => {
 | 
			
		||||
                onChange(_color);
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              {isTransparent(_color) ? (
 | 
			
		||||
                <div className="color-picker-transparent"></div>
 | 
			
		||||
              ) : undefined}
 | 
			
		||||
              <span className="color-picker-keybinding">{keyBindings[i]}</span>
 | 
			
		||||
            </button>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
        {showInput && (
 | 
			
		||||
          <ColorInput
 | 
			
		||||
            color={color}
 | 
			
		||||
@@ -237,13 +244,16 @@ export const ColorPicker = ({
 | 
			
		||||
  color,
 | 
			
		||||
  onChange,
 | 
			
		||||
  label,
 | 
			
		||||
  isActive,
 | 
			
		||||
  setActive,
 | 
			
		||||
}: {
 | 
			
		||||
  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
			
		||||
  color: string | null;
 | 
			
		||||
  onChange: (color: string) => void;
 | 
			
		||||
  label: string;
 | 
			
		||||
  isActive: boolean;
 | 
			
		||||
  setActive: (active: boolean) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [isActive, setActive] = React.useState(false);
 | 
			
		||||
  const pickerButton = React.useRef<HTMLButtonElement>(null);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 
 | 
			
		||||
@@ -76,7 +76,7 @@
 | 
			
		||||
    z-index: 1;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
    .context-menu-option {
 | 
			
		||||
      display: block;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { Popover } from "./Popover";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,42 +1,32 @@
 | 
			
		||||
import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export type Appearence = "light" | "dark";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { THEME } from "../constants";
 | 
			
		||||
import { Theme } from "../element/types";
 | 
			
		||||
 | 
			
		||||
// We chose to use only explicit toggle and not a third option for system value,
 | 
			
		||||
// but this could be added in the future.
 | 
			
		||||
export const DarkModeToggle = (props: {
 | 
			
		||||
  value: Appearence;
 | 
			
		||||
  onChange: (value: Appearence) => void;
 | 
			
		||||
  value: Theme;
 | 
			
		||||
  onChange: (value: Theme) => void;
 | 
			
		||||
  title?: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const title = props.title
 | 
			
		||||
    ? props.title
 | 
			
		||||
    : props.value === "dark"
 | 
			
		||||
    ? t("buttons.lightMode")
 | 
			
		||||
    : t("buttons.darkMode");
 | 
			
		||||
  const title =
 | 
			
		||||
    props.title ||
 | 
			
		||||
    (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
 | 
			
		||||
      data-testid="toggle-dark-mode"
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="icon"
 | 
			
		||||
      icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
 | 
			
		||||
      title={title}
 | 
			
		||||
    >
 | 
			
		||||
      <input
 | 
			
		||||
        className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        onChange={(event) =>
 | 
			
		||||
          props.onChange(event.target.checked ? "dark" : "light")
 | 
			
		||||
        }
 | 
			
		||||
        checked={props.value === "dark"}
 | 
			
		||||
        aria-label={title}
 | 
			
		||||
      />
 | 
			
		||||
      <div className="ToolIcon__icon">
 | 
			
		||||
        {props.value === "light" ? ICONS.MOON : ICONS.SUN}
 | 
			
		||||
      </div>
 | 
			
		||||
    </label>
 | 
			
		||||
      aria-label={title}
 | 
			
		||||
      onClick={() =>
 | 
			
		||||
        props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
 | 
			
		||||
      }
 | 
			
		||||
      data-testid="toggle-dark-mode"
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,7 @@
 | 
			
		||||
    padding: 0 16px 16px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
    .Dialog {
 | 
			
		||||
      --metric: calc(var(--space-factor) * 4);
 | 
			
		||||
      --inset-left: #{"max(var(--metric), var(--sal))"};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,14 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import React, { useEffect } from "react";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import "./Dialog.scss";
 | 
			
		||||
import { back, close } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import { Modal } from "./Modal";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const Dialog = (props: {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
@@ -16,8 +17,11 @@ export const Dialog = (props: {
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  title: React.ReactNode;
 | 
			
		||||
  autofocus?: boolean;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
			
		||||
  const [lastActiveElement] = useState(document.activeElement);
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!islandNode) {
 | 
			
		||||
@@ -65,19 +69,25 @@ export const Dialog = (props: {
 | 
			
		||||
    return focusableElements ? Array.from(focusableElements) : [];
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onClose = () => {
 | 
			
		||||
    (lastActiveElement as HTMLElement).focus();
 | 
			
		||||
    props.onCloseRequest();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      className={clsx("Dialog", props.className)}
 | 
			
		||||
      labelledBy="dialog-title"
 | 
			
		||||
      maxWidth={props.small ? 550 : 800}
 | 
			
		||||
      onCloseRequest={props.onCloseRequest}
 | 
			
		||||
      onCloseRequest={onClose}
 | 
			
		||||
      theme={props.theme}
 | 
			
		||||
    >
 | 
			
		||||
      <Island ref={setIslandNode}>
 | 
			
		||||
        <h2 id="dialog-title" className="Dialog__title">
 | 
			
		||||
        <h2 id={`${id}-dialog-title`} className="Dialog__title">
 | 
			
		||||
          <span className="Dialog__titleContent">{props.title}</span>
 | 
			
		||||
          <button
 | 
			
		||||
            className="Modal__close"
 | 
			
		||||
            onClick={props.onCloseRequest}
 | 
			
		||||
            onClick={onClose}
 | 
			
		||||
            aria-label={t("buttons.close")}
 | 
			
		||||
          >
 | 
			
		||||
            {useIsMobile() ? back : close}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
 | 
			
		||||
export const ErrorDialog = ({
 | 
			
		||||
  message,
 | 
			
		||||
@@ -11,6 +12,7 @@ export const ErrorDialog = ({
 | 
			
		||||
  onClose?: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(!!message);
 | 
			
		||||
  const { container: excalidrawContainer } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
@@ -18,7 +20,9 @@ export const ErrorDialog = ({
 | 
			
		||||
    if (onClose) {
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
    // TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
 | 
			
		||||
    excalidrawContainer?.focus();
 | 
			
		||||
  }, [onClose, excalidrawContainer]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,34 +28,7 @@
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ExportDialog__name {
 | 
			
		||||
    grid-column: project-name;
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
 | 
			
		||||
    .TextInput {
 | 
			
		||||
      height: calc(1rem - 3px);
 | 
			
		||||
      width: 200px;
 | 
			
		||||
      overflow: hidden;
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      margin-left: 8px;
 | 
			
		||||
      text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
      &--readonly {
 | 
			
		||||
        background: none;
 | 
			
		||||
        border: none;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background: none;
 | 
			
		||||
        }
 | 
			
		||||
        width: auto;
 | 
			
		||||
        max-width: 200px;
 | 
			
		||||
        padding-left: 2px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
    .ExportDialog {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
@@ -84,4 +57,63 @@
 | 
			
		||||
      overflow-y: auto;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ExportDialog--json {
 | 
			
		||||
    .ExportDialog-cards {
 | 
			
		||||
      display: grid;
 | 
			
		||||
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 | 
			
		||||
      justify-items: center;
 | 
			
		||||
      row-gap: 2em;
 | 
			
		||||
 | 
			
		||||
      @media (max-width: 460px) {
 | 
			
		||||
        grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
 | 
			
		||||
        .Card-details {
 | 
			
		||||
          min-height: 40px;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ProjectName {
 | 
			
		||||
        width: fit-content;
 | 
			
		||||
        margin: 1em auto;
 | 
			
		||||
        align-items: flex-start;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
 | 
			
		||||
        .TextInput {
 | 
			
		||||
          width: auto;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ProjectName-label {
 | 
			
		||||
        margin: 0.625em 0;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  button.ExportDialog-imageExportButton {
 | 
			
		||||
    width: 5rem;
 | 
			
		||||
    height: 5rem;
 | 
			
		||||
    margin: 0 0.2em;
 | 
			
		||||
 | 
			
		||||
    border-radius: 1rem;
 | 
			
		||||
    background-color: var(--button-color);
 | 
			
		||||
    box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
 | 
			
		||||
      0 6px 10px 0 rgba(0, 0, 0, 0.14);
 | 
			
		||||
 | 
			
		||||
    font-family: Cascadia;
 | 
			
		||||
    font-size: 1.8em;
 | 
			
		||||
    color: $oc-white;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: var(--button-color-darker);
 | 
			
		||||
    }
 | 
			
		||||
    &:active {
 | 
			
		||||
      background-color: var(--button-color-darkest);
 | 
			
		||||
      box-shadow: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    svg {
 | 
			
		||||
      width: 0.9em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,286 +0,0 @@
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { probablySupportsClipboardBlob } from "../clipboard";
 | 
			
		||||
import { canvasToBlob } from "../data/blob";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { exportToCanvas, getExportSize } from "../scene/export";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import { clipboard, exportFile, link } from "./icons";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
const scales = [1, 2, 3];
 | 
			
		||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
 | 
			
		||||
 | 
			
		||||
const supportsContextFilters =
 | 
			
		||||
  "filter" in document.createElement("canvas").getContext("2d")!;
 | 
			
		||||
 | 
			
		||||
export const ErrorCanvasPreview = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h3>{t("canvasError.cannotShowPreview")}</h3>
 | 
			
		||||
      <p>
 | 
			
		||||
        <span>{t("canvasError.canvasTooBig")}</span>
 | 
			
		||||
      </p>
 | 
			
		||||
      <em>({t("canvasError.canvasTooBigTip")})</em>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const renderPreview = (
 | 
			
		||||
  content: HTMLCanvasElement | Error,
 | 
			
		||||
  previewNode: HTMLDivElement,
 | 
			
		||||
) => {
 | 
			
		||||
  unmountComponentAtNode(previewNode);
 | 
			
		||||
  previewNode.innerHTML = "";
 | 
			
		||||
  if (content instanceof HTMLCanvasElement) {
 | 
			
		||||
    previewNode.appendChild(content);
 | 
			
		||||
  } else {
 | 
			
		||||
    render(<ErrorCanvasPreview />, previewNode);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ExportCB = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  scale?: number,
 | 
			
		||||
) => void;
 | 
			
		||||
 | 
			
		||||
const ExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  exportPadding = 10,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
  onExportToBackend?: ExportCB;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const someElementIsSelected = isSomeElementSelected(elements, appState);
 | 
			
		||||
  const [scale, setScale] = useState(defaultScale);
 | 
			
		||||
  const [exportSelected, setExportSelected] = useState(someElementIsSelected);
 | 
			
		||||
  const previewRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const {
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
    shouldAddWatermark,
 | 
			
		||||
  } = appState;
 | 
			
		||||
 | 
			
		||||
  const exportedElements = exportSelected
 | 
			
		||||
    ? getSelectedElements(elements, appState)
 | 
			
		||||
    : elements;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setExportSelected(someElementIsSelected);
 | 
			
		||||
  }, [someElementIsSelected]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const previewNode = previewRef.current;
 | 
			
		||||
    if (!previewNode) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      const canvas = exportToCanvas(exportedElements, appState, {
 | 
			
		||||
        exportBackground,
 | 
			
		||||
        viewBackgroundColor,
 | 
			
		||||
        exportPadding,
 | 
			
		||||
        scale,
 | 
			
		||||
        shouldAddWatermark,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // if converting to blob fails, there's some problem that will
 | 
			
		||||
      // likely prevent preview and export (e.g. canvas too big)
 | 
			
		||||
      canvasToBlob(canvas)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          renderPreview(canvas, previewNode);
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error(error);
 | 
			
		||||
          renderPreview(new CanvasError(), previewNode);
 | 
			
		||||
        });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      renderPreview(new CanvasError(), previewNode);
 | 
			
		||||
    }
 | 
			
		||||
  }, [
 | 
			
		||||
    appState,
 | 
			
		||||
    exportedElements,
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    exportPadding,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
    scale,
 | 
			
		||||
    shouldAddWatermark,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ExportDialog">
 | 
			
		||||
      <div className="ExportDialog__preview" ref={previewRef} />
 | 
			
		||||
      {supportsContextFilters &&
 | 
			
		||||
        actionManager.renderAction("exportWithDarkMode")}
 | 
			
		||||
      <Stack.Col gap={2} align="center">
 | 
			
		||||
        <div className="ExportDialog__actions">
 | 
			
		||||
          <Stack.Row gap={2}>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              type="button"
 | 
			
		||||
              label="PNG"
 | 
			
		||||
              title={t("buttons.exportToPng")}
 | 
			
		||||
              aria-label={t("buttons.exportToPng")}
 | 
			
		||||
              onClick={() => onExportToPng(exportedElements, scale)}
 | 
			
		||||
            />
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              type="button"
 | 
			
		||||
              label="SVG"
 | 
			
		||||
              title={t("buttons.exportToSvg")}
 | 
			
		||||
              aria-label={t("buttons.exportToSvg")}
 | 
			
		||||
              onClick={() => onExportToSvg(exportedElements, scale)}
 | 
			
		||||
            />
 | 
			
		||||
            {probablySupportsClipboardBlob && (
 | 
			
		||||
              <ToolButton
 | 
			
		||||
                type="button"
 | 
			
		||||
                icon={clipboard}
 | 
			
		||||
                title={t("buttons.copyPngToClipboard")}
 | 
			
		||||
                aria-label={t("buttons.copyPngToClipboard")}
 | 
			
		||||
                onClick={() => onExportToClipboard(exportedElements, scale)}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            {onExportToBackend && (
 | 
			
		||||
              <ToolButton
 | 
			
		||||
                type="button"
 | 
			
		||||
                icon={link}
 | 
			
		||||
                title={t("buttons.getShareableLink")}
 | 
			
		||||
                aria-label={t("buttons.getShareableLink")}
 | 
			
		||||
                onClick={() => onExportToBackend(exportedElements)}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </Stack.Row>
 | 
			
		||||
          <div className="ExportDialog__name">
 | 
			
		||||
            {actionManager.renderAction("changeProjectName")}
 | 
			
		||||
          </div>
 | 
			
		||||
          <Stack.Row gap={2}>
 | 
			
		||||
            {scales.map((s) => {
 | 
			
		||||
              const [width, height] = getExportSize(
 | 
			
		||||
                exportedElements,
 | 
			
		||||
                exportPadding,
 | 
			
		||||
                shouldAddWatermark,
 | 
			
		||||
                s,
 | 
			
		||||
              );
 | 
			
		||||
 | 
			
		||||
              const scaleButtonTitle = `${t(
 | 
			
		||||
                "buttons.scale",
 | 
			
		||||
              )} ${s}x (${width}x${height})`;
 | 
			
		||||
 | 
			
		||||
              return (
 | 
			
		||||
                <ToolButton
 | 
			
		||||
                  key={s}
 | 
			
		||||
                  size="s"
 | 
			
		||||
                  type="radio"
 | 
			
		||||
                  icon={`${s}x`}
 | 
			
		||||
                  name="export-canvas-scale"
 | 
			
		||||
                  title={scaleButtonTitle}
 | 
			
		||||
                  aria-label={scaleButtonTitle}
 | 
			
		||||
                  id="export-canvas-scale"
 | 
			
		||||
                  checked={s === scale}
 | 
			
		||||
                  onChange={() => setScale(s)}
 | 
			
		||||
                />
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
          </Stack.Row>
 | 
			
		||||
        </div>
 | 
			
		||||
        {actionManager.renderAction("toggleAutosave")}
 | 
			
		||||
        {actionManager.renderAction("changeExportBackground")}
 | 
			
		||||
        {someElementIsSelected && (
 | 
			
		||||
          <div>
 | 
			
		||||
            <label>
 | 
			
		||||
              <input
 | 
			
		||||
                type="checkbox"
 | 
			
		||||
                checked={exportSelected}
 | 
			
		||||
                onChange={(event) =>
 | 
			
		||||
                  setExportSelected(event.currentTarget.checked)
 | 
			
		||||
                }
 | 
			
		||||
              />{" "}
 | 
			
		||||
              {t("labels.onlySelected")}
 | 
			
		||||
            </label>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {actionManager.renderAction("changeExportEmbedScene")}
 | 
			
		||||
        {actionManager.renderAction("changeShouldAddWatermark")}
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  exportPadding = 10,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
  onExportToBackend?: ExportCB;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(false);
 | 
			
		||||
  const triggerButton = useRef<HTMLButtonElement>(null);
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
    triggerButton.current?.focus();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        data-testid="export-button"
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.export")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        title={t("buttons.export")}
 | 
			
		||||
        ref={triggerButton}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
 | 
			
		||||
          <ExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            exportPadding={exportPadding}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onExportToPng={onExportToPng}
 | 
			
		||||
            onExportToSvg={onExportToSvg}
 | 
			
		||||
            onExportToClipboard={onExportToClipboard}
 | 
			
		||||
            onExportToBackend={onExportToBackend}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
          />
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .FixedSideContainer {
 | 
			
		||||
    --margin: 0.25rem;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
  }
 | 
			
		||||
@@ -10,9 +9,9 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .FixedSideContainer_side_top {
 | 
			
		||||
    left: var(--margin);
 | 
			
		||||
    top: var(--margin);
 | 
			
		||||
    right: var(--margin);
 | 
			
		||||
    left: var(--space-factor);
 | 
			
		||||
    top: var(--space-factor);
 | 
			
		||||
    right: var(--space-factor);
 | 
			
		||||
    z-index: 2;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -23,16 +22,16 @@
 | 
			
		||||
 | 
			
		||||
/* TODO: if these are used, make sure to implement RTL support
 | 
			
		||||
.FixedSideContainer_side_left {
 | 
			
		||||
  left: var(--margin);
 | 
			
		||||
  top: var(--margin);
 | 
			
		||||
  bottom: var(--margin);
 | 
			
		||||
  left: var(--space-factor);
 | 
			
		||||
  top: var(--space-factor);
 | 
			
		||||
  bottom: var(--space-factor);
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.FixedSideContainer_side_right {
 | 
			
		||||
  right: var(--margin);
 | 
			
		||||
  top: var(--margin);
 | 
			
		||||
  bottom: var(--margin);
 | 
			
		||||
  right: var(--space-factor);
 | 
			
		||||
  top: var(--space-factor);
 | 
			
		||||
  bottom: var(--space-factor);
 | 
			
		||||
  z-index: 3;
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										53
									
								
								src/components/FontList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/components/FontList.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
import { FONT_FAMILY } from "../constants";
 | 
			
		||||
import { FontFamilyValues } from "../element/types";
 | 
			
		||||
 | 
			
		||||
const FontsList = ({
 | 
			
		||||
  onChange,
 | 
			
		||||
  currentFontFamily,
 | 
			
		||||
}: {
 | 
			
		||||
  onChange: (val: FontFamilyValues) => void;
 | 
			
		||||
  currentFontFamily: FontFamilyValues;
 | 
			
		||||
}) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <select
 | 
			
		||||
      className="dropdown-select"
 | 
			
		||||
      onChange={(event) => {
 | 
			
		||||
        onChange(Number(event.target.value));
 | 
			
		||||
      }}
 | 
			
		||||
      value={currentFontFamily}
 | 
			
		||||
    >
 | 
			
		||||
      <option key="virgil" value={FONT_FAMILY.Virgil}>
 | 
			
		||||
        Hand-Drawn
 | 
			
		||||
      </option>
 | 
			
		||||
      <option key="helvetica" value={FONT_FAMILY.Helvetica}>
 | 
			
		||||
        Normal
 | 
			
		||||
      </option>
 | 
			
		||||
      <option key="cascadia" value={FONT_FAMILY.Cascadia}>
 | 
			
		||||
        code
 | 
			
		||||
      </option>
 | 
			
		||||
      <option key="redacted-regular" value={FONT_FAMILY.REDACTED_REGULAR}>
 | 
			
		||||
        Redacted Regular
 | 
			
		||||
      </option>
 | 
			
		||||
      <option
 | 
			
		||||
        key="redacted-script-regular"
 | 
			
		||||
        value={FONT_FAMILY.REDACTED_SCRIPT_REGULAR}
 | 
			
		||||
      >
 | 
			
		||||
        Redacted Script
 | 
			
		||||
      </option>
 | 
			
		||||
      <option
 | 
			
		||||
        key="redacted-script-bold"
 | 
			
		||||
        value={FONT_FAMILY.REDACTED_SCRIPT_BOLD}
 | 
			
		||||
      >
 | 
			
		||||
        Redacted Script BOLD
 | 
			
		||||
      </option>
 | 
			
		||||
      <option key="Scribble" value={FONT_FAMILY.SCRIBBLE}>
 | 
			
		||||
        Scribble
 | 
			
		||||
      </option>
 | 
			
		||||
      <option key="Blokk" value={FONT_FAMILY.BLOKK}>
 | 
			
		||||
        Blokk
 | 
			
		||||
      </option>
 | 
			
		||||
    </select>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default FontsList;
 | 
			
		||||
@@ -153,10 +153,19 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.draw")}
 | 
			
		||||
                  label={t("toolBar.freedraw")}
 | 
			
		||||
                  shortcuts={["Shift+P", "7"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.editSelectedShape")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey("Enter"),
 | 
			
		||||
                    t("helpDialog.doubleClick"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.textNewLine")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -231,6 +240,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.viewMode")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+R")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.toggleTheme")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+Shift+D")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("stats.title")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+/")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
            <Column>
 | 
			
		||||
@@ -357,6 +374,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.flipVertical")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Shift+V")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.showStroke")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("S")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.showBackground")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("G")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
          </Columns>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { questionCircle } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
type HelpIconProps = {
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
 | 
			
		||||
    color: $oc-gray-6;
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      position: static;
 | 
			
		||||
      padding-right: 2em;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,14 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
 | 
			
		||||
import "./HintViewer.scss";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  isImageElement,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
 | 
			
		||||
interface Hint {
 | 
			
		||||
@@ -23,7 +26,7 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    return t("hints.linearElementMulti");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "draw") {
 | 
			
		||||
  if (elementType === "freedraw") {
 | 
			
		||||
    return t("hints.freeDraw");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -31,6 +34,10 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    return t("hints.text");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (appState.elementType === "image" && appState.pendingImageElement) {
 | 
			
		||||
    return t("hints.placeImage");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
  if (
 | 
			
		||||
    isResizing &&
 | 
			
		||||
@@ -41,7 +48,9 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    if (isLinearElement(targetElement) && targetElement.points.length === 2) {
 | 
			
		||||
      return t("hints.lockAngle");
 | 
			
		||||
    }
 | 
			
		||||
    return t("hints.resize");
 | 
			
		||||
    return isImageElement(targetElement)
 | 
			
		||||
      ? t("hints.resizeImage")
 | 
			
		||||
      : t("hints.resize");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isRotating && lastPointerDownWith === "mouse") {
 | 
			
		||||
@@ -57,6 +66,14 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    return t("hints.lineEditor_info");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
 | 
			
		||||
    return t("hints.text_selected");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (appState.editingElement && isTextElement(appState.editingElement)) {
 | 
			
		||||
    return t("hints.text_editing");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -90,7 +90,7 @@
 | 
			
		||||
  .picker-content {
 | 
			
		||||
    padding: 0.5rem;
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-auto-flow: column;
 | 
			
		||||
    grid-template-columns: repeat(3, auto);
 | 
			
		||||
    grid-gap: 0.5rem;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
@@ -111,7 +111,7 @@
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -88,6 +88,7 @@ function Picker<T>({
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										273
									
								
								src/components/ImageExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										273
									
								
								src/components/ImageExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,273 @@
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { probablySupportsClipboardBlob } from "../clipboard";
 | 
			
		||||
import { canvasToBlob } from "../data/blob";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { exportToCanvas } from "../scene/export";
 | 
			
		||||
import { AppState, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { clipboard, exportImage } from "./icons";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import OpenColor from "open-color";
 | 
			
		||||
import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
 | 
			
		||||
const supportsContextFilters =
 | 
			
		||||
  "filter" in document.createElement("canvas").getContext("2d")!;
 | 
			
		||||
 | 
			
		||||
export const ErrorCanvasPreview = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h3>{t("canvasError.cannotShowPreview")}</h3>
 | 
			
		||||
      <p>
 | 
			
		||||
        <span>{t("canvasError.canvasTooBig")}</span>
 | 
			
		||||
      </p>
 | 
			
		||||
      <em>({t("canvasError.canvasTooBigTip")})</em>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const renderPreview = (
 | 
			
		||||
  content: HTMLCanvasElement | Error,
 | 
			
		||||
  previewNode: HTMLDivElement,
 | 
			
		||||
) => {
 | 
			
		||||
  unmountComponentAtNode(previewNode);
 | 
			
		||||
  previewNode.innerHTML = "";
 | 
			
		||||
  if (content instanceof HTMLCanvasElement) {
 | 
			
		||||
    previewNode.appendChild(content);
 | 
			
		||||
  } else {
 | 
			
		||||
    render(<ErrorCanvasPreview />, previewNode);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ExportCB = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  scale?: number,
 | 
			
		||||
) => void;
 | 
			
		||||
 | 
			
		||||
const ExportButton: React.FC<{
 | 
			
		||||
  color: keyof OpenColor;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  title: string;
 | 
			
		||||
  shade?: number;
 | 
			
		||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      className="ExportDialog-imageExportButton"
 | 
			
		||||
      style={{
 | 
			
		||||
        ["--button-color" as any]: OpenColor[color][shade],
 | 
			
		||||
        ["--button-color-darker" as any]: OpenColor[color][shade + 1],
 | 
			
		||||
        ["--button-color-darkest" as any]: OpenColor[color][shade + 2],
 | 
			
		||||
      }}
 | 
			
		||||
      title={title}
 | 
			
		||||
      aria-label={title}
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ImageExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const someElementIsSelected = isSomeElementSelected(elements, appState);
 | 
			
		||||
  const [exportSelected, setExportSelected] = useState(someElementIsSelected);
 | 
			
		||||
  const previewRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const { exportBackground, viewBackgroundColor } = appState;
 | 
			
		||||
 | 
			
		||||
  const exportedElements = exportSelected
 | 
			
		||||
    ? getSelectedElements(elements, appState)
 | 
			
		||||
    : elements;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setExportSelected(someElementIsSelected);
 | 
			
		||||
  }, [someElementIsSelected]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const previewNode = previewRef.current;
 | 
			
		||||
    if (!previewNode) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    exportToCanvas(exportedElements, appState, files, {
 | 
			
		||||
      exportBackground,
 | 
			
		||||
      viewBackgroundColor,
 | 
			
		||||
      exportPadding,
 | 
			
		||||
    })
 | 
			
		||||
      .then((canvas) => {
 | 
			
		||||
        // if converting to blob fails, there's some problem that will
 | 
			
		||||
        // likely prevent preview and export (e.g. canvas too big)
 | 
			
		||||
        return canvasToBlob(canvas).then(() => {
 | 
			
		||||
          renderPreview(canvas, previewNode);
 | 
			
		||||
        });
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
        renderPreview(new CanvasError(), previewNode);
 | 
			
		||||
      });
 | 
			
		||||
  }, [
 | 
			
		||||
    appState,
 | 
			
		||||
    files,
 | 
			
		||||
    exportedElements,
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    exportPadding,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ExportDialog">
 | 
			
		||||
      <div className="ExportDialog__preview" ref={previewRef} />
 | 
			
		||||
      {supportsContextFilters &&
 | 
			
		||||
        actionManager.renderAction("exportWithDarkMode")}
 | 
			
		||||
      <div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "grid",
 | 
			
		||||
            gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
 | 
			
		||||
            // dunno why this is needed, but when the items wrap it creates
 | 
			
		||||
            // an overflow
 | 
			
		||||
            overflow: "hidden",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {actionManager.renderAction("changeExportBackground")}
 | 
			
		||||
          {someElementIsSelected && (
 | 
			
		||||
            <CheckboxItem
 | 
			
		||||
              checked={exportSelected}
 | 
			
		||||
              onChange={(checked) => setExportSelected(checked)}
 | 
			
		||||
            >
 | 
			
		||||
              {t("labels.onlySelected")}
 | 
			
		||||
            </CheckboxItem>
 | 
			
		||||
          )}
 | 
			
		||||
          {actionManager.renderAction("changeExportEmbedScene")}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
 | 
			
		||||
        <Stack.Row gap={2}>
 | 
			
		||||
          {actionManager.renderAction("changeExportScale")}
 | 
			
		||||
        </Stack.Row>
 | 
			
		||||
        <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
          justifyContent: "center",
 | 
			
		||||
          margin: ".6em 0",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {!nativeFileSystemSupported &&
 | 
			
		||||
          actionManager.renderAction("changeProjectName")}
 | 
			
		||||
      </div>
 | 
			
		||||
      <Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
 | 
			
		||||
        <ExportButton
 | 
			
		||||
          color="indigo"
 | 
			
		||||
          title={t("buttons.exportToPng")}
 | 
			
		||||
          aria-label={t("buttons.exportToPng")}
 | 
			
		||||
          onClick={() => onExportToPng(exportedElements)}
 | 
			
		||||
        >
 | 
			
		||||
          PNG
 | 
			
		||||
        </ExportButton>
 | 
			
		||||
        <ExportButton
 | 
			
		||||
          color="red"
 | 
			
		||||
          title={t("buttons.exportToSvg")}
 | 
			
		||||
          aria-label={t("buttons.exportToSvg")}
 | 
			
		||||
          onClick={() => onExportToSvg(exportedElements)}
 | 
			
		||||
        >
 | 
			
		||||
          SVG
 | 
			
		||||
        </ExportButton>
 | 
			
		||||
        {probablySupportsClipboardBlob && (
 | 
			
		||||
          <ExportButton
 | 
			
		||||
            title={t("buttons.copyPngToClipboard")}
 | 
			
		||||
            onClick={() => onExportToClipboard(exportedElements)}
 | 
			
		||||
            color="gray"
 | 
			
		||||
            shade={7}
 | 
			
		||||
          >
 | 
			
		||||
            {clipboard}
 | 
			
		||||
          </ExportButton>
 | 
			
		||||
        )}
 | 
			
		||||
      </Stack.Row>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ImageExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        data-testid="image-export-button"
 | 
			
		||||
        icon={exportImage}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.exportImage")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        title={t("buttons.exportImage")}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
 | 
			
		||||
          <ImageExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            files={files}
 | 
			
		||||
            exportPadding={exportPadding}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onExportToPng={onExportToPng}
 | 
			
		||||
            onExportToSvg={onExportToSvg}
 | 
			
		||||
            onExportToClipboard={onExportToClipboard}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
          />
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,30 +1,25 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  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 [loading, setLoading] = useState(true);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const updateLang = async () => {
 | 
			
		||||
      await setLanguage(currentLang);
 | 
			
		||||
    };
 | 
			
		||||
    const currentLang =
 | 
			
		||||
      languages.find((lang) => lang.code === this.props.langCode) ||
 | 
			
		||||
      defaultLang;
 | 
			
		||||
    await setLanguage(currentLang);
 | 
			
		||||
    this.setState({
 | 
			
		||||
      isLoading: false,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
      languages.find((lang) => lang.code === props.langCode) || defaultLang;
 | 
			
		||||
    updateLang();
 | 
			
		||||
    setLoading(false);
 | 
			
		||||
  }, [props.langCode]);
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    return this.state.isLoading ? <LoadingMessage /> : this.props.children;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
  return loading ? <LoadingMessage /> : props.children;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@
 | 
			
		||||
  .Island {
 | 
			
		||||
    --padding: 0;
 | 
			
		||||
    background-color: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: saturate(100%) blur(10px);
 | 
			
		||||
    box-shadow: var(--shadow-island);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    padding: calc(var(--padding) * var(--space-factor));
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										135
									
								
								src/components/JSONExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								src/components/JSONExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,135 @@
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { exportFile, exportToFileIcon, link } from "./icons";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { actionSaveFileToDisk } from "../actions/actionExport";
 | 
			
		||||
import { Card } from "./Card";
 | 
			
		||||
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
 | 
			
		||||
export type ExportCB = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  scale?: number,
 | 
			
		||||
) => void;
 | 
			
		||||
 | 
			
		||||
const JSONExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
}) => {
 | 
			
		||||
  const { onExportToBackend } = exportOpts;
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ExportDialog ExportDialog--json">
 | 
			
		||||
      <div className="ExportDialog-cards">
 | 
			
		||||
        {exportOpts.saveFileToDisk && (
 | 
			
		||||
          <Card color="lime">
 | 
			
		||||
            <div className="Card-icon">{exportToFileIcon}</div>
 | 
			
		||||
            <h2>{t("exportDialog.disk_title")}</h2>
 | 
			
		||||
            <div className="Card-details">
 | 
			
		||||
              {t("exportDialog.disk_details")}
 | 
			
		||||
              {!nativeFileSystemSupported &&
 | 
			
		||||
                actionManager.renderAction("changeProjectName")}
 | 
			
		||||
            </div>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              className="Card-button"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("exportDialog.disk_button")}
 | 
			
		||||
              aria-label={t("exportDialog.disk_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                actionManager.executeAction(actionSaveFileToDisk);
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
        {onExportToBackend && (
 | 
			
		||||
          <Card color="pink">
 | 
			
		||||
            <div className="Card-icon">{link}</div>
 | 
			
		||||
            <h2>{t("exportDialog.link_title")}</h2>
 | 
			
		||||
            <div className="Card-details">{t("exportDialog.link_details")}</div>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              className="Card-button"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("exportDialog.link_button")}
 | 
			
		||||
              aria-label={t("exportDialog.link_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                onExportToBackend(elements, appState, files, canvas)
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
        {exportOpts.renderCustomUI &&
 | 
			
		||||
          exportOpts.renderCustomUI(elements, appState, files, canvas)}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const JSONExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        data-testid="json-export-button"
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.export")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        title={t("buttons.export")}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
 | 
			
		||||
          <JSONExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            files={files}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
            exportOpts={exportOpts}
 | 
			
		||||
            canvas={canvas}
 | 
			
		||||
          />
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -40,50 +40,17 @@
 | 
			
		||||
  .layer-ui__wrapper {
 | 
			
		||||
    z-index: var(--zIndex-layerUI);
 | 
			
		||||
 | 
			
		||||
    .encrypted-icon {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      margin-inline-start: 15px;
 | 
			
		||||
    &__top-right {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      border-radius: var(--space-factor);
 | 
			
		||||
      color: $oc-green-9;
 | 
			
		||||
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 1.2rem;
 | 
			
		||||
        height: 1.2rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__github-corner {
 | 
			
		||||
      top: 0;
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] & {
 | 
			
		||||
        right: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="rtl"] & {
 | 
			
		||||
        left: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      width: 40px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__footer {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      z-index: 100;
 | 
			
		||||
      bottom: 0;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] & {
 | 
			
		||||
        right: 0;
 | 
			
		||||
      &-right {
 | 
			
		||||
        z-index: 100;
 | 
			
		||||
        display: flex;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="rtl"] & {
 | 
			
		||||
        left: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      width: 190px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .zen-mode-transition {
 | 
			
		||||
@@ -105,11 +72,15 @@
 | 
			
		||||
        transform: translate(-999px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] &.App-menu_bottom--transition-left {
 | 
			
		||||
        transform: translate(-92px, 0);
 | 
			
		||||
      :root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
 | 
			
		||||
        transform: translate(-76px, 0);
 | 
			
		||||
      }
 | 
			
		||||
      :root[dir="rtl"] &.App-menu_bottom--transition-left {
 | 
			
		||||
        transform: translate(92px, 0);
 | 
			
		||||
      :root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
 | 
			
		||||
        transform: translate(76px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.layer-ui__wrapper__footer-left--transition-bottom {
 | 
			
		||||
        transform: translate(0, 92px);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -137,5 +108,27 @@
 | 
			
		||||
        transition-delay: 0.8s;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .layer-ui__wrapper__footer-center {
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
      & > * {
 | 
			
		||||
        pointer-events: all;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    .layer-ui__wrapper__footer-left,
 | 
			
		||||
    .layer-ui__wrapper__footer-right,
 | 
			
		||||
    .disable-zen-mode--visible {
 | 
			
		||||
      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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,17 +10,17 @@ import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { CLASSES } from "../constants";
 | 
			
		||||
import { exportCanvas } from "../data";
 | 
			
		||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import { isTextElement, showSelectedShapeActions } from "../element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { Language, t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
 | 
			
		||||
import { ExportType } from "../scene/types";
 | 
			
		||||
import {
 | 
			
		||||
  AppProps,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
} from "../types";
 | 
			
		||||
@@ -29,16 +29,15 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import CollabButton from "./CollabButton";
 | 
			
		||||
import { ErrorDialog } from "./ErrorDialog";
 | 
			
		||||
import { ExportCB, ExportDialog } from "./ExportDialog";
 | 
			
		||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 | 
			
		||||
import { FixedSideContainer } from "./FixedSideContainer";
 | 
			
		||||
import { GitHubCorner } from "./GitHubCorner";
 | 
			
		||||
import { HintViewer } from "./HintViewer";
 | 
			
		||||
import { exportFile, load, shield, trash } from "./icons";
 | 
			
		||||
import { exportFile, load, trash } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import "./LayerUI.scss";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { LockButton } from "./LockButton";
 | 
			
		||||
import { MobileMenu } from "./MobileMenu";
 | 
			
		||||
import { PasteChartDialog } from "./PasteChartDialog";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
@@ -47,10 +46,15 @@ import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import { JSONExportDialog } from "./JSONExportDialog";
 | 
			
		||||
import { LibraryButton } from "./LibraryButton";
 | 
			
		||||
import { isImageFileHandle } from "../data/blob";
 | 
			
		||||
 | 
			
		||||
interface LayerUIProps {
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
@@ -63,15 +67,18 @@ interface LayerUIProps {
 | 
			
		||||
  toggleZenMode: () => void;
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  onExportToBackend?: (
 | 
			
		||||
    exportedElements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  renderTopRightUI?: (
 | 
			
		||||
    isMobile: boolean,
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    canvas: HTMLCanvasElement | null,
 | 
			
		||||
  ) => void;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
  ) => JSX.Element | null;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  viewModeEnabled: boolean;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  UIOptions: AppProps["UIOptions"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
@@ -103,26 +110,36 @@ const useOnClickOutside = (
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LibraryMenuItems = ({
 | 
			
		||||
  library,
 | 
			
		||||
  libraryItems,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  setLibraryItems,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  files,
 | 
			
		||||
  id,
 | 
			
		||||
}: {
 | 
			
		||||
  library: LibraryItems;
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: (index: number) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: (elements: LibraryItem) => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  setLibraryItems: (library: LibraryItems) => void;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
 | 
			
		||||
  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 = [];
 | 
			
		||||
@@ -140,7 +157,7 @@ const LibraryMenuItems = ({
 | 
			
		||||
        aria-label={t("buttons.load")}
 | 
			
		||||
        icon={load}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          importLibraryFromJSON()
 | 
			
		||||
          importLibraryFromJSON(library)
 | 
			
		||||
            .then(() => {
 | 
			
		||||
              // Close and then open to get the libraries updated
 | 
			
		||||
              setAppState({ isLibraryOpen: false });
 | 
			
		||||
@@ -152,7 +169,7 @@ const LibraryMenuItems = ({
 | 
			
		||||
            });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {!!library.length && (
 | 
			
		||||
      {!!libraryItems.length && (
 | 
			
		||||
        <>
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="export"
 | 
			
		||||
@@ -161,7 +178,7 @@ const LibraryMenuItems = ({
 | 
			
		||||
            aria-label={t("buttons.export")}
 | 
			
		||||
            icon={exportFile}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              saveLibraryAsJSON()
 | 
			
		||||
              saveLibraryAsJSON(library)
 | 
			
		||||
                .catch(muteFSAbortError)
 | 
			
		||||
                .catch((error) => {
 | 
			
		||||
                  setAppState({ errorMessage: error.message });
 | 
			
		||||
@@ -176,8 +193,9 @@ const LibraryMenuItems = ({
 | 
			
		||||
            icon={trash}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              if (window.confirm(t("alerts.resetLibrary"))) {
 | 
			
		||||
                Library.resetLibrary();
 | 
			
		||||
                library.resetLibrary();
 | 
			
		||||
                setLibraryItems([]);
 | 
			
		||||
                focusContainer();
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
@@ -186,7 +204,7 @@ const LibraryMenuItems = ({
 | 
			
		||||
      <a
 | 
			
		||||
        href={`https://libraries.excalidraw.com?target=${
 | 
			
		||||
          window.name || "_blank"
 | 
			
		||||
        }&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
 | 
			
		||||
        }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
 | 
			
		||||
        target="_excalidraw_libraries"
 | 
			
		||||
      >
 | 
			
		||||
        {t("labels.libraries")}
 | 
			
		||||
@@ -201,13 +219,14 @@ const LibraryMenuItems = ({
 | 
			
		||||
      const shouldAddPendingElements: boolean =
 | 
			
		||||
        pendingElements.length > 0 &&
 | 
			
		||||
        !addedPendingElements &&
 | 
			
		||||
        y + x >= library.length;
 | 
			
		||||
        y + x >= libraryItems.length;
 | 
			
		||||
      addedPendingElements = addedPendingElements || shouldAddPendingElements;
 | 
			
		||||
 | 
			
		||||
      children.push(
 | 
			
		||||
        <Stack.Col key={x}>
 | 
			
		||||
          <LibraryUnit
 | 
			
		||||
            elements={library[y + x]}
 | 
			
		||||
            elements={libraryItems[y + x]}
 | 
			
		||||
            files={files}
 | 
			
		||||
            pendingElements={
 | 
			
		||||
              shouldAddPendingElements ? pendingElements : undefined
 | 
			
		||||
            }
 | 
			
		||||
@@ -215,7 +234,7 @@ const LibraryMenuItems = ({
 | 
			
		||||
            onClick={
 | 
			
		||||
              shouldAddPendingElements
 | 
			
		||||
                ? onAddToLibrary.bind(null, pendingElements)
 | 
			
		||||
                : onInsertShape.bind(null, library[y + x])
 | 
			
		||||
                : onInsertShape.bind(null, libraryItems[y + x])
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </Stack.Col>,
 | 
			
		||||
@@ -240,15 +259,25 @@ const LibraryMenu = ({
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  files,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onClickOutside: (event: MouseEvent) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: () => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useOnClickOutside(ref, (event) => {
 | 
			
		||||
@@ -265,16 +294,16 @@ const LibraryMenu = ({
 | 
			
		||||
    "preloading" | "loading" | "ready"
 | 
			
		||||
  >("preloading");
 | 
			
		||||
 | 
			
		||||
  const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
 | 
			
		||||
  const loadingTimerRef = useRef<number | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    Promise.race([
 | 
			
		||||
      new Promise((resolve) => {
 | 
			
		||||
        loadingTimerRef.current = setTimeout(() => {
 | 
			
		||||
        loadingTimerRef.current = window.setTimeout(() => {
 | 
			
		||||
          resolve("loading");
 | 
			
		||||
        }, 100);
 | 
			
		||||
      }),
 | 
			
		||||
      Library.loadLibrary().then((items) => {
 | 
			
		||||
      library.loadLibrary().then((items) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setIsLoading("ready");
 | 
			
		||||
      }),
 | 
			
		||||
@@ -286,24 +315,39 @@ const LibraryMenu = ({
 | 
			
		||||
    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);
 | 
			
		||||
    setLibraryItems(nextItems);
 | 
			
		||||
  }, []);
 | 
			
		||||
  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();
 | 
			
		||||
      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 = [...items, elements];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      Library.saveLibrary(nextItems);
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary],
 | 
			
		||||
    [onAddToLibrary, library, setAppState],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return loadingState === "preloading" ? null : (
 | 
			
		||||
@@ -314,7 +358,7 @@ const LibraryMenu = ({
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LibraryMenuItems
 | 
			
		||||
          library={libraryItems}
 | 
			
		||||
          libraryItems={libraryItems}
 | 
			
		||||
          onRemoveFromLibrary={removeFromLibrary}
 | 
			
		||||
          onAddToLibrary={addToLibrary}
 | 
			
		||||
          onInsertShape={onInsertShape}
 | 
			
		||||
@@ -322,6 +366,11 @@ const LibraryMenu = ({
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          setLibraryItems={setLibraryItems}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
          focusContainer={focusContainer}
 | 
			
		||||
          library={library}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
          files={files}
 | 
			
		||||
          id={id}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Island>
 | 
			
		||||
@@ -331,6 +380,7 @@ const LibraryMenu = ({
 | 
			
		||||
const LayerUI = ({
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  canvas,
 | 
			
		||||
  elements,
 | 
			
		||||
@@ -342,75 +392,86 @@ const LayerUI = ({
 | 
			
		||||
  showThemeBtn,
 | 
			
		||||
  toggleZenMode,
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
  renderTopRightUI,
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
  viewModeEnabled,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  UIOptions,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
}: LayerUIProps) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
  const renderEncryptedIcon = () => (
 | 
			
		||||
    <a
 | 
			
		||||
      className={clsx("encrypted-icon tooltip zen-mode-visibility", {
 | 
			
		||||
        "zen-mode-visibility--hidden": zenModeEnabled,
 | 
			
		||||
      })}
 | 
			
		||||
      href="https://blog.excalidraw.com/end-to-end-encryption/"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      aria-label={t("encrypted.link")}
 | 
			
		||||
    >
 | 
			
		||||
      <Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
 | 
			
		||||
        {shield}
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </a>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderExportDialog = () => {
 | 
			
		||||
  const renderJSONExportDialog = () => {
 | 
			
		||||
    if (!UIOptions.canvasActions.export) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <JSONExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        files={files}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        exportOpts={UIOptions.canvasActions.export}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderImageExportDialog = () => {
 | 
			
		||||
    if (!UIOptions.canvasActions.saveAsImage) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const createExporter = (type: ExportType): ExportCB => async (
 | 
			
		||||
      exportedElements,
 | 
			
		||||
      scale,
 | 
			
		||||
    ) => {
 | 
			
		||||
      if (canvas) {
 | 
			
		||||
        await exportCanvas(type, exportedElements, appState, canvas, {
 | 
			
		||||
      const fileHandle = await exportCanvas(
 | 
			
		||||
        type,
 | 
			
		||||
        exportedElements,
 | 
			
		||||
        appState,
 | 
			
		||||
        files,
 | 
			
		||||
        {
 | 
			
		||||
          exportBackground: appState.exportBackground,
 | 
			
		||||
          name: appState.name,
 | 
			
		||||
          viewBackgroundColor: appState.viewBackgroundColor,
 | 
			
		||||
          scale,
 | 
			
		||||
          shouldAddWatermark: appState.shouldAddWatermark,
 | 
			
		||||
        })
 | 
			
		||||
          .catch(muteFSAbortError)
 | 
			
		||||
          .catch((error) => {
 | 
			
		||||
            console.error(error);
 | 
			
		||||
            setAppState({ errorMessage: error.message });
 | 
			
		||||
          });
 | 
			
		||||
        },
 | 
			
		||||
      )
 | 
			
		||||
        .catch(muteFSAbortError)
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error(error);
 | 
			
		||||
          setAppState({ errorMessage: error.message });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        appState.exportEmbedScene &&
 | 
			
		||||
        fileHandle &&
 | 
			
		||||
        isImageFileHandle(fileHandle)
 | 
			
		||||
      ) {
 | 
			
		||||
        setAppState({ fileHandle });
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ExportDialog
 | 
			
		||||
      <ImageExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        files={files}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        onExportToPng={createExporter("png")}
 | 
			
		||||
        onExportToSvg={createExporter("svg")}
 | 
			
		||||
        onExportToClipboard={createExporter("clipboard")}
 | 
			
		||||
        onExportToBackend={
 | 
			
		||||
          onExportToBackend
 | 
			
		||||
            ? (elements) => {
 | 
			
		||||
                onExportToBackend &&
 | 
			
		||||
                  onExportToBackend(elements, appState, canvas);
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const Separator = () => {
 | 
			
		||||
    return <div style={{ width: ".625em" }} />;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderViewModeCanvasActions = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Section
 | 
			
		||||
@@ -424,15 +485,15 @@ const LayerUI = ({
 | 
			
		||||
        <Island padding={2} style={{ zIndex: 1 }}>
 | 
			
		||||
          <Stack.Col gap={4}>
 | 
			
		||||
            <Stack.Row gap={1} justifyContent="space-between">
 | 
			
		||||
              {actionManager.renderAction("saveScene")}
 | 
			
		||||
              {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
              {renderExportDialog()}
 | 
			
		||||
              {renderJSONExportDialog()}
 | 
			
		||||
              {renderImageExportDialog()}
 | 
			
		||||
            </Stack.Row>
 | 
			
		||||
          </Stack.Col>
 | 
			
		||||
        </Island>
 | 
			
		||||
      </Section>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderCanvasActions = () => (
 | 
			
		||||
    <Section
 | 
			
		||||
      heading="canvasActions"
 | 
			
		||||
@@ -445,11 +506,12 @@ const LayerUI = ({
 | 
			
		||||
      <Island padding={2} style={{ zIndex: 1 }}>
 | 
			
		||||
        <Stack.Col gap={4}>
 | 
			
		||||
          <Stack.Row gap={1} justifyContent="space-between">
 | 
			
		||||
            {actionManager.renderAction("loadScene")}
 | 
			
		||||
            {actionManager.renderAction("saveScene")}
 | 
			
		||||
            {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
            {renderExportDialog()}
 | 
			
		||||
            {actionManager.renderAction("clearCanvas")}
 | 
			
		||||
            <Separator />
 | 
			
		||||
            {actionManager.renderAction("loadScene")}
 | 
			
		||||
            {renderJSONExportDialog()}
 | 
			
		||||
            {renderImageExportDialog()}
 | 
			
		||||
            <Separator />
 | 
			
		||||
            {onCollabButtonClick && (
 | 
			
		||||
              <CollabButton
 | 
			
		||||
                isCollaborating={isCollaborating}
 | 
			
		||||
@@ -464,6 +526,9 @@ const LayerUI = ({
 | 
			
		||||
            setAppState={setAppState}
 | 
			
		||||
            showThemeBtn={showThemeBtn}
 | 
			
		||||
          />
 | 
			
		||||
          {appState.fileHandle && (
 | 
			
		||||
            <>{actionManager.renderAction("saveToActiveFile")}</>
 | 
			
		||||
          )}
 | 
			
		||||
        </Stack.Col>
 | 
			
		||||
      </Island>
 | 
			
		||||
    </Section>
 | 
			
		||||
@@ -482,7 +547,8 @@ const LayerUI = ({
 | 
			
		||||
        style={{
 | 
			
		||||
          // we want to make sure this doesn't overflow so substracting 200
 | 
			
		||||
          // which is approximately height of zoom footer and top left menu items with some buffer
 | 
			
		||||
          maxHeight: `${appState.height - 200}px`,
 | 
			
		||||
          // if active file name is displayed, subtracting 248 to account for its height
 | 
			
		||||
          maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <SelectedShapeActions
 | 
			
		||||
@@ -517,6 +583,11 @@ const LayerUI = ({
 | 
			
		||||
      onAddToLibrary={deselectItems}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
      libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
      focusContainer={focusContainer}
 | 
			
		||||
      library={library}
 | 
			
		||||
      theme={appState.theme}
 | 
			
		||||
      files={files}
 | 
			
		||||
      id={id}
 | 
			
		||||
    />
 | 
			
		||||
  ) : null;
 | 
			
		||||
 | 
			
		||||
@@ -543,6 +614,12 @@ const LayerUI = ({
 | 
			
		||||
              {(heading) => (
 | 
			
		||||
                <Stack.Col gap={4} align="start">
 | 
			
		||||
                  <Stack.Row gap={1}>
 | 
			
		||||
                    <LockButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
                      onChange={onLockToggle}
 | 
			
		||||
                      title={t("toolBar.lock")}
 | 
			
		||||
                    />
 | 
			
		||||
                    <Island
 | 
			
		||||
                      padding={1}
 | 
			
		||||
                      className={clsx({ "zen-mode": zenModeEnabled })}
 | 
			
		||||
@@ -554,15 +631,17 @@ const LayerUI = ({
 | 
			
		||||
                          canvas={canvas}
 | 
			
		||||
                          elementType={appState.elementType}
 | 
			
		||||
                          setAppState={setAppState}
 | 
			
		||||
                          isLibraryOpen={appState.isLibraryOpen}
 | 
			
		||||
                          onImageAction={({ pointerType }) => {
 | 
			
		||||
                            onImageAction({
 | 
			
		||||
                              insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                            });
 | 
			
		||||
                          }}
 | 
			
		||||
                        />
 | 
			
		||||
                      </Stack.Row>
 | 
			
		||||
                    </Island>
 | 
			
		||||
                    <LockIcon
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
                      onChange={onLockToggle}
 | 
			
		||||
                      title={t("toolBar.lock")}
 | 
			
		||||
                    <LibraryButton
 | 
			
		||||
                      appState={appState}
 | 
			
		||||
                      setAppState={setAppState}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Stack.Row>
 | 
			
		||||
                  {libraryMenu}
 | 
			
		||||
@@ -570,24 +649,32 @@ const LayerUI = ({
 | 
			
		||||
              )}
 | 
			
		||||
            </Section>
 | 
			
		||||
          )}
 | 
			
		||||
          <UserList
 | 
			
		||||
            className={clsx("zen-mode-transition", {
 | 
			
		||||
              "transition-right": zenModeEnabled,
 | 
			
		||||
            })}
 | 
			
		||||
          <div
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              "layer-ui__wrapper__top-right zen-mode-transition",
 | 
			
		||||
              {
 | 
			
		||||
                "transition-right": zenModeEnabled,
 | 
			
		||||
              },
 | 
			
		||||
            )}
 | 
			
		||||
          >
 | 
			
		||||
            {appState.collaborators.size > 0 &&
 | 
			
		||||
              Array.from(appState.collaborators)
 | 
			
		||||
                // Collaborator is either not initialized or is actually the current user.
 | 
			
		||||
                .filter(([_, client]) => Object.keys(client).length !== 0)
 | 
			
		||||
                .map(([clientId, client]) => (
 | 
			
		||||
                  <Tooltip
 | 
			
		||||
                    label={client.username || "Unknown user"}
 | 
			
		||||
                    key={clientId}
 | 
			
		||||
                  >
 | 
			
		||||
                    {actionManager.renderAction("goToCollaborator", clientId)}
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                ))}
 | 
			
		||||
          </UserList>
 | 
			
		||||
            <UserList>
 | 
			
		||||
              {appState.collaborators.size > 0 &&
 | 
			
		||||
                Array.from(appState.collaborators)
 | 
			
		||||
                  // Collaborator is either not initialized or is actually the current user.
 | 
			
		||||
                  .filter(([_, client]) => Object.keys(client).length !== 0)
 | 
			
		||||
                  .map(([clientId, client]) => (
 | 
			
		||||
                    <Tooltip
 | 
			
		||||
                      label={client.username || "Unknown user"}
 | 
			
		||||
                      key={clientId}
 | 
			
		||||
                    >
 | 
			
		||||
                      {actionManager.renderAction("goToCollaborator", {
 | 
			
		||||
                        id: clientId,
 | 
			
		||||
                      })}
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  ))}
 | 
			
		||||
            </UserList>
 | 
			
		||||
            {renderTopRightUI?.(isMobile, appState)}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </FixedSideContainer>
 | 
			
		||||
    );
 | 
			
		||||
@@ -595,60 +682,71 @@ const LayerUI = ({
 | 
			
		||||
 | 
			
		||||
  const renderBottomAppMenu = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("App-menu App-menu_bottom zen-mode-transition", {
 | 
			
		||||
          "App-menu_bottom--transition-left": zenModeEnabled,
 | 
			
		||||
        })}
 | 
			
		||||
      <footer
 | 
			
		||||
        role="contentinfo"
 | 
			
		||||
        className="layer-ui__wrapper__footer App-menu App-menu_bottom"
 | 
			
		||||
      >
 | 
			
		||||
        <Stack.Col gap={2}>
 | 
			
		||||
          <Section heading="canvasActions">
 | 
			
		||||
            <Island padding={1}>
 | 
			
		||||
              <ZoomActions
 | 
			
		||||
                renderAction={actionManager.renderAction}
 | 
			
		||||
                zoom={appState.zoom}
 | 
			
		||||
              />
 | 
			
		||||
            </Island>
 | 
			
		||||
            {renderEncryptedIcon()}
 | 
			
		||||
          </Section>
 | 
			
		||||
        </Stack.Col>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-left zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          <Stack.Col gap={2}>
 | 
			
		||||
            <Section heading="canvasActions">
 | 
			
		||||
              <Island padding={1}>
 | 
			
		||||
                <ZoomActions
 | 
			
		||||
                  renderAction={actionManager.renderAction}
 | 
			
		||||
                  zoom={appState.zoom}
 | 
			
		||||
                />
 | 
			
		||||
              </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>
 | 
			
		||||
          </Stack.Col>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-center zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {renderCustomFooter?.(false, appState)}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
  const renderGitHubCorner = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <aside
 | 
			
		||||
        className={clsx(
 | 
			
		||||
          "layer-ui__wrapper__github-corner zen-mode-transition",
 | 
			
		||||
          {
 | 
			
		||||
            "transition-right": zenModeEnabled,
 | 
			
		||||
          },
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        <GitHubCorner theme={appState.theme} />
 | 
			
		||||
      </aside>
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-right zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "transition-right disable-pointerEvents": zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {actionManager.renderAction("toggleShortcuts")}
 | 
			
		||||
        </div>
 | 
			
		||||
        <button
 | 
			
		||||
          className={clsx("disable-zen-mode", {
 | 
			
		||||
            "disable-zen-mode--visible": showExitZenModeBtn,
 | 
			
		||||
          })}
 | 
			
		||||
          onClick={toggleZenMode}
 | 
			
		||||
        >
 | 
			
		||||
          {t("buttons.exitZenMode")}
 | 
			
		||||
        </button>
 | 
			
		||||
      </footer>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
  const renderFooter = () => (
 | 
			
		||||
    <footer role="contentinfo" className="layer-ui__wrapper__footer">
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("zen-mode-transition", {
 | 
			
		||||
          "transition-right disable-pointerEvents": zenModeEnabled,
 | 
			
		||||
        })}
 | 
			
		||||
      >
 | 
			
		||||
        {renderCustomFooter?.(false)}
 | 
			
		||||
        {actionManager.renderAction("toggleShortcuts")}
 | 
			
		||||
      </div>
 | 
			
		||||
      <button
 | 
			
		||||
        className={clsx("disable-zen-mode", {
 | 
			
		||||
          "disable-zen-mode--visible": showExitZenModeBtn,
 | 
			
		||||
        })}
 | 
			
		||||
        onClick={toggleZenMode}
 | 
			
		||||
      >
 | 
			
		||||
        {t("buttons.exitZenMode")}
 | 
			
		||||
      </button>
 | 
			
		||||
    </footer>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const dialogs = (
 | 
			
		||||
    <>
 | 
			
		||||
@@ -660,7 +758,11 @@ const LayerUI = ({
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.showHelpDialog && (
 | 
			
		||||
        <HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
 | 
			
		||||
        <HelpDialog
 | 
			
		||||
          onClose={() => {
 | 
			
		||||
            setAppState({ showHelpDialog: false });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.pasteDialog.shown && (
 | 
			
		||||
        <PasteChartDialog
 | 
			
		||||
@@ -685,7 +787,8 @@ const LayerUI = ({
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        libraryMenu={libraryMenu}
 | 
			
		||||
        exportButton={renderExportDialog()}
 | 
			
		||||
        renderJSONExportDialog={renderJSONExportDialog}
 | 
			
		||||
        renderImageExportDialog={renderImageExportDialog}
 | 
			
		||||
        setAppState={setAppState}
 | 
			
		||||
        onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
        onLockToggle={onLockToggle}
 | 
			
		||||
@@ -694,6 +797,8 @@ const LayerUI = ({
 | 
			
		||||
        renderCustomFooter={renderCustomFooter}
 | 
			
		||||
        viewModeEnabled={viewModeEnabled}
 | 
			
		||||
        showThemeBtn={showThemeBtn}
 | 
			
		||||
        onImageAction={onImageAction}
 | 
			
		||||
        renderTopRightUI={renderTopRightUI}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ) : (
 | 
			
		||||
@@ -708,8 +813,6 @@ const LayerUI = ({
 | 
			
		||||
      {dialogs}
 | 
			
		||||
      {renderFixedSideContainer()}
 | 
			
		||||
      {renderBottomAppMenu()}
 | 
			
		||||
      {renderGitHubCorner()}
 | 
			
		||||
      {renderFooter()}
 | 
			
		||||
      {appState.scrolledOutside && (
 | 
			
		||||
        <button
 | 
			
		||||
          className="scroll-back-to-content"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/LibraryButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/LibraryButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { capitalizeString } from "../utils";
 | 
			
		||||
 | 
			
		||||
const LIBRARY_ICON = (
 | 
			
		||||
  <svg viewBox="0 0 576 512">
 | 
			
		||||
    <path
 | 
			
		||||
      fill="currentColor"
 | 
			
		||||
      d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
 | 
			
		||||
    ></path>
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const LibraryButton: React.FC<{
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
}> = ({ appState, setAppState }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
 | 
			
		||||
        `ToolIcon_size_medium`,
 | 
			
		||||
        {
 | 
			
		||||
          "zen-mode-visibility--hidden": appState.zenModeEnabled,
 | 
			
		||||
        },
 | 
			
		||||
      )}
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 0`}
 | 
			
		||||
      style={{ marginInlineStart: "var(--space-factor)" }}
 | 
			
		||||
    >
 | 
			
		||||
      <input
 | 
			
		||||
        className="ToolIcon_type_checkbox"
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        name="editor-library"
 | 
			
		||||
        onChange={(event) => {
 | 
			
		||||
          setAppState({ isLibraryOpen: event.target.checked });
 | 
			
		||||
        }}
 | 
			
		||||
        checked={appState.isLibraryOpen}
 | 
			
		||||
        aria-label={capitalizeString(t("toolBar.library"))}
 | 
			
		||||
        aria-keyshortcuts="0"
 | 
			
		||||
      />
 | 
			
		||||
      <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
 | 
			
		||||
    </label>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
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 { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import { BinaryFiles, LibraryItem } from "../types";
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
 | 
			
		||||
// fa-plus
 | 
			
		||||
@@ -21,39 +21,44 @@ const PLUS_ICON = (
 | 
			
		||||
 | 
			
		||||
export const LibraryUnit = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  files,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onClick,
 | 
			
		||||
}: {
 | 
			
		||||
  elements?: LibraryItem;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  pendingElements?: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: () => void;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const elementsToRender = elements || pendingElements;
 | 
			
		||||
    if (!elementsToRender) {
 | 
			
		||||
    const node = ref.current;
 | 
			
		||||
    if (!node) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const svg = exportToSvg(elementsToRender, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
    for (const child of ref.current!.children) {
 | 
			
		||||
      if (child.tagName !== "svg") {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      ref.current!.removeChild(child);
 | 
			
		||||
    }
 | 
			
		||||
    ref.current!.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
    const current = ref.current!;
 | 
			
		||||
    (async () => {
 | 
			
		||||
      const elementsToRender = elements || pendingElements;
 | 
			
		||||
      if (!elementsToRender) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const svg = await exportToSvg(
 | 
			
		||||
        elementsToRender,
 | 
			
		||||
        {
 | 
			
		||||
          exportBackground: false,
 | 
			
		||||
          viewBackgroundColor: oc.white,
 | 
			
		||||
        },
 | 
			
		||||
        files,
 | 
			
		||||
      );
 | 
			
		||||
      node.innerHTML = svg.outerHTML;
 | 
			
		||||
    })();
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      current.removeChild(svg);
 | 
			
		||||
      node.innerHTML = "";
 | 
			
		||||
    };
 | 
			
		||||
  }, [elements, pendingElements]);
 | 
			
		||||
  }, [elements, pendingElements, files]);
 | 
			
		||||
 | 
			
		||||
  const [isHovered, setIsHovered] = useState(false);
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const LoadingMessage = () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,20 +2,17 @@ import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
type LockIconSize = "s" | "m";
 | 
			
		||||
import { ToolButtonSize } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
type LockIconProps = {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange?(): void;
 | 
			
		||||
  size?: LockIconSize;
 | 
			
		||||
  zenModeEnabled?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DEFAULT_SIZE: LockIconSize = "m";
 | 
			
		||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
 | 
			
		||||
 | 
			
		||||
const ICONS = {
 | 
			
		||||
  CHECKED: (
 | 
			
		||||
@@ -41,12 +38,12 @@ const ICONS = {
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const LockIcon = (props: LockIconProps) => {
 | 
			
		||||
export const LockButton = (props: LockIconProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
 | 
			
		||||
        `ToolIcon_size_${props.size || DEFAULT_SIZE}`,
 | 
			
		||||
        `ToolIcon_size_${DEFAULT_SIZE}`,
 | 
			
		||||
        {
 | 
			
		||||
          "zen-mode-visibility--hidden": props.zenModeEnabled,
 | 
			
		||||
        },
 | 
			
		||||
@@ -57,7 +54,6 @@ export const LockIcon = (props: LockIconProps) => {
 | 
			
		||||
        className="ToolIcon_type_checkbox"
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        name={props.name}
 | 
			
		||||
        id={props.id}
 | 
			
		||||
        onChange={props.onChange}
 | 
			
		||||
        checked={props.checked}
 | 
			
		||||
        aria-label={props.title}
 | 
			
		||||
@@ -13,14 +13,16 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
import CollabButton from "./CollabButton";
 | 
			
		||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { LockButton } from "./LockButton";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import { LibraryButton } from "./LibraryButton";
 | 
			
		||||
 | 
			
		||||
type MobileMenuProps = {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  exportButton: React.ReactNode;
 | 
			
		||||
  renderJSONExportDialog: () => React.ReactNode;
 | 
			
		||||
  renderImageExportDialog: () => React.ReactNode;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  libraryMenu: JSX.Element | null;
 | 
			
		||||
@@ -28,9 +30,14 @@ type MobileMenuProps = {
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  viewModeEnabled: boolean;
 | 
			
		||||
  showThemeBtn: boolean;
 | 
			
		||||
  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 | 
			
		||||
  renderTopRightUI?: (
 | 
			
		||||
    isMobile: boolean,
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => JSX.Element | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const MobileMenu = ({
 | 
			
		||||
@@ -38,7 +45,8 @@ export const MobileMenu = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  libraryMenu,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportButton,
 | 
			
		||||
  renderJSONExportDialog,
 | 
			
		||||
  renderImageExportDialog,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
@@ -47,6 +55,8 @@ export const MobileMenu = ({
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
  viewModeEnabled,
 | 
			
		||||
  showThemeBtn,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  renderTopRightUI,
 | 
			
		||||
}: MobileMenuProps) => {
 | 
			
		||||
  const renderToolbar = () => {
 | 
			
		||||
    return (
 | 
			
		||||
@@ -62,15 +72,21 @@ export const MobileMenu = ({
 | 
			
		||||
                      canvas={canvas}
 | 
			
		||||
                      elementType={appState.elementType}
 | 
			
		||||
                      setAppState={setAppState}
 | 
			
		||||
                      isLibraryOpen={appState.isLibraryOpen}
 | 
			
		||||
                      onImageAction={({ pointerType }) => {
 | 
			
		||||
                        onImageAction({
 | 
			
		||||
                          insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                        });
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Stack.Row>
 | 
			
		||||
                </Island>
 | 
			
		||||
                <LockIcon
 | 
			
		||||
                {renderTopRightUI && renderTopRightUI(true, appState)}
 | 
			
		||||
                <LockButton
 | 
			
		||||
                  checked={appState.elementLocked}
 | 
			
		||||
                  onChange={onLockToggle}
 | 
			
		||||
                  title={t("toolBar.lock")}
 | 
			
		||||
                />
 | 
			
		||||
                <LibraryButton appState={appState} setAppState={setAppState} />
 | 
			
		||||
              </Stack.Row>
 | 
			
		||||
              {libraryMenu}
 | 
			
		||||
            </Stack.Col>
 | 
			
		||||
@@ -107,19 +123,17 @@ export const MobileMenu = ({
 | 
			
		||||
    if (viewModeEnabled) {
 | 
			
		||||
      return (
 | 
			
		||||
        <>
 | 
			
		||||
          {actionManager.renderAction("saveScene")}
 | 
			
		||||
          {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
          {exportButton}
 | 
			
		||||
          {renderJSONExportDialog()}
 | 
			
		||||
          {renderImageExportDialog()}
 | 
			
		||||
        </>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {actionManager.renderAction("loadScene")}
 | 
			
		||||
        {actionManager.renderAction("saveScene")}
 | 
			
		||||
        {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
        {exportButton}
 | 
			
		||||
        {actionManager.renderAction("clearCanvas")}
 | 
			
		||||
        {actionManager.renderAction("loadScene")}
 | 
			
		||||
        {renderJSONExportDialog()}
 | 
			
		||||
        {renderImageExportDialog()}
 | 
			
		||||
        {onCollabButtonClick && (
 | 
			
		||||
          <CollabButton
 | 
			
		||||
            isCollaborating={isCollaborating}
 | 
			
		||||
@@ -155,7 +169,7 @@ export const MobileMenu = ({
 | 
			
		||||
              <div className="panelColumn">
 | 
			
		||||
                <Stack.Col gap={4}>
 | 
			
		||||
                  {renderCanvasActions()}
 | 
			
		||||
                  {renderCustomFooter?.(true)}
 | 
			
		||||
                  {renderCustomFooter?.(true, appState)}
 | 
			
		||||
                  {appState.collaborators.size > 0 && (
 | 
			
		||||
                    <fieldset>
 | 
			
		||||
                      <legend>{t("labels.collaborators")}</legend>
 | 
			
		||||
@@ -167,10 +181,9 @@ export const MobileMenu = ({
 | 
			
		||||
                          )
 | 
			
		||||
                          .map(([clientId, client]) => (
 | 
			
		||||
                            <React.Fragment key={clientId}>
 | 
			
		||||
                              {actionManager.renderAction(
 | 
			
		||||
                                "goToCollaborator",
 | 
			
		||||
                                clientId,
 | 
			
		||||
                              )}
 | 
			
		||||
                              {actionManager.renderAction("goToCollaborator", {
 | 
			
		||||
                                id: clientId,
 | 
			
		||||
                              })}
 | 
			
		||||
                            </React.Fragment>
 | 
			
		||||
                          ))}
 | 
			
		||||
                      </UserList>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,7 @@
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    z-index: 1;
 | 
			
		||||
    background-color: transparentize($oc-black, 0.7);
 | 
			
		||||
    backdrop-filter: blur(2px);
 | 
			
		||||
    background-color: transparentize($oc-black, 0.3);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Modal__content {
 | 
			
		||||
@@ -45,14 +44,17 @@
 | 
			
		||||
 | 
			
		||||
    // for modals, reset blurry bg
 | 
			
		||||
    background: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: none;
 | 
			
		||||
 | 
			
		||||
    border: 1px solid var(--dialog-border-color);
 | 
			
		||||
    box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
 | 
			
		||||
    border-radius: 6px;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      max-width: 100%;
 | 
			
		||||
      border: 0;
 | 
			
		||||
      border-radius: 0;
 | 
			
		||||
@@ -82,7 +84,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
    .Modal {
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,12 @@
 | 
			
		||||
import "./Modal.scss";
 | 
			
		||||
 | 
			
		||||
import React, { useState, useLayoutEffect } from "react";
 | 
			
		||||
import React, { useState, useLayoutEffect, useRef } from "react";
 | 
			
		||||
import { createPortal } from "react-dom";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { useExcalidrawContainer, useIsMobile } from "./App";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { THEME } from "../constants";
 | 
			
		||||
 | 
			
		||||
export const Modal = (props: {
 | 
			
		||||
  className?: string;
 | 
			
		||||
@@ -11,8 +14,10 @@ export const Modal = (props: {
 | 
			
		||||
  maxWidth?: number;
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  labelledBy: string;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const modalRoot = useBodyRoot();
 | 
			
		||||
  const { theme = THEME.LIGHT } = props;
 | 
			
		||||
  const modalRoot = useBodyRoot(theme);
 | 
			
		||||
 | 
			
		||||
  if (!modalRoot) {
 | 
			
		||||
    return null;
 | 
			
		||||
@@ -21,6 +26,7 @@ export const Modal = (props: {
 | 
			
		||||
  const handleKeydown = (event: React.KeyboardEvent) => {
 | 
			
		||||
    if (event.key === KEYS.ESCAPE) {
 | 
			
		||||
      event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
      event.stopPropagation();
 | 
			
		||||
      props.onCloseRequest();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
@@ -37,6 +43,7 @@ export const Modal = (props: {
 | 
			
		||||
      <div
 | 
			
		||||
        className="Modal__content"
 | 
			
		||||
        style={{ "--max-width": `${props.maxWidth}px` }}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -45,16 +52,29 @@ export const Modal = (props: {
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const useBodyRoot = () => {
 | 
			
		||||
const useBodyRoot = (theme: AppState["theme"]) => {
 | 
			
		||||
  const [div, setDiv] = useState<HTMLDivElement | null>(null);
 | 
			
		||||
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const isMobileRef = useRef(isMobile);
 | 
			
		||||
  isMobileRef.current = isMobile;
 | 
			
		||||
 | 
			
		||||
  const { container: excalidrawContainer } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    const isDarkTheme = !!document
 | 
			
		||||
      .querySelector(".excalidraw")
 | 
			
		||||
      ?.classList.contains("theme--dark");
 | 
			
		||||
    if (div) {
 | 
			
		||||
      div.classList.toggle("excalidraw--mobile", isMobile);
 | 
			
		||||
    }
 | 
			
		||||
  }, [div, isMobile]);
 | 
			
		||||
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    const isDarkTheme =
 | 
			
		||||
      !!excalidrawContainer?.classList.contains("theme--dark") ||
 | 
			
		||||
      theme === "dark";
 | 
			
		||||
    const div = document.createElement("div");
 | 
			
		||||
 | 
			
		||||
    div.classList.add("excalidraw", "excalidraw-modal-container");
 | 
			
		||||
    div.classList.toggle("excalidraw--mobile", isMobileRef.current);
 | 
			
		||||
 | 
			
		||||
    if (isDarkTheme) {
 | 
			
		||||
      div.classList.add("theme--dark");
 | 
			
		||||
@@ -67,7 +87,7 @@ const useBodyRoot = () => {
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.body.removeChild(div);
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
  }, [excalidrawContainer, theme]);
 | 
			
		||||
 | 
			
		||||
  return div;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .PasteChartDialog {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      .Island {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
@@ -13,7 +13,7 @@
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: space-around;
 | 
			
		||||
      flex-wrap: wrap;
 | 
			
		||||
      @media #{$is-mobile-query} {
 | 
			
		||||
      @include isMobile {
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -34,20 +34,25 @@ const ChartPreviewBtn = (props: {
 | 
			
		||||
      0,
 | 
			
		||||
    );
 | 
			
		||||
    setChartElements(elements);
 | 
			
		||||
 | 
			
		||||
    const svg = exportToSvg(elements, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    let svg: SVGSVGElement;
 | 
			
		||||
    const previewNode = previewRef.current!;
 | 
			
		||||
 | 
			
		||||
    previewNode.appendChild(svg);
 | 
			
		||||
    (async () => {
 | 
			
		||||
      svg = await exportToSvg(
 | 
			
		||||
        elements,
 | 
			
		||||
        {
 | 
			
		||||
          exportBackground: false,
 | 
			
		||||
          viewBackgroundColor: oc.white,
 | 
			
		||||
        },
 | 
			
		||||
        null, // files
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    if (props.selected) {
 | 
			
		||||
      (previewNode.parentNode as HTMLDivElement).focus();
 | 
			
		||||
    }
 | 
			
		||||
      previewNode.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
      if (props.selected) {
 | 
			
		||||
        (previewNode.parentNode as HTMLDivElement).focus();
 | 
			
		||||
      }
 | 
			
		||||
    })();
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      previewNode.removeChild(svg);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								src/components/ProjectName.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/components/ProjectName.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
.ProjectName {
 | 
			
		||||
  margin: auto;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
  .TextInput {
 | 
			
		||||
    height: calc(1rem - 3px);
 | 
			
		||||
    width: 200px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    margin-left: 8px;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
    &--readonly {
 | 
			
		||||
      background: none;
 | 
			
		||||
      border: none;
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background: none;
 | 
			
		||||
      }
 | 
			
		||||
      width: auto;
 | 
			
		||||
      max-width: 200px;
 | 
			
		||||
      padding-left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,10 @@
 | 
			
		||||
import "./TextInput.scss";
 | 
			
		||||
 | 
			
		||||
import React, { Component } from "react";
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { focusNearestParent } from "../utils";
 | 
			
		||||
 | 
			
		||||
import "./ProjectName.scss";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  value: string;
 | 
			
		||||
@@ -9,21 +13,19 @@ type Props = {
 | 
			
		||||
  isNameEditable: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type State = {
 | 
			
		||||
  fileName: string;
 | 
			
		||||
};
 | 
			
		||||
export class ProjectName extends Component<Props, State> {
 | 
			
		||||
  state = {
 | 
			
		||||
    fileName: this.props.value,
 | 
			
		||||
  };
 | 
			
		||||
  private handleBlur = (event: any) => {
 | 
			
		||||
export const ProjectName = (props: Props) => {
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
  const [fileName, setFileName] = useState<string>(props.value);
 | 
			
		||||
 | 
			
		||||
  const handleBlur = (event: any) => {
 | 
			
		||||
    focusNearestParent(event.target);
 | 
			
		||||
    const value = event.target.value;
 | 
			
		||||
    if (value !== this.props.value) {
 | 
			
		||||
      this.props.onChange(value);
 | 
			
		||||
    if (value !== props.value) {
 | 
			
		||||
      props.onChange(value);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
 | 
			
		||||
  const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
 | 
			
		||||
    if (event.key === "Enter") {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      if (event.nativeEvent.isComposing || event.keyCode === 229) {
 | 
			
		||||
@@ -33,29 +35,25 @@ export class ProjectName extends Component<Props, State> {
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        <label htmlFor="file-name">
 | 
			
		||||
          {`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
 | 
			
		||||
        </label>
 | 
			
		||||
        {this.props.isNameEditable ? (
 | 
			
		||||
          <input
 | 
			
		||||
            className="TextInput"
 | 
			
		||||
            onBlur={this.handleBlur}
 | 
			
		||||
            onKeyDown={this.handleKeyDown}
 | 
			
		||||
            id="file-name"
 | 
			
		||||
            value={this.state.fileName}
 | 
			
		||||
            onChange={(event) =>
 | 
			
		||||
              this.setState({ fileName: event.target.value })
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <span className="TextInput TextInput--readonly" id="file-name">
 | 
			
		||||
            {this.props.value}
 | 
			
		||||
          </span>
 | 
			
		||||
        )}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ProjectName">
 | 
			
		||||
      <label className="ProjectName-label" htmlFor="filename">
 | 
			
		||||
        {`${props.label}${props.isNameEditable ? "" : ":"}`}
 | 
			
		||||
      </label>
 | 
			
		||||
      {props.isNameEditable ? (
 | 
			
		||||
        <input
 | 
			
		||||
          className="TextInput"
 | 
			
		||||
          onBlur={handleBlur}
 | 
			
		||||
          onKeyDown={handleKeyDown}
 | 
			
		||||
          id={`${id}-filename`}
 | 
			
		||||
          value={fileName}
 | 
			
		||||
          onChange={(event) => setFileName(event.target.value)}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <span className="TextInput TextInput--readonly" id={`${id}-filename`}>
 | 
			
		||||
          {props.value}
 | 
			
		||||
        </span>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
 | 
			
		||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
 | 
			
		||||
  heading: string;
 | 
			
		||||
@@ -7,13 +8,14 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
  const header = (
 | 
			
		||||
    <h2 className="visually-hidden" id={`${heading}-title`}>
 | 
			
		||||
    <h2 className="visually-hidden" id={`${id}-${heading}-title`}>
 | 
			
		||||
      {t(`headings.${heading}`)}
 | 
			
		||||
    </h2>
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    <section {...props} aria-labelledby={`${heading}-title`}>
 | 
			
		||||
    <section {...props} aria-labelledby={`${id}-${heading}-title`}>
 | 
			
		||||
      {typeof children === "function" ? (
 | 
			
		||||
        children(header)
 | 
			
		||||
      ) : (
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										48
									
								
								src/components/Spinner.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/components/Spinner.scss
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										28
									
								
								src/components/Spinner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/components/Spinner.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
    top: 64px;
 | 
			
		||||
    right: 12px;
 | 
			
		||||
    font-size: 12px;
 | 
			
		||||
    z-index: 999;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
 | 
			
		||||
    h3 {
 | 
			
		||||
      margin: 0 24px 8px 0;
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user