mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	Compare commits
	
		
			318 Commits
		
	
	
		
			random_use
			...
			test-csb
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					e894d41a22 | ||
| 
						 | 
					14d1d39e8e | ||
| 
						 | 
					69336b4832 | ||
| 
						 | 
					32b677fb8a | ||
| 
						 | 
					570f725516 | ||
| 
						 | 
					a60860867c | ||
| 
						 | 
					7a61196462 | ||
| 
						 | 
					9653d676fe | ||
| 
						 | 
					0cdd0eebf1 | ||
| 
						 | 
					ae8b1d8bf7 | ||
| 
						 | 
					92ffe8dda6 | ||
| 
						 | 
					4d9dbd5a45 | ||
| 
						 | 
					c66cabaefd | ||
| 
						 | 
					e073128469 | ||
| 
						 | 
					835848d711 | ||
| 
						 | 
					2bd1d7ef59 | ||
| 
						 | 
					37c8b9c2ff | ||
| 
						 | 
					cf9f00f55f | ||
| 
						 | 
					7ae9043221 | ||
| 
						 | 
					7c567408c5 | ||
| 
						 | 
					54612621aa | ||
| 
						 | 
					d27b3bbebe | ||
| 
						 | 
					e4ffc9812e | ||
| 
						 | 
					a066317d3c | ||
| 
						 | 
					050bc1ce2b | ||
| 
						 | 
					5007df6522 | ||
| 
						 | 
					d450c36581 | ||
| 
						 | 
					66c92fc65a | ||
| 
						 | 
					5f1cd4591a | ||
| 
						 | 
					9be6243873 | ||
| 
						 | 
					c3f6d6d344 | ||
| 
						 | 
					339636caab | ||
| 
						 | 
					08115ef311 | ||
| 
						 | 
					e68abdbab4 | ||
| 
						 | 
					8aff076782 | ||
| 
						 | 
					96de887cc8 | ||
| 
						 | 
					98ea46664c | ||
| 
						 | 
					00e30ca0e4 | ||
| 
						 | 
					de6371aac4 | ||
| 
						 | 
					f47ddb988f | ||
| 
						 | 
					59cbf5fde5 | ||
| 
						 | 
					4486fbc2c6 | ||
| 
						 | 
					edfbac9d7d | ||
| 
						 | 
					719ae7b72f | ||
| 
						 | 
					631a228ca1 | ||
| 
						 | 
					4b5270ab12 | ||
| 
						 | 
					dcee594b66 | ||
| 
						 | 
					79d323fab1 | ||
| 
						 | 
					e4edda4555 | ||
| 
						 | 
					ca89d47d4c | ||
| 
						 | 
					18c526d877 | ||
| 
						 | 
					cbc6bd1ad8 | ||
| 
						 | 
					83d9282dbf | ||
| 
						 | 
					abff780983 | ||
| 
						 | 
					c009e03c8e | ||
| 
						 | 
					24bf4cb5fb | ||
| 
						 | 
					0850ab0dd0 | ||
| 
						 | 
					a7473169ba | ||
| 
						 | 
					f6325b1e5e | ||
| 
						 | 
					466220a3a8 | ||
| 
						 | 
					d9cc7d1033 | ||
| 
						 | 
					c037e9854c | ||
| 
						 | 
					9373961857 | ||
| 
						 | 
					1fd2fe56ee | ||
| 
						 | 
					dba71e358d | ||
| 
						 | 
					1ef287027b | ||
| 
						 | 
					a51ed9ced6 | ||
| 
						 | 
					4501d6d630 | ||
| 
						 | 
					92a5936c7f | ||
| 
						 | 
					50bd5fbae1 | ||
| 
						 | 
					62bead66d7 | ||
| 
						 | 
					b3073984b3 | ||
| 
						 | 
					3c9ee13979 | ||
| 
						 | 
					228c8136cf | ||
| 
						 | 
					324dd460c8 | ||
| 
						 | 
					d8ea085a94 | ||
| 
						 | 
					adbd486f32 | ||
| 
						 | 
					0a89c4b0c8 | ||
| 
						 | 
					c03845bac3 | ||
| 
						 | 
					d5a6014076 | ||
| 
						 | 
					74861b1398 | ||
| 
						 | 
					ac71ee7278 | ||
| 
						 | 
					9088df8f5a | ||
| 
						 | 
					c5fe0cd446 | ||
| 
						 | 
					9f8783c2dd | ||
| 
						 | 
					b475412199 | ||
| 
						 | 
					5f1616f2c5 | ||
| 
						 | 
					cec92c1d17 | ||
| 
						 | 
					5f476e09d4 | ||
| 
						 | 
					9aa6a27252 | ||
| 
						 | 
					a2e8806f57 | ||
| 
						 | 
					b71e702991 | ||
| 
						 | 
					5c67329be6 | ||
| 
						 | 
					28546fbb55 | ||
| 
						 | 
					b0cccbb9e8 | ||
| 
						 | 
					b621d065de | ||
| 
						 | 
					96580c92a5 | ||
| 
						 | 
					975441549b | ||
| 
						 | 
					4be701416a | ||
| 
						 | 
					1acb1e33f1 | ||
| 
						 | 
					986e1e40d3 | ||
| 
						 | 
					fab4a0e060 | ||
| 
						 | 
					b265ebf88f | ||
| 
						 | 
					351845019e | ||
| 
						 | 
					c0fcce6f27 | ||
| 
						 | 
					b093d2d2b6 | ||
| 
						 | 
					69548c5502 | ||
| 
						 | 
					6ca0afa6e5 | ||
| 
						 | 
					c50f81b829 | ||
| 
						 | 
					b122c8c4eb | ||
| 
						 | 
					9a7216fe94 | ||
| 
						 | 
					8eee749076 | ||
| 
						 | 
					2158ad0656 | ||
| 
						 | 
					74c3fea7f5 | ||
| 
						 | 
					5e456e6d05 | ||
| 
						 | 
					477cce2ed6 | ||
| 
						 | 
					dd8e465304 | ||
| 
						 | 
					11396a21de | ||
| 
						 | 
					38236bc5e0 | ||
| 
						 | 
					63ce5b82d7 | ||
| 
						 | 
					bae0e985b2 | ||
| 
						 | 
					04f852a40a | ||
| 
						 | 
					f463c047c0 | ||
| 
						 | 
					1fd347cade | ||
| 
						 | 
					ef62390841 | ||
| 
						 | 
					bf2bca221e | ||
| 
						 | 
					d0733b1960 | ||
| 
						 | 
					64c2d76cfa | ||
| 
						 | 
					c76784b774 | ||
| 
						 | 
					25e54e5999 | ||
| 
						 | 
					55b7a7d554 | ||
| 
						 | 
					c1c37a6ee7 | ||
| 
						 | 
					25b529f519 | ||
| 
						 | 
					8e6a747873 | ||
| 
						 | 
					089b05db1b | ||
| 
						 | 
					081e097cef | ||
| 
						 | 
					8b5657e1ce | ||
| 
						 | 
					8b2b03347c | ||
| 
						 | 
					c2a8712593 | ||
| 
						 | 
					ff1d7728a0 | ||
| 
						 | 
					98b5c37e45 | ||
| 
						 | 
					7db63bd397 | ||
| 
						 | 
					390da3fd0f | ||
| 
						 | 
					104664cb9e | ||
| 
						 | 
					c822055ec8 | ||
| 
						 | 
					e15d73d94c | ||
| 
						 | 
					80ee097b85 | ||
| 
						 | 
					10048b877b | ||
| 
						 | 
					5dd5862bb9 | ||
| 
						 | 
					79989fedda | ||
| 
						 | 
					cecabc2196 | ||
| 
						 | 
					ed8fb40b63 | ||
| 
						 | 
					6e391728fe | ||
| 
						 | 
					dfbfbc3f11 | ||
| 
						 | 
					9b8ee3cacf | ||
| 
						 | 
					4ea73d5d5b | ||
| 
						 | 
					618f204ddd | ||
| 
						 | 
					720588130c | ||
| 
						 | 
					f354788cd0 | ||
| 
						 | 
					1c7ee09010 | ||
| 
						 | 
					ca15b0a008 | ||
| 
						 | 
					650930c5ce | ||
| 
						 | 
					79c0d59244 | ||
| 
						 | 
					cd50b5f7e9 | ||
| 
						 | 
					c0434957ff | ||
| 
						 | 
					66aeaeb38d | ||
| 
						 | 
					7f545e74ab | ||
| 
						 | 
					a776955579 | ||
| 
						 | 
					afa7932c9b | ||
| 
						 | 
					1ee8d7d082 | ||
| 
						 | 
					06db702b5d | ||
| 
						 | 
					b53d1f6f3e | ||
| 
						 | 
					ca1f3aa094 | ||
| 
						 | 
					8ff159e76e | ||
| 
						 | 
					f9d2d537a2 | ||
| 
						 | 
					dac970c640 | ||
| 
						 | 
					78bb3b3d84 | ||
| 
						 | 
					7d9d7ad297 | ||
| 
						 | 
					de20a5e3ba | ||
| 
						 | 
					289f72e45d | ||
| 
						 | 
					6dd0e6a4c5 | ||
| 
						 | 
					96b31ecbce | ||
| 
						 | 
					a132f154cb | ||
| 
						 | 
					23acd8f6d1 | ||
| 
						 | 
					a60709f5ea | ||
| 
						 | 
					896c476716 | ||
| 
						 | 
					133ba19919 | ||
| 
						 | 
					a2136bfe9d | ||
| 
						 | 
					6fbd64fdaa | ||
| 
						 | 
					cc4b0c2932 | ||
| 
						 | 
					b6ef953dc9 | ||
| 
						 | 
					620b662085 | ||
| 
						 | 
					1c11df011a | ||
| 
						 | 
					59e9651547 | ||
| 
						 | 
					1c48d122e0 | ||
| 
						 | 
					e4d02fb275 | ||
| 
						 | 
					34a382ace9 | ||
| 
						 | 
					e60e48e67d | ||
| 
						 | 
					84d1d9993c | ||
| 
						 | 
					3ff9744b39 | ||
| 
						 | 
					b9abcc825a | ||
| 
						 | 
					9679eaf74c | ||
| 
						 | 
					284747d742 | ||
| 
						 | 
					876f85fd7a | ||
| 
						 | 
					efc2bbed21 | ||
| 
						 | 
					61d193b87b | ||
| 
						 | 
					3989d6a989 | ||
| 
						 | 
					f6559b65ef | ||
| 
						 | 
					bc6b066c07 | ||
| 
						 | 
					6370d517a2 | ||
| 
						 | 
					b8a37c42e4 | ||
| 
						 | 
					76763b80a9 | ||
| 
						 | 
					d2a2c9d6b5 | ||
| 
						 | 
					3a72f347d2 | ||
| 
						 | 
					c1d9456235 | ||
| 
						 | 
					c4f8b98208 | ||
| 
						 | 
					b6eb57d3f1 | ||
| 
						 | 
					473b8ca0ca | ||
| 
						 | 
					45206c4ef1 | ||
| 
						 | 
					56b4a29aaa | ||
| 
						 | 
					bb4dda64b5 | ||
| 
						 | 
					39e53b4ae7 | ||
| 
						 | 
					6143d5195a | ||
| 
						 | 
					f59e608f18 | ||
| 
						 | 
					6b24592e4a | ||
| 
						 | 
					7b442997dc | ||
| 
						 | 
					4bfc5bbcaa | ||
| 
						 | 
					2b29b9a96d | ||
| 
						 | 
					cc201a6d80 | ||
| 
						 | 
					5be58b59e0 | ||
| 
						 | 
					f1eb969565 | ||
| 
						 | 
					8d4f455cd3 | ||
| 
						 | 
					60262cb4cc | ||
| 
						 | 
					7501c24f22 | ||
| 
						 | 
					00d81aa982 | ||
| 
						 | 
					67fe156d06 | ||
| 
						 | 
					ef433233d1 | ||
| 
						 | 
					1c7056bdaa | ||
| 
						 | 
					277ffaacb9 | ||
| 
						 | 
					2a3e242cfd | ||
| 
						 | 
					b1c6051d6b | ||
| 
						 | 
					8df9742463 | ||
| 
						 | 
					9fdc382d71 | ||
| 
						 | 
					f70d11c2d1 | ||
| 
						 | 
					05e54d6785 | ||
| 
						 | 
					795a6e4546 | ||
| 
						 | 
					a01a4ad739 | ||
| 
						 | 
					e09b96ac6f | ||
| 
						 | 
					d48fb17718 | ||
| 
						 | 
					ede3c4af82 | ||
| 
						 | 
					8bcfd97fc5 | ||
| 
						 | 
					5721c6dfb5 | ||
| 
						 | 
					9b1f77c3be | ||
| 
						 | 
					3369035f40 | ||
| 
						 | 
					dbc7a8599b | ||
| 
						 | 
					09f649daf7 | ||
| 
						 | 
					d357664850 | ||
| 
						 | 
					f973fdfa89 | ||
| 
						 | 
					c15bc50f17 | ||
| 
						 | 
					c2d0107cc5 | ||
| 
						 | 
					c43fac31a1 | ||
| 
						 | 
					9dfaf1752b | ||
| 
						 | 
					d9a1eb2f01 | ||
| 
						 | 
					f1e17a320f | ||
| 
						 | 
					75ecd818b3 | ||
| 
						 | 
					a7abc71f6a | ||
| 
						 | 
					6d0f0c8f21 | ||
| 
						 | 
					790e6da500 | ||
| 
						 | 
					8df1a11535 | ||
| 
						 | 
					b61ee56dc8 | ||
| 
						 | 
					c61f95a327 | ||
| 
						 | 
					d6d629f416 | ||
| 
						 | 
					65dec605f2 | ||
| 
						 | 
					cacec0b5c4 | ||
| 
						 | 
					87a302d7e9 | ||
| 
						 | 
					899b36c206 | ||
| 
						 | 
					534cbef982 | ||
| 
						 | 
					b7f118404e | ||
| 
						 | 
					aab5067718 | ||
| 
						 | 
					b679da02ee | ||
| 
						 | 
					ec652820ea | ||
| 
						 | 
					5d941ed107 | ||
| 
						 | 
					adc478ca34 | ||
| 
						 | 
					f1202adb15 | ||
| 
						 | 
					fd439cf38a | ||
| 
						 | 
					83c63be846 | ||
| 
						 | 
					b59d49dd7f | ||
| 
						 | 
					0116b70edf | ||
| 
						 | 
					3f390d4858 | ||
| 
						 | 
					fdde73bff4 | ||
| 
						 | 
					90a416e265 | ||
| 
						 | 
					a828b2e5de | ||
| 
						 | 
					7c51d3c24c | ||
| 
						 | 
					4d2d6f181a | ||
| 
						 | 
					071416f6ef | ||
| 
						 | 
					d675b07089 | ||
| 
						 | 
					3975fd592a | ||
| 
						 | 
					34a9a4dac6 | ||
| 
						 | 
					78e419b790 | ||
| 
						 | 
					8d8769ba4e | ||
| 
						 | 
					d89fb3371b | ||
| 
						 | 
					8410972cff | ||
| 
						 | 
					2c8d041987 | ||
| 
						 | 
					94519c8250 | ||
| 
						 | 
					add8a1b1a7 | ||
| 
						 | 
					516e7656f3 | ||
| 
						 | 
					d7cdee37bf | ||
| 
						 | 
					5c5b8c517f | ||
| 
						 | 
					7dbd0c5e0a | ||
| 
						 | 
					ba35eb8f8c | ||
| 
						 | 
					163ad1f4c4 | ||
| 
						 | 
					0f0244224d | ||
| 
						 | 
					6eecadce60 | ||
| 
						 | 
					bc88cf5002 | ||
| 
						 | 
					571be9c0fe | ||
| 
						 | 
					5d925c7d3f | ||
| 
						 | 
					45c520341f | ||
| 
						 | 
					c6ffc06541 | 
							
								
								
									
										5
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								.env
									
									
									
									
									
								
							@@ -1,5 +0,0 @@
 | 
			
		||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
 | 
			
		||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
 | 
			
		||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
 | 
			
		||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 | 
			
		||||
							
								
								
									
										8
									
								
								.env.development
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.env.development
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
 | 
			
		||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
 | 
			
		||||
 | 
			
		||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
 | 
			
		||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 | 
			
		||||
 | 
			
		||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
 | 
			
		||||
@@ -1 +1,11 @@
 | 
			
		||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
 | 
			
		||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
 | 
			
		||||
 | 
			
		||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
 | 
			
		||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 | 
			
		||||
 | 
			
		||||
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 | 
			
		||||
 | 
			
		||||
# production-only vars
 | 
			
		||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
			
		||||
 
 | 
			
		||||
@@ -5,3 +5,4 @@ package-lock.json
 | 
			
		||||
firebase/
 | 
			
		||||
dist/
 | 
			
		||||
public/workbox
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": ["@excalidraw/eslint-config", "react-app"],
 | 
			
		||||
  "rules": {
 | 
			
		||||
    "import/no-anonymous-default-export": "off"
 | 
			
		||||
    "import/no-anonymous-default-export": "off",
 | 
			
		||||
    "no-restricted-globals": "off"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@@ -10,6 +10,7 @@ updates:
 | 
			
		||||
      - lipis
 | 
			
		||||
    assignees:
 | 
			
		||||
      - lipis
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/excalidraw/
 | 
			
		||||
@@ -21,6 +22,7 @@ updates:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/utils/
 | 
			
		||||
@@ -32,3 +34,4 @@ updates:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							@@ -23,4 +23,5 @@ jobs:
 | 
			
		||||
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 | 
			
		||||
      - name: Auto release
 | 
			
		||||
        run: |
 | 
			
		||||
          yarn add @actions/core
 | 
			
		||||
          yarn autorelease
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										55
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
name: Auto release preview @excalidraw/excalidraw-preview
 | 
			
		||||
on:
 | 
			
		||||
  issue_comment:
 | 
			
		||||
    types: [created, edited]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  Auto-release-excalidraw-preview:
 | 
			
		||||
    name: Auto release preview
 | 
			
		||||
    if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: React to release comment
 | 
			
		||||
        uses: peter-evans/create-or-update-comment@v1
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 | 
			
		||||
          comment-id: ${{ github.event.comment.id }}
 | 
			
		||||
          reactions: "+1"
 | 
			
		||||
      - name: Get PR SHA
 | 
			
		||||
        id: sha
 | 
			
		||||
        uses: actions/github-script@v4
 | 
			
		||||
        with:
 | 
			
		||||
          result-encoding: string
 | 
			
		||||
          script: |
 | 
			
		||||
            const { owner, repo, number } = context.issue;
 | 
			
		||||
            const pr = await github.pulls.get({
 | 
			
		||||
              owner,
 | 
			
		||||
              repo,
 | 
			
		||||
              pull_number: number,
 | 
			
		||||
            });
 | 
			
		||||
            return pr.data.head.sha
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
        with:
 | 
			
		||||
          ref: ${{ steps.sha.outputs.result }}
 | 
			
		||||
          fetch-depth: 2
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v2
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
      - name: Set up publish access
 | 
			
		||||
        run: |
 | 
			
		||||
          npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
 | 
			
		||||
        env:
 | 
			
		||||
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 | 
			
		||||
      - name: Auto release preview
 | 
			
		||||
        id: "autorelease"
 | 
			
		||||
        run: |
 | 
			
		||||
          yarn add @actions/core
 | 
			
		||||
          yarn autorelease preview ${{ github.event.issue.number }}
 | 
			
		||||
      - name: Post comment post release
 | 
			
		||||
        if: always()
 | 
			
		||||
        uses: peter-evans/create-or-update-comment@v1
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 | 
			
		||||
          issue-number: ${{ github.event.issue.number }}
 | 
			
		||||
          body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -11,7 +11,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: docker/build-push-action@v1
 | 
			
		||||
      - uses: docker/build-push-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -23,3 +23,7 @@ static
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
src/packages/excalidraw/example/public/bundle.js
 | 
			
		||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
 | 
			
		||||
src/packages/excalidraw/example/public/excalidraw.development.js
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.husky/pre-commit
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										2
									
								
								.husky/pre-commit
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
#!/bin/sh
 | 
			
		||||
yarn lint-staged
 | 
			
		||||
@@ -118,6 +118,10 @@ yarn start
 | 
			
		||||
 | 
			
		||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
 | 
			
		||||
 | 
			
		||||
#### Collaboration
 | 
			
		||||
 | 
			
		||||
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
 | 
			
		||||
 | 
			
		||||
#### Commands
 | 
			
		||||
 | 
			
		||||
| Command            | Description                       |
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										59
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										59
									
								
								package.json
									
									
									
									
									
								
							@@ -19,25 +19,28 @@
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@dwelle/browser-fs-access": "0.21.1",
 | 
			
		||||
    "@excalidraw/random-username": "1.0.0",
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.10",
 | 
			
		||||
    "@testing-library/react": "11.2.6",
 | 
			
		||||
    "@tldraw/vec": "0.0.106",
 | 
			
		||||
    "@types/jest": "26.0.22",
 | 
			
		||||
    "@types/react": "17.0.3",
 | 
			
		||||
    "@types/react-dom": "17.0.3",
 | 
			
		||||
    "@testing-library/jest-dom": "5.16.2",
 | 
			
		||||
    "@testing-library/react": "12.1.2",
 | 
			
		||||
    "@tldraw/vec": "1.4.3",
 | 
			
		||||
    "@types/jest": "27.4.0",
 | 
			
		||||
    "@types/pica": "5.1.3",
 | 
			
		||||
    "@types/react": "17.0.38",
 | 
			
		||||
    "@types/react-dom": "17.0.11",
 | 
			
		||||
    "@types/socket.io-client": "1.4.36",
 | 
			
		||||
    "browser-fs-access": "0.23.0",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "fake-indexeddb": "3.1.7",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.0",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.2",
 | 
			
		||||
    "idb-keyval": "6.0.3",
 | 
			
		||||
    "image-blob-reduce": "3.0.1",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "3.1.22",
 | 
			
		||||
    "open-color": "1.8.0",
 | 
			
		||||
    "nanoid": "3.1.32",
 | 
			
		||||
    "open-color": "1.9.1",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "perfect-freehand": "1.0.15",
 | 
			
		||||
    "perfect-freehand": "1.0.16",
 | 
			
		||||
    "png-chunk-text": "1.0.0",
 | 
			
		||||
    "png-chunks-encode": "1.0.0",
 | 
			
		||||
    "png-chunks-extract": "1.0.0",
 | 
			
		||||
@@ -46,39 +49,40 @@
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react-scripts": "4.0.3",
 | 
			
		||||
    "roughjs": "4.4.1",
 | 
			
		||||
    "sass": "1.32.10",
 | 
			
		||||
    "roughjs": "4.5.2",
 | 
			
		||||
    "sass": "1.49.7",
 | 
			
		||||
    "socket.io-client": "2.3.1",
 | 
			
		||||
    "typescript": "4.2.4"
 | 
			
		||||
    "typescript": "4.5.5"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
			
		||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
			
		||||
    "@types/chai": "4.3.0",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/pako": "1.0.1",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.5",
 | 
			
		||||
    "@types/pako": "1.0.3",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.6",
 | 
			
		||||
    "chai": "4.3.6",
 | 
			
		||||
    "dotenv": "10.0.0",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "firebase-tools": "9.9.0",
 | 
			
		||||
    "husky": "4.3.8",
 | 
			
		||||
    "firebase-tools": "9.23.0",
 | 
			
		||||
    "husky": "7.0.4",
 | 
			
		||||
    "jest-canvas-mock": "2.3.1",
 | 
			
		||||
    "lint-staged": "10.5.4",
 | 
			
		||||
    "lint-staged": "12.3.3",
 | 
			
		||||
    "pepjs": "0.5.3",
 | 
			
		||||
    "prettier": "2.2.1",
 | 
			
		||||
    "prettier": "2.5.1",
 | 
			
		||||
    "rewire": "5.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "resolutions": {
 | 
			
		||||
    "@typescript-eslint/typescript-estree": "5.10.2"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=14.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "homepage": ".",
 | 
			
		||||
  "husky": {
 | 
			
		||||
    "hooks": {
 | 
			
		||||
      "pre-commit": "lint-staged"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "jest": {
 | 
			
		||||
    "transformIgnorePatterns": [
 | 
			
		||||
      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
 | 
			
		||||
      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
 | 
			
		||||
    ],
 | 
			
		||||
    "resetMocks": false
 | 
			
		||||
  },
 | 
			
		||||
@@ -97,6 +101,7 @@
 | 
			
		||||
    "fix": "yarn fix:other && yarn fix:code",
 | 
			
		||||
    "locales-coverage": "node scripts/build-locales-coverage.js",
 | 
			
		||||
    "locales-coverage:description": "node scripts/locales-coverage-description.js",
 | 
			
		||||
    "prepare": "husky install",
 | 
			
		||||
    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
 | 
			
		||||
    "start": "react-scripts start",
 | 
			
		||||
    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
 | 
			
		||||
 
 | 
			
		||||
@@ -13,18 +13,6 @@
 | 
			
		||||
 | 
			
		||||
    <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"
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,6 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "capture_links": "new-client",
 | 
			
		||||
  "share_target": {
 | 
			
		||||
    "action": "/web-share-target",
 | 
			
		||||
    "method": "POST",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const { exec, execSync } = require("child_process");
 | 
			
		||||
const core = require("@actions/core");
 | 
			
		||||
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
@@ -15,37 +16,62 @@ const publish = () => {
 | 
			
		||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
    console.info("Published 🎉");
 | 
			
		||||
    core.setOutput(
 | 
			
		||||
      "result",
 | 
			
		||||
      `**Preview version has been shipped** :rocket:
 | 
			
		||||
    You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
 | 
			
		||||
    );
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    core.setOutput("result", "package couldn't be published :warning:!");
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 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);
 | 
			
		||||
    core.setOutput("result", ":warning: Package couldn't be published!");
 | 
			
		||||
    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 && !filesToIgnoreRegex.test(file);
 | 
			
		||||
    return (
 | 
			
		||||
      (file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
 | 
			
		||||
      !filesToIgnoreRegex.test(file)
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!excalidrawPackageFiles.length) {
 | 
			
		||||
    console.info("Skipping release as no valid diff found");
 | 
			
		||||
    core.setOutput("result", "Skipping release as no valid diff found");
 | 
			
		||||
    process.exit(0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // update package.json
 | 
			
		||||
  pkg.version = `${pkg.version}-${getShortCommitHash()}`;
 | 
			
		||||
  pkg.name = "@excalidraw/excalidraw-next";
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
			
		||||
  let version = `${pkg.version}-${getShortCommitHash()}`;
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
  const isPreview = process.argv.slice(2)[0] === "preview";
 | 
			
		||||
  if (isPreview) {
 | 
			
		||||
    // use pullNumber-commithash as the version for preview
 | 
			
		||||
    const pullRequestNumber = process.argv.slice(3)[0];
 | 
			
		||||
    version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
 | 
			
		||||
    // replace "excalidraw-next" with "excalidraw-preview"
 | 
			
		||||
    pkg.name = "@excalidraw/excalidraw-preview";
 | 
			
		||||
    data = data.replace(/excalidraw-next/g, "excalidraw-preview");
 | 
			
		||||
    data = data.trim();
 | 
			
		||||
  }
 | 
			
		||||
  pkg.version = version;
 | 
			
		||||
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
			
		||||
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
  console.info("Publish in progress...");
 | 
			
		||||
  publish();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,16 @@
 | 
			
		||||
const { readdirSync, writeFileSync } = require("fs");
 | 
			
		||||
const files = readdirSync(`${__dirname}/../src/locales`);
 | 
			
		||||
 | 
			
		||||
const flatten = (object) =>
 | 
			
		||||
  Object.keys(object).reduce(
 | 
			
		||||
    (initial, current) => ({ ...initial, ...object[current] }),
 | 
			
		||||
    {},
 | 
			
		||||
  );
 | 
			
		||||
const flatten = (object = {}, result = {}, extraKey = "") => {
 | 
			
		||||
  for (const key in object) {
 | 
			
		||||
    if (typeof object[key] !== "object") {
 | 
			
		||||
      result[extraKey + key] = object[key];
 | 
			
		||||
    } else {
 | 
			
		||||
      flatten(object[key], result, `${extraKey}${key}.`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const locales = files.filter(
 | 
			
		||||
  (file) => file !== "README.md" && file !== "percentages.json",
 | 
			
		||||
@@ -19,10 +24,8 @@ for (let index = 0; index < locales.length; index++) {
 | 
			
		||||
 | 
			
		||||
  const allKeys = Object.keys(data);
 | 
			
		||||
  const translatedKeys = allKeys.filter((item) => data[item] !== "");
 | 
			
		||||
 | 
			
		||||
  const percentage = (100 * translatedKeys.length) / allKeys.length;
 | 
			
		||||
 | 
			
		||||
  percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
 | 
			
		||||
  const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
 | 
			
		||||
  percentages[currentLocale.replace(".json", "")] = percentage;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
writeFileSync(
 | 
			
		||||
 
 | 
			
		||||
@@ -5,10 +5,13 @@ const THRESSHOLD = 85;
 | 
			
		||||
const crowdinMap = {
 | 
			
		||||
  "ar-SA": "en-ar",
 | 
			
		||||
  "bg-BG": "en-bg",
 | 
			
		||||
  "bn-BD": "en-bn",
 | 
			
		||||
  "ca-ES": "en-ca",
 | 
			
		||||
  "da-DK": "en-da",
 | 
			
		||||
  "de-DE": "en-de",
 | 
			
		||||
  "el-GR": "en-el",
 | 
			
		||||
  "es-ES": "en-es",
 | 
			
		||||
  "eu-ES": "en-eu",
 | 
			
		||||
  "fa-IR": "en-fa",
 | 
			
		||||
  "fi-FI": "en-fi",
 | 
			
		||||
  "fr-FR": "en-fr",
 | 
			
		||||
@@ -31,12 +34,16 @@ const crowdinMap = {
 | 
			
		||||
  "pt-PT": "en-pt",
 | 
			
		||||
  "ro-RO": "en-ro",
 | 
			
		||||
  "ru-RU": "en-ru",
 | 
			
		||||
  "si-LK": "en-silk",
 | 
			
		||||
  "sk-SK": "en-sk",
 | 
			
		||||
  "sv-SE": "en-sv",
 | 
			
		||||
  "ta-IN": "en-ta",
 | 
			
		||||
  "tr-TR": "en-tr",
 | 
			
		||||
  "uk-UA": "en-uk",
 | 
			
		||||
  "zh-CN": "en-zhcn",
 | 
			
		||||
  "zh-HK": "en-zhhk",
 | 
			
		||||
  "zh-TW": "en-zhtw",
 | 
			
		||||
  "lt-LT": "en-lt",
 | 
			
		||||
  "lv-LV": "en-lv",
 | 
			
		||||
  "cs-CZ": "en-cs",
 | 
			
		||||
  "kk-KZ": "en-kk",
 | 
			
		||||
@@ -45,7 +52,10 @@ const crowdinMap = {
 | 
			
		||||
const flags = {
 | 
			
		||||
  "ar-SA": "🇸🇦",
 | 
			
		||||
  "bg-BG": "🇧🇬",
 | 
			
		||||
  "bn-BD": "🇧🇩",
 | 
			
		||||
  "ca-ES": "🏳",
 | 
			
		||||
  "cs-CZ": "🇨🇿",
 | 
			
		||||
  "da-DK": "🇩🇰",
 | 
			
		||||
  "de-DE": "🇩🇪",
 | 
			
		||||
  "el-GR": "🇬🇷",
 | 
			
		||||
  "es-ES": "🇪🇸",
 | 
			
		||||
@@ -59,7 +69,10 @@ const flags = {
 | 
			
		||||
  "it-IT": "🇮🇹",
 | 
			
		||||
  "ja-JP": "🇯🇵",
 | 
			
		||||
  "kab-KAB": "🏳",
 | 
			
		||||
  "kk-KZ": "🇰🇿",
 | 
			
		||||
  "ko-KR": "🇰🇷",
 | 
			
		||||
  "lt-LT": "🇱🇹",
 | 
			
		||||
  "lv-LV": "🇱🇻",
 | 
			
		||||
  "my-MM": "🇲🇲",
 | 
			
		||||
  "nb-NO": "🇳🇴",
 | 
			
		||||
  "nl-NL": "🇳🇱",
 | 
			
		||||
@@ -71,24 +84,28 @@ const flags = {
 | 
			
		||||
  "pt-PT": "🇵🇹",
 | 
			
		||||
  "ro-RO": "🇷🇴",
 | 
			
		||||
  "ru-RU": "🇷🇺",
 | 
			
		||||
  "si-LK": "🇱🇰",
 | 
			
		||||
  "sk-SK": "🇸🇰",
 | 
			
		||||
  "sv-SE": "🇸🇪",
 | 
			
		||||
  "ta-IN": "🇮🇳",
 | 
			
		||||
  "tr-TR": "🇹🇷",
 | 
			
		||||
  "uk-UA": "🇺🇦",
 | 
			
		||||
  "zh-CN": "🇨🇳",
 | 
			
		||||
  "zh-HK": "🇭🇰",
 | 
			
		||||
  "zh-TW": "🇹🇼",
 | 
			
		||||
  "lv-LV": "🇱🇻",
 | 
			
		||||
  "cs-CZ": "🇨🇿",
 | 
			
		||||
  "kk-KZ": "🇰🇿",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const languages = {
 | 
			
		||||
  "ar-SA": "العربية",
 | 
			
		||||
  "bg-BG": "Български",
 | 
			
		||||
  "bn-BD": "Bengali",
 | 
			
		||||
  "ca-ES": "Català",
 | 
			
		||||
  "cs-CZ": "Česky",
 | 
			
		||||
  "da-DK": "Dansk",
 | 
			
		||||
  "de-DE": "Deutsch",
 | 
			
		||||
  "el-GR": "Ελληνικά",
 | 
			
		||||
  "es-ES": "Español",
 | 
			
		||||
  "eu-ES": "Euskara",
 | 
			
		||||
  "fa-IR": "فارسی",
 | 
			
		||||
  "fi-FI": "Suomi",
 | 
			
		||||
  "fr-FR": "Français",
 | 
			
		||||
@@ -99,7 +116,10 @@ const languages = {
 | 
			
		||||
  "it-IT": "Italiano",
 | 
			
		||||
  "ja-JP": "日本語",
 | 
			
		||||
  "kab-KAB": "Taqbaylit",
 | 
			
		||||
  "kk-KZ": "Қазақ тілі",
 | 
			
		||||
  "ko-KR": "한국어",
 | 
			
		||||
  "lt-LT": "Lietuvių",
 | 
			
		||||
  "lv-LV": "Latviešu",
 | 
			
		||||
  "my-MM": "Burmese",
 | 
			
		||||
  "nb-NO": "Norsk bokmål",
 | 
			
		||||
  "nl-NL": "Nederlands",
 | 
			
		||||
@@ -111,15 +131,15 @@ const languages = {
 | 
			
		||||
  "pt-PT": "Português",
 | 
			
		||||
  "ro-RO": "Română",
 | 
			
		||||
  "ru-RU": "Русский",
 | 
			
		||||
  "si-LK": "සිංහල",
 | 
			
		||||
  "sk-SK": "Slovenčina",
 | 
			
		||||
  "sv-SE": "Svenska",
 | 
			
		||||
  "ta-IN": "Tamil",
 | 
			
		||||
  "tr-TR": "Türkçe",
 | 
			
		||||
  "uk-UA": "Українська",
 | 
			
		||||
  "zh-CN": "简体中文",
 | 
			
		||||
  "zh-HK": "繁體中文 (香港)",
 | 
			
		||||
  "zh-TW": "繁體中文",
 | 
			
		||||
  "lv-LV": "Latviešu",
 | 
			
		||||
  "cs-CZ": "Česky",
 | 
			
		||||
  "kk-KZ": "Қазақ тілі",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const percentages = fs.readFileSync(
 | 
			
		||||
 
 | 
			
		||||
@@ -25,8 +25,8 @@ const release = async (nextVersion) => {
 | 
			
		||||
    );
 | 
			
		||||
    /* eslint-disable no-console */
 | 
			
		||||
    console.log("Done!");
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => {
 | 
			
		||||
      `git log --format=format:"%H" --grep=${commitMessage}`,
 | 
			
		||||
    );
 | 
			
		||||
    return stdout;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,8 @@ import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
@@ -9,15 +11,49 @@ export const actionAddToLibrary = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    if (selectedElements.some((element) => element.type === "image")) {
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          errorMessage: "Support for adding images to the library coming soon!",
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    app.library.loadLibrary().then((items) => {
 | 
			
		||||
      app.library.saveLibrary([
 | 
			
		||||
        ...items,
 | 
			
		||||
        selectedElements.map(deepCopyElement),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
    return false;
 | 
			
		||||
    return app.library
 | 
			
		||||
      .loadLibrary()
 | 
			
		||||
      .then((items) => {
 | 
			
		||||
        return app.library.saveLibrary([
 | 
			
		||||
          {
 | 
			
		||||
            id: randomId(),
 | 
			
		||||
            status: "unpublished",
 | 
			
		||||
            elements: selectedElements.map(deepCopyElement),
 | 
			
		||||
            created: Date.now(),
 | 
			
		||||
          },
 | 
			
		||||
          ...items,
 | 
			
		||||
        ]);
 | 
			
		||||
      })
 | 
			
		||||
      .then(() => {
 | 
			
		||||
        return {
 | 
			
		||||
          commitToHistory: false,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            toastMessage: t("toast.addedToLibrary"),
 | 
			
		||||
          },
 | 
			
		||||
        };
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        return {
 | 
			
		||||
          commitToHistory: false,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            errorMessage: error.message,
 | 
			
		||||
          },
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.addToLibrary",
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -8,13 +8,13 @@ import {
 | 
			
		||||
  CenterVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const enableActionGroup = (
 | 
			
		||||
@@ -34,9 +34,11 @@ const alignSelectedElements = (
 | 
			
		||||
 | 
			
		||||
  const updatedElements = alignElements(selectedElements, alignment);
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
  return elements.map(
 | 
			
		||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const actionAlignTop = register({
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,22 @@
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { trash, zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
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 "../components/App";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { getStateForZoom } 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",
 | 
			
		||||
@@ -47,54 +47,48 @@ 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,
 | 
			
		||||
        penMode: appState.penMode,
 | 
			
		||||
        penDetected: appState.penDetected,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
        elementType:
 | 
			
		||||
          appState.elementType === "image" ? "selection" : appState.elementType,
 | 
			
		||||
      },
 | 
			
		||||
      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({
 | 
			
		||||
  name: "zoomIn",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    const zoom = getNewZoom(
 | 
			
		||||
      getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
 | 
			
		||||
      appState.zoom,
 | 
			
		||||
      { left: appState.offsetLeft, top: appState.offsetTop },
 | 
			
		||||
      { x: appState.width / 2, y: appState.height / 2 },
 | 
			
		||||
    );
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        zoom,
 | 
			
		||||
        ...getStateForZoom(
 | 
			
		||||
          {
 | 
			
		||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
			
		||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
			
		||||
            nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
 | 
			
		||||
          },
 | 
			
		||||
          appState,
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
@@ -118,18 +112,18 @@ export const actionZoomIn = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomOut = register({
 | 
			
		||||
  name: "zoomOut",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    const zoom = getNewZoom(
 | 
			
		||||
      getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
 | 
			
		||||
      appState.zoom,
 | 
			
		||||
      { left: appState.offsetLeft, top: appState.offsetTop },
 | 
			
		||||
      { x: appState.width / 2, y: appState.height / 2 },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        zoom,
 | 
			
		||||
        ...getStateForZoom(
 | 
			
		||||
          {
 | 
			
		||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
			
		||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
			
		||||
            nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
 | 
			
		||||
          },
 | 
			
		||||
          appState,
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
@@ -153,25 +147,24 @@ export const actionZoomOut = register({
 | 
			
		||||
 | 
			
		||||
export const actionResetZoom = register({
 | 
			
		||||
  name: "resetZoom",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        zoom: getNewZoom(
 | 
			
		||||
          1 as NormalizedZoomValue,
 | 
			
		||||
          appState.zoom,
 | 
			
		||||
          { left: appState.offsetLeft, top: appState.offsetTop },
 | 
			
		||||
        ...getStateForZoom(
 | 
			
		||||
          {
 | 
			
		||||
            x: appState.width / 2,
 | 
			
		||||
            y: appState.height / 2,
 | 
			
		||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
			
		||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
			
		||||
            nextZoom: getNormalizedZoom(1),
 | 
			
		||||
          },
 | 
			
		||||
          appState,
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <Tooltip label={t("buttons.resetZoom")}>
 | 
			
		||||
    <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        type="button"
 | 
			
		||||
        className="reset-zoom-button"
 | 
			
		||||
@@ -223,14 +216,12 @@ const zoomToFitElements = (
 | 
			
		||||
      ? getCommonBounds(selectedElements)
 | 
			
		||||
      : getCommonBounds(nonDeletedElements);
 | 
			
		||||
 | 
			
		||||
  const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
 | 
			
		||||
    width: appState.width,
 | 
			
		||||
    height: appState.height,
 | 
			
		||||
  });
 | 
			
		||||
  const newZoom = getNewZoom(zoomValue, appState.zoom, {
 | 
			
		||||
    left: appState.offsetLeft,
 | 
			
		||||
    top: appState.offsetTop,
 | 
			
		||||
  });
 | 
			
		||||
  const newZoom = {
 | 
			
		||||
    value: zoomValueToFitBoundsOnViewport(commonBounds, {
 | 
			
		||||
      width: appState.width,
 | 
			
		||||
      height: appState.height,
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [x1, y1, x2, y2] = commonBounds;
 | 
			
		||||
  const centerX = (x1 + x2) / 2;
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
@@ -25,7 +25,7 @@ export const actionCut = register({
 | 
			
		||||
  name: "cut",
 | 
			
		||||
  perform: (elements, appState, data, app) => {
 | 
			
		||||
    actionCopy.perform(elements, appState, data, app);
 | 
			
		||||
    return actionDeleteSelected.perform(elements, appState, data, app);
 | 
			
		||||
    return actionDeleteSelected.perform(elements, appState);
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.cut",
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
 | 
			
		||||
@@ -42,6 +42,7 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
@@ -50,12 +51,13 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.files,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
@@ -80,6 +82,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
@@ -88,6 +91,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.files,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
@@ -104,7 +108,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getElementsInGroup } from "../groups";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
const deleteSelectedElements = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -21,6 +22,12 @@ const deleteSelectedElements = (
 | 
			
		||||
      if (appState.selectedElementIds[el.id]) {
 | 
			
		||||
        return newElementWith(el, { isDeleted: true });
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        isBoundToContainer(el) &&
 | 
			
		||||
        appState.selectedElementIds[el.containerId]
 | 
			
		||||
      ) {
 | 
			
		||||
        return newElementWith(el, { isDeleted: true });
 | 
			
		||||
      }
 | 
			
		||||
      return el;
 | 
			
		||||
    }),
 | 
			
		||||
    appState: {
 | 
			
		||||
@@ -55,7 +62,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
        elementId,
 | 
			
		||||
        activePointIndex,
 | 
			
		||||
        selectedPointsIndices,
 | 
			
		||||
        startBindingElement,
 | 
			
		||||
        endBindingElement,
 | 
			
		||||
      } = appState.editingLinearElement;
 | 
			
		||||
@@ -65,8 +72,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        // case: no point selected → delete whole element
 | 
			
		||||
        activePointIndex == null ||
 | 
			
		||||
        activePointIndex === -1 ||
 | 
			
		||||
        selectedPointsIndices == null ||
 | 
			
		||||
        // case: deleting last remaining point
 | 
			
		||||
        element.points.length < 2
 | 
			
		||||
      ) {
 | 
			
		||||
@@ -86,15 +92,17 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      // We cannot do this inside `movePoint` because it is also called
 | 
			
		||||
      // when deleting the uncommitted point (which hasn't caused any binding)
 | 
			
		||||
      const binding = {
 | 
			
		||||
        startBindingElement:
 | 
			
		||||
          activePointIndex === 0 ? null : startBindingElement,
 | 
			
		||||
        endBindingElement:
 | 
			
		||||
          activePointIndex === element.points.length - 1
 | 
			
		||||
            ? null
 | 
			
		||||
            : endBindingElement,
 | 
			
		||||
        startBindingElement: selectedPointsIndices?.includes(0)
 | 
			
		||||
          ? null
 | 
			
		||||
          : startBindingElement,
 | 
			
		||||
        endBindingElement: selectedPointsIndices?.includes(
 | 
			
		||||
          element.points.length - 1,
 | 
			
		||||
        )
 | 
			
		||||
          ? null
 | 
			
		||||
          : endBindingElement,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      LinearElementEditor.movePoint(element, activePointIndex, "delete");
 | 
			
		||||
      LinearElementEditor.deletePoints(element, selectedPointsIndices);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
@@ -103,17 +111,17 @@ export const actionDeleteSelected = register({
 | 
			
		||||
          editingLinearElement: {
 | 
			
		||||
            ...appState.editingLinearElement,
 | 
			
		||||
            ...binding,
 | 
			
		||||
            activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
 | 
			
		||||
            selectedPointsIndices:
 | 
			
		||||
              selectedPointsIndices?.[0] > 0
 | 
			
		||||
                ? [selectedPointsIndices[0] - 1]
 | 
			
		||||
                : [0],
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let {
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      appState: nextAppState,
 | 
			
		||||
    } = deleteSelectedElements(elements, appState);
 | 
			
		||||
    let { elements: nextElements, appState: nextAppState } =
 | 
			
		||||
      deleteSelectedElements(elements, appState);
 | 
			
		||||
    fixBindingsAfterDeletion(
 | 
			
		||||
      nextElements,
 | 
			
		||||
      elements.filter(({ id }) => appState.selectedElementIds[id]),
 | 
			
		||||
 
 | 
			
		||||
@@ -4,13 +4,13 @@ import {
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { distributeElements, Distribution } from "../disitrubte";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const enableActionGroup = (
 | 
			
		||||
@@ -30,9 +30,11 @@ const distributeSelectedElements = (
 | 
			
		||||
 | 
			
		||||
  const updatedElements = distributeElements(selectedElements, distribution);
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
  return elements.map(
 | 
			
		||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const distributeHorizontally = register({
 | 
			
		||||
 
 | 
			
		||||
@@ -2,13 +2,12 @@ import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { duplicateElement, getNonDeletedElements } from "../element";
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { clone } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  selectGroupsForSelectedElements,
 | 
			
		||||
  getSelectedGroupForElement,
 | 
			
		||||
@@ -18,41 +17,23 @@ import { AppState } from "../types";
 | 
			
		||||
import { fixBindingsAfterDuplication } from "../element/binding";
 | 
			
		||||
import { ActionResult } from "./types";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { bindTextToShapeAfterDuplication } from "../element/textElement";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionDuplicateSelection = register({
 | 
			
		||||
  name: "duplicateSelection",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    // duplicate point if selected while editing multi-point element
 | 
			
		||||
    // duplicate selected point(s) if editing a line
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const { activePointIndex, elementId } = appState.editingLinearElement;
 | 
			
		||||
      const element = LinearElementEditor.getElement(elementId);
 | 
			
		||||
      if (!element || activePointIndex === null) {
 | 
			
		||||
      const ret = LinearElementEditor.duplicateSelectedPoints(appState);
 | 
			
		||||
 | 
			
		||||
      if (!ret) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      const { points } = element;
 | 
			
		||||
      const selectedPoint = points[activePointIndex];
 | 
			
		||||
      const nextPoint = points[activePointIndex + 1];
 | 
			
		||||
      mutateElement(element, {
 | 
			
		||||
        points: [
 | 
			
		||||
          ...points.slice(0, activePointIndex + 1),
 | 
			
		||||
          nextPoint
 | 
			
		||||
            ? [
 | 
			
		||||
                (selectedPoint[0] + nextPoint[0]) / 2,
 | 
			
		||||
                (selectedPoint[1] + nextPoint[1]) / 2,
 | 
			
		||||
              ]
 | 
			
		||||
            : [selectedPoint[0] + 30, selectedPoint[1] + 30],
 | 
			
		||||
          ...points.slice(activePointIndex + 1),
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          editingLinearElement: {
 | 
			
		||||
            ...appState.editingLinearElement,
 | 
			
		||||
            activePointIndex: activePointIndex + 1,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        elements,
 | 
			
		||||
        appState: ret.appState,
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
@@ -106,9 +87,12 @@ const duplicateElements = (
 | 
			
		||||
  const finalElements: ExcalidrawElement[] = [];
 | 
			
		||||
 | 
			
		||||
  let index = 0;
 | 
			
		||||
  const selectedElementIds = arrayToMap(
 | 
			
		||||
    getSelectedElements(elements, appState, true),
 | 
			
		||||
  );
 | 
			
		||||
  while (index < elements.length) {
 | 
			
		||||
    const element = elements[index];
 | 
			
		||||
    if (appState.selectedElementIds[element.id]) {
 | 
			
		||||
    if (selectedElementIds.get(element.id)) {
 | 
			
		||||
      if (element.groupIds.length) {
 | 
			
		||||
        const groupId = getSelectedGroupForElement(appState, element);
 | 
			
		||||
        // if group selected, duplicate it atomically
 | 
			
		||||
@@ -130,7 +114,11 @@ const duplicateElements = (
 | 
			
		||||
    }
 | 
			
		||||
    index++;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bindTextToShapeAfterDuplication(
 | 
			
		||||
    finalElements,
 | 
			
		||||
    oldElements,
 | 
			
		||||
    oldIdToDuplicatedId,
 | 
			
		||||
  );
 | 
			
		||||
  fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
@@ -140,7 +128,9 @@ const duplicateElements = (
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        selectedElementIds: newElements.reduce((acc, element) => {
 | 
			
		||||
          acc[element.id] = true;
 | 
			
		||||
          if (!isBoundToContainer(element)) {
 | 
			
		||||
            acc[element.id] = true;
 | 
			
		||||
          }
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {} as any),
 | 
			
		||||
      },
 | 
			
		||||
 
 | 
			
		||||
@@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionSaveToActiveFile = register({
 | 
			
		||||
  name: "saveToActiveFile",
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    const fileHandleExists = !!appState.fileHandle;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = isImageFileHandle(appState.fileHandle)
 | 
			
		||||
        ? await resaveAsImageWithScene(elements, appState)
 | 
			
		||||
        : await saveAsJSON(elements, appState);
 | 
			
		||||
        ? await resaveAsImageWithScene(elements, appState, app.files)
 | 
			
		||||
        : await saveAsJSON(elements, appState, app.files);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
@@ -151,9 +151,11 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
            : null,
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn(error);
 | 
			
		||||
      }
 | 
			
		||||
      return { commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
@@ -170,16 +172,22 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
 | 
			
		||||
export const actionSaveFileToDisk = register({
 | 
			
		||||
  name: "saveFileToDisk",
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
  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) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn(error);
 | 
			
		||||
      }
 | 
			
		||||
      return { commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
@@ -202,24 +210,28 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  perform: async (elements, appState) => {
 | 
			
		||||
  perform: async (elements, appState, _, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
        files,
 | 
			
		||||
      } = await loadFromJSON(appState, elements);
 | 
			
		||||
      return {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
        files,
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      if (error?.name === "AbortError") {
 | 
			
		||||
        console.warn(error);
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
        appState: { ...appState, errorMessage: error.message },
 | 
			
		||||
        files: app.files,
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -19,11 +19,8 @@ export const actionFinalize = register({
 | 
			
		||||
  name: "finalize",
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
        elementId,
 | 
			
		||||
        startBindingElement,
 | 
			
		||||
        endBindingElement,
 | 
			
		||||
      } = appState.editingLinearElement;
 | 
			
		||||
      const { elementId, startBindingElement, endBindingElement } =
 | 
			
		||||
        appState.editingLinearElement;
 | 
			
		||||
      const element = LinearElementEditor.getElement(elementId);
 | 
			
		||||
 | 
			
		||||
      if (element) {
 | 
			
		||||
@@ -49,6 +46,11 @@ export const actionFinalize = register({
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let newElements = elements;
 | 
			
		||||
 | 
			
		||||
    if (appState.pendingImageElement) {
 | 
			
		||||
      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (window.document.activeElement instanceof HTMLElement) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
    }
 | 
			
		||||
@@ -152,6 +154,7 @@ export const actionFinalize = register({
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
              }
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
        pendingImageElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
			
		||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
 | 
			
		||||
@@ -9,6 +9,7 @@ import { getTransformHandles } from "../element/transformHandles";
 | 
			
		||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { updateBoundElements } from "../element/binding";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const enableActionFlipHorizontal = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -83,9 +84,11 @@ const flipSelectedElements = (
 | 
			
		||||
    flipDirection,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
  return elements.map(
 | 
			
		||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flipElements = (
 | 
			
		||||
@@ -93,13 +96,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;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -142,10 +145,9 @@ const flipElement = (
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isLinearElement(element)) {
 | 
			
		||||
    for (let i = 1; i < element.points.length; i++) {
 | 
			
		||||
      LinearElementEditor.movePoint(element, i, [
 | 
			
		||||
        -element.points[i][0],
 | 
			
		||||
        element.points[i][1],
 | 
			
		||||
    for (let index = 1; index < element.points.length; index++) {
 | 
			
		||||
      LinearElementEditor.movePoints(element, [
 | 
			
		||||
        { index, point: [-element.points[index][0], element.points[index][1]] },
 | 
			
		||||
      ]);
 | 
			
		||||
    }
 | 
			
		||||
    LinearElementEditor.normalizePoints(element);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { UngroupIcon, GroupIcon } from "../components/icons";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
@@ -17,8 +17,9 @@ import {
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 | 
			
		||||
  if (elements.length >= 2) {
 | 
			
		||||
@@ -44,6 +45,7 @@ const enableActionGroup = (
 | 
			
		||||
  const selectedElements = getSelectedElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
    appState,
 | 
			
		||||
    true,
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
 | 
			
		||||
@@ -56,6 +58,7 @@ export const actionGroup = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    if (selectedElements.length < 2) {
 | 
			
		||||
      // nothing to group
 | 
			
		||||
@@ -83,8 +86,9 @@ export const actionGroup = register({
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const newGroupId = randomId();
 | 
			
		||||
    const selectElementIds = arrayToMap(selectedElements);
 | 
			
		||||
    const updatedElements = elements.map((element) => {
 | 
			
		||||
      if (!appState.selectedElementIds[element.id]) {
 | 
			
		||||
      if (!selectElementIds.get(element.id)) {
 | 
			
		||||
        return element;
 | 
			
		||||
      }
 | 
			
		||||
      return newElementWith(element, {
 | 
			
		||||
@@ -99,9 +103,8 @@ export const actionGroup = register({
 | 
			
		||||
    // to the z order of the highest element in the layer stack
 | 
			
		||||
    const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
 | 
			
		||||
    const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
 | 
			
		||||
    const lastGroupElementIndex = updatedElements.lastIndexOf(
 | 
			
		||||
      lastElementInGroup,
 | 
			
		||||
    );
 | 
			
		||||
    const lastGroupElementIndex =
 | 
			
		||||
      updatedElements.lastIndexOf(lastElementInGroup);
 | 
			
		||||
    const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
 | 
			
		||||
    const elementsBeforeGroup = updatedElements
 | 
			
		||||
      .slice(0, lastGroupElementIndex)
 | 
			
		||||
@@ -149,7 +152,12 @@ export const actionUngroup = register({
 | 
			
		||||
    if (groupIds.length === 0) {
 | 
			
		||||
      return { appState, elements, commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
 | 
			
		||||
    const nextElements = elements.map((element) => {
 | 
			
		||||
      if (isBoundToContainer(element)) {
 | 
			
		||||
        boundTextElementIds.push(element.id);
 | 
			
		||||
      }
 | 
			
		||||
      const nextGroupIds = removeFromSelectedGroups(
 | 
			
		||||
        element.groupIds,
 | 
			
		||||
        appState.selectedGroupIds,
 | 
			
		||||
@@ -161,11 +169,19 @@ export const actionUngroup = register({
 | 
			
		||||
        groupIds: nextGroupIds,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const updateAppState = selectGroupsForSelectedElements(
 | 
			
		||||
      { ...appState, selectedGroupIds: {} },
 | 
			
		||||
      getNonDeletedElements(nextElements),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // remove binded text elements from selection
 | 
			
		||||
    boundTextElementIds.forEach(
 | 
			
		||||
      (id) => (updateAppState.selectedElementIds[id] = false),
 | 
			
		||||
    );
 | 
			
		||||
    return {
 | 
			
		||||
      appState: selectGroupsForSelectedElements(
 | 
			
		||||
        { ...appState, selectedGroupIds: {} },
 | 
			
		||||
        getNonDeletedElements(nextElements),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: updateAppState,
 | 
			
		||||
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,9 @@ import History, { HistoryEntry } from "../history";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isWindows, KEYS } from "../keys";
 | 
			
		||||
import { getElementMap } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const writeData = (
 | 
			
		||||
  prevElements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -27,17 +27,17 @@ const writeData = (
 | 
			
		||||
      return { commitToHistory };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevElementMap = getElementMap(prevElements);
 | 
			
		||||
    const prevElementMap = arrayToMap(prevElements);
 | 
			
		||||
    const nextElements = data.elements;
 | 
			
		||||
    const nextElementMap = getElementMap(nextElements);
 | 
			
		||||
    const nextElementMap = arrayToMap(nextElements);
 | 
			
		||||
 | 
			
		||||
    const deletedElements = prevElements.filter(
 | 
			
		||||
      (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
 | 
			
		||||
      (prevElement) => !nextElementMap.has(prevElement.id),
 | 
			
		||||
    );
 | 
			
		||||
    const elements = nextElements
 | 
			
		||||
      .map((nextElement) =>
 | 
			
		||||
        newElementWith(
 | 
			
		||||
          prevElementMap[nextElement.id] || nextElement,
 | 
			
		||||
          prevElementMap.get(nextElement.id) || nextElement,
 | 
			
		||||
          nextElement,
 | 
			
		||||
        ),
 | 
			
		||||
      )
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import {
 | 
			
		||||
  ArrowheadArrowIcon,
 | 
			
		||||
  ArrowheadBarIcon,
 | 
			
		||||
  ArrowheadDotIcon,
 | 
			
		||||
  ArrowheadTriangleIcon,
 | 
			
		||||
  ArrowheadNoneIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
@@ -40,8 +41,16 @@ import {
 | 
			
		||||
  isTextElement,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  getContainerElement,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  isBoundToContainer,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isLinearElementType,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  Arrowhead,
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
@@ -51,24 +60,34 @@ import {
 | 
			
		||||
  TextAlign,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getLanguage, t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { randomInteger } from "../random";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
  getCommonAttributeOfSelectedElements,
 | 
			
		||||
  getSelectedElements,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 | 
			
		||||
 | 
			
		||||
const changeProperty = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  callback: (element: ExcalidrawElement) => ExcalidrawElement,
 | 
			
		||||
  includeBoundText = false,
 | 
			
		||||
) => {
 | 
			
		||||
  const selectedElementIds = arrayToMap(
 | 
			
		||||
    getSelectedElements(elements, appState, includeBoundText),
 | 
			
		||||
  );
 | 
			
		||||
  return elements.map((element) => {
 | 
			
		||||
    if (
 | 
			
		||||
      appState.selectedElementIds[element.id] ||
 | 
			
		||||
      selectedElementIds.get(element.id) ||
 | 
			
		||||
      element.id === appState.editingElement?.id
 | 
			
		||||
    ) {
 | 
			
		||||
      return callback(element);
 | 
			
		||||
@@ -98,15 +117,96 @@ const getFormValue = function <T>(
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const offsetElementAfterFontResize = (
 | 
			
		||||
  prevElement: ExcalidrawTextElement,
 | 
			
		||||
  nextElement: ExcalidrawTextElement,
 | 
			
		||||
) => {
 | 
			
		||||
  if (isBoundToContainer(nextElement)) {
 | 
			
		||||
    return nextElement;
 | 
			
		||||
  }
 | 
			
		||||
  return mutateElement(
 | 
			
		||||
    nextElement,
 | 
			
		||||
    {
 | 
			
		||||
      x:
 | 
			
		||||
        prevElement.textAlign === "left"
 | 
			
		||||
          ? prevElement.x
 | 
			
		||||
          : prevElement.x +
 | 
			
		||||
            (prevElement.width - nextElement.width) /
 | 
			
		||||
              (prevElement.textAlign === "center" ? 2 : 1),
 | 
			
		||||
      // centering vertically is non-standard, but for Excalidraw I think
 | 
			
		||||
      // it makes sense
 | 
			
		||||
      y: prevElement.y + (prevElement.height - nextElement.height) / 2,
 | 
			
		||||
    },
 | 
			
		||||
    false,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const changeFontSize = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  getNewFontSize: (element: ExcalidrawTextElement) => number,
 | 
			
		||||
  fallbackValue?: ExcalidrawTextElement["fontSize"],
 | 
			
		||||
) => {
 | 
			
		||||
  const newFontSizes = new Set<number>();
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    elements: changeProperty(
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      (oldElement) => {
 | 
			
		||||
        if (isTextElement(oldElement)) {
 | 
			
		||||
          const newFontSize = getNewFontSize(oldElement);
 | 
			
		||||
          newFontSizes.add(newFontSize);
 | 
			
		||||
 | 
			
		||||
          let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
 | 
			
		||||
            fontSize: newFontSize,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(
 | 
			
		||||
            newElement,
 | 
			
		||||
            getContainerElement(oldElement),
 | 
			
		||||
            appState,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          newElement = offsetElementAfterFontResize(oldElement, newElement);
 | 
			
		||||
 | 
			
		||||
          return newElement;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return oldElement;
 | 
			
		||||
      },
 | 
			
		||||
      true,
 | 
			
		||||
    ),
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
      // update state only if we've set all select text elements to
 | 
			
		||||
      // the same font size
 | 
			
		||||
      currentItemFontSize:
 | 
			
		||||
        newFontSizes.size === 1
 | 
			
		||||
          ? [...newFontSizes][0]
 | 
			
		||||
          : fallbackValue ?? appState.currentItemFontSize,
 | 
			
		||||
    },
 | 
			
		||||
    commitToHistory: true,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
        elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
          newElementWith(el, {
 | 
			
		||||
            strokeColor: value.currentItemStrokeColor,
 | 
			
		||||
          }),
 | 
			
		||||
        elements: changeProperty(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (el) => {
 | 
			
		||||
            return hasStrokeColor(el.type)
 | 
			
		||||
              ? newElementWith(el, {
 | 
			
		||||
                  strokeColor: value.currentItemStrokeColor,
 | 
			
		||||
                })
 | 
			
		||||
              : el;
 | 
			
		||||
          },
 | 
			
		||||
          true,
 | 
			
		||||
        ),
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -421,24 +521,7 @@ export const actionChangeOpacity = register({
 | 
			
		||||
export const actionChangeFontSize = register({
 | 
			
		||||
  name: "changeFontSize",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isTextElement(el)) {
 | 
			
		||||
          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
			
		||||
            fontSize: value,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(element);
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return el;
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemFontSize: value,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
    return changeFontSize(elements, appState, () => value, value);
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <fieldset>
 | 
			
		||||
@@ -450,27 +533,40 @@ export const actionChangeFontSize = register({
 | 
			
		||||
            value: 16,
 | 
			
		||||
            text: t("labels.small"),
 | 
			
		||||
            icon: <FontSizeSmallIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-small",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 20,
 | 
			
		||||
            text: t("labels.medium"),
 | 
			
		||||
            icon: <FontSizeMediumIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-medium",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 28,
 | 
			
		||||
            text: t("labels.large"),
 | 
			
		||||
            icon: <FontSizeLargeIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-large",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 36,
 | 
			
		||||
            text: t("labels.veryLarge"),
 | 
			
		||||
            icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-veryLarge",
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => isTextElement(element) && element.fontSize,
 | 
			
		||||
          (element) => {
 | 
			
		||||
            if (isTextElement(element)) {
 | 
			
		||||
              return element.fontSize;
 | 
			
		||||
            }
 | 
			
		||||
            const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
            if (boundTextElement) {
 | 
			
		||||
              return boundTextElement.fontSize;
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
          },
 | 
			
		||||
          appState.currentItemFontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(value) => updateData(value)}
 | 
			
		||||
@@ -479,21 +575,71 @@ export const actionChangeFontSize = register({
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionDecreaseFontSize = register({
 | 
			
		||||
  name: "decreaseFontSize",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(
 | 
			
		||||
        // get previous value before relative increase (doesn't work fully
 | 
			
		||||
        // due to rounding and float precision issues)
 | 
			
		||||
        (1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => {
 | 
			
		||||
    return (
 | 
			
		||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      // KEYS.COMMA needed for MacOS
 | 
			
		||||
      (event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionIncreaseFontSize = register({
 | 
			
		||||
  name: "increaseFontSize",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => {
 | 
			
		||||
    return (
 | 
			
		||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      // KEYS.PERIOD needed for MacOS
 | 
			
		||||
      (event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontFamily = register({
 | 
			
		||||
  name: "changeFontFamily",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isTextElement(el)) {
 | 
			
		||||
          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
			
		||||
            fontFamily: value,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(element);
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (oldElement) => {
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              {
 | 
			
		||||
                fontFamily: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        return el;
 | 
			
		||||
      }),
 | 
			
		||||
          return oldElement;
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemFontFamily: value,
 | 
			
		||||
@@ -533,7 +679,16 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) => isTextElement(element) && element.fontFamily,
 | 
			
		||||
            (element) => {
 | 
			
		||||
              if (isTextElement(element)) {
 | 
			
		||||
                return element.fontFamily;
 | 
			
		||||
              }
 | 
			
		||||
              const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
              if (boundTextElement) {
 | 
			
		||||
                return boundTextElement.fontFamily;
 | 
			
		||||
              }
 | 
			
		||||
              return null;
 | 
			
		||||
            },
 | 
			
		||||
            appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
          )}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
@@ -547,17 +702,29 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
  name: "changeTextAlign",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isTextElement(el)) {
 | 
			
		||||
          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
			
		||||
            textAlign: value,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(element);
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (oldElement) => {
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              {
 | 
			
		||||
                textAlign: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        return el;
 | 
			
		||||
      }),
 | 
			
		||||
          return oldElement;
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemTextAlign: value,
 | 
			
		||||
@@ -590,7 +757,16 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => isTextElement(element) && element.textAlign,
 | 
			
		||||
          (element) => {
 | 
			
		||||
            if (isTextElement(element)) {
 | 
			
		||||
              return element.textAlign;
 | 
			
		||||
            }
 | 
			
		||||
            const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
            if (boundTextElement) {
 | 
			
		||||
              return boundTextElement.textAlign;
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
          },
 | 
			
		||||
          appState.currentItemTextAlign,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(value) => updateData(value)}
 | 
			
		||||
@@ -735,6 +911,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,
 | 
			
		||||
@@ -777,6 +961,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,7 +1,7 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { selectGroupsForSelectedElements } from "../groups";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
 | 
			
		||||
export const actionSelectAll = register({
 | 
			
		||||
  name: "selectAll",
 | 
			
		||||
@@ -15,7 +15,10 @@ export const actionSelectAll = register({
 | 
			
		||||
          ...appState,
 | 
			
		||||
          editingGroupId: null,
 | 
			
		||||
          selectedElementIds: elements.reduce((map, element) => {
 | 
			
		||||
            if (!element.isDeleted) {
 | 
			
		||||
            if (
 | 
			
		||||
              !element.isDeleted &&
 | 
			
		||||
              !(isTextElement(element) && element.containerId)
 | 
			
		||||
            ) {
 | 
			
		||||
              map[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
            return map;
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { getContainerElement } from "../element/textElement";
 | 
			
		||||
 | 
			
		||||
// `copiedStyles` is exported only for tests.
 | 
			
		||||
export let copiedStyles: string = "{}";
 | 
			
		||||
@@ -55,13 +56,18 @@ export const actionPasteStyles = register({
 | 
			
		||||
            opacity: pastedElement?.opacity,
 | 
			
		||||
            roughness: pastedElement?.roughness,
 | 
			
		||||
          });
 | 
			
		||||
          if (isTextElement(newElement)) {
 | 
			
		||||
          if (isTextElement(newElement) && isTextElement(element)) {
 | 
			
		||||
            mutateElement(newElement, {
 | 
			
		||||
              fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
              fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
			
		||||
            });
 | 
			
		||||
            redrawTextBoundingBox(newElement);
 | 
			
		||||
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              element,
 | 
			
		||||
              getContainerElement(element),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
          return newElement;
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { getBoundTextElement, measureText } from "../element/textElement";
 | 
			
		||||
import { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -80,3 +80,5 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
			
		||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
			
		||||
 | 
			
		||||
export { actionToggleStats } from "./actionToggleStats";
 | 
			
		||||
export { actionUnbindText } from "./actionUnbindText";
 | 
			
		||||
export { actionLink } from "../element/Hyperlink";
 | 
			
		||||
 
 | 
			
		||||
@@ -8,18 +8,8 @@ import {
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppProps, AppState } from "../types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { MODES } from "../constants";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
 | 
			
		||||
// This is the <App> component, but for now we don't care about anything but its
 | 
			
		||||
// `canvas` state.
 | 
			
		||||
type App = {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  props: AppProps;
 | 
			
		||||
  library: Library;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
@@ -28,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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,9 @@ import { Action } from "./types";
 | 
			
		||||
 | 
			
		||||
export let actions: readonly Action[] = [];
 | 
			
		||||
 | 
			
		||||
export const register = (action: Action): Action => {
 | 
			
		||||
export const register = <T extends Action>(action: T) => {
 | 
			
		||||
  actions = actions.concat(action);
 | 
			
		||||
  return action;
 | 
			
		||||
  return action as T & {
 | 
			
		||||
    keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,8 @@ export type ShortcutName =
 | 
			
		||||
  | "addToLibrary"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical";
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "link";
 | 
			
		||||
 | 
			
		||||
const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  cut: [getShortcutKey("CtrlOrCmd+X")],
 | 
			
		||||
@@ -62,6 +63,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  flipHorizontal: [getShortcutKey("Shift+H")],
 | 
			
		||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
			
		||||
  viewMode: [getShortcutKey("Alt+R")],
 | 
			
		||||
  link: [getShortcutKey("CtrlOrCmd+K")],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,11 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState, ExcalidrawProps } from "../types";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import {
 | 
			
		||||
  AppClassProperties,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
@@ -12,22 +16,18 @@ export type ActionResult =
 | 
			
		||||
        AppState,
 | 
			
		||||
        "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
      > | null;
 | 
			
		||||
      files?: BinaryFiles | null;
 | 
			
		||||
      commitToHistory: boolean;
 | 
			
		||||
      syncHistory?: boolean;
 | 
			
		||||
      replaceFiles?: boolean;
 | 
			
		||||
    }
 | 
			
		||||
  | false;
 | 
			
		||||
 | 
			
		||||
type AppAPI = {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  focusContainer(): void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ActionFn = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  formData: any,
 | 
			
		||||
  app: AppAPI,
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
) => ActionResult | Promise<ActionResult>;
 | 
			
		||||
 | 
			
		||||
export type UpdaterFn = (res: ActionResult) => void;
 | 
			
		||||
@@ -101,7 +101,11 @@ export type ActionName =
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "exportWithDarkMode"
 | 
			
		||||
  | "toggleTheme";
 | 
			
		||||
  | "toggleTheme"
 | 
			
		||||
  | "increaseFontSize"
 | 
			
		||||
  | "decreaseFontSize"
 | 
			
		||||
  | "unbindText"
 | 
			
		||||
  | "link";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
@@ -121,7 +125,12 @@ export interface Action {
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  contextItemLabel?: string;
 | 
			
		||||
  contextItemLabel?:
 | 
			
		||||
    | string
 | 
			
		||||
    | ((
 | 
			
		||||
        elements: readonly ExcalidrawElement[],
 | 
			
		||||
        appState: Readonly<AppState>,
 | 
			
		||||
      ) => string);
 | 
			
		||||
  contextItemPredicate?: (
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								src/align.ts
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								src/align.ts
									
									
									
									
									
								
							@@ -1,13 +1,7 @@
 | 
			
		||||
import { ExcalidrawElement } from "./element/types";
 | 
			
		||||
import { newElementWith } from "./element/mutateElement";
 | 
			
		||||
import { getCommonBounds } from "./element";
 | 
			
		||||
 | 
			
		||||
interface Box {
 | 
			
		||||
  minX: number;
 | 
			
		||||
  minY: number;
 | 
			
		||||
  maxX: number;
 | 
			
		||||
  maxY: number;
 | 
			
		||||
}
 | 
			
		||||
import { Box, getCommonBoundingBox } from "./element/bounds";
 | 
			
		||||
import { getMaximumGroups } from "./groups";
 | 
			
		||||
 | 
			
		||||
export interface Alignment {
 | 
			
		||||
  position: "start" | "center" | "end";
 | 
			
		||||
@@ -37,28 +31,6 @@ export const alignElements = (
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getMaximumGroups = (
 | 
			
		||||
  elements: ExcalidrawElement[],
 | 
			
		||||
): ExcalidrawElement[][] => {
 | 
			
		||||
  const groups: Map<String, ExcalidrawElement[]> = new Map<
 | 
			
		||||
    String,
 | 
			
		||||
    ExcalidrawElement[]
 | 
			
		||||
  >();
 | 
			
		||||
 | 
			
		||||
  elements.forEach((element: ExcalidrawElement) => {
 | 
			
		||||
    const groupId =
 | 
			
		||||
      element.groupIds.length === 0
 | 
			
		||||
        ? element.id
 | 
			
		||||
        : element.groupIds[element.groupIds.length - 1];
 | 
			
		||||
 | 
			
		||||
    const currentGroupMembers = groups.get(groupId) || [];
 | 
			
		||||
 | 
			
		||||
    groups.set(groupId, [...currentGroupMembers, element]);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return Array.from(groups.values());
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const calculateTranslation = (
 | 
			
		||||
  group: ExcalidrawElement[],
 | 
			
		||||
  selectionBoundingBox: Box,
 | 
			
		||||
@@ -88,8 +60,3 @@ const calculateTranslation = (
 | 
			
		||||
      (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
 | 
			
		||||
  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
 | 
			
		||||
  return { minX, minY, maxX, maxY };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										167
									
								
								src/appState.ts
									
									
									
									
									
								
							
							
						
						
									
										167
									
								
								src/appState.ts
									
									
									
									
									
								
							@@ -43,6 +43,8 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    editingLinearElement: null,
 | 
			
		||||
    elementLocked: false,
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    penMode: false,
 | 
			
		||||
    penDetected: false,
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
    exportBackground: true,
 | 
			
		||||
    exportScale: defaultExportScale,
 | 
			
		||||
@@ -77,8 +79,12 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    toastMessage: null,
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
 | 
			
		||||
    zoom: {
 | 
			
		||||
      value: 1 as NormalizedZoomValue,
 | 
			
		||||
    },
 | 
			
		||||
    viewModeEnabled: false,
 | 
			
		||||
    pendingImageElement: null,
 | 
			
		||||
    showHyperlinkPopup: false,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -92,78 +98,89 @@ 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)({
 | 
			
		||||
  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 },
 | 
			
		||||
  exportScale: { 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 },
 | 
			
		||||
  openPopup: { browser: false, 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 },
 | 
			
		||||
  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 },
 | 
			
		||||
  T extends Record<keyof AppState, Values>,
 | 
			
		||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
 | 
			
		||||
  config)({
 | 
			
		||||
  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 },
 | 
			
		||||
  penMode: { browser: false, export: false, server: false },
 | 
			
		||||
  penDetected: { browser: false, 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 },
 | 
			
		||||
  showHyperlinkPopup: { browser: false, export: false, server: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
 | 
			
		||||
const _clearAppStateForStorage = <
 | 
			
		||||
  ExportType extends "export" | "browser" | "server",
 | 
			
		||||
>(
 | 
			
		||||
  appState: Partial<AppState>,
 | 
			
		||||
  exportType: ExportType,
 | 
			
		||||
) => {
 | 
			
		||||
@@ -176,8 +193,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;
 | 
			
		||||
@@ -190,3 +209,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,19 +3,22 @@ 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 { 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;
 | 
			
		||||
}
 | 
			
		||||
@@ -37,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,
 | 
			
		||||
@@ -53,17 +56,26 @@ const clipboardContainsElements = (
 | 
			
		||||
export const copyToClipboard = async (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  files: BinaryFiles,
 | 
			
		||||
) => {
 | 
			
		||||
  // select binded text elements when copying
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
  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;
 | 
			
		||||
  try {
 | 
			
		||||
    PREFER_APP_CLIPBOARD = false;
 | 
			
		||||
    await copyTextToSystemClipboard(json);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    PREFER_APP_CLIPBOARD = true;
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
@@ -76,7 +88,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    return JSON.parse(CLIPBOARD);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    return {};
 | 
			
		||||
  }
 | 
			
		||||
@@ -138,7 +150,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 {
 | 
			
		||||
@@ -153,7 +168,7 @@ export const parseClipboard = async (
 | 
			
		||||
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
 | 
			
		||||
  await navigator.clipboard.write([
 | 
			
		||||
    new window.ClipboardItem({ "image/png": blob }),
 | 
			
		||||
    new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -165,7 +180,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
			
		||||
      // not focused
 | 
			
		||||
      await navigator.clipboard.writeText(text || "");
 | 
			
		||||
      copied = true;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -205,7 +220,7 @@ const copyTextViaExecCommand = (text: string) => {
 | 
			
		||||
    textarea.setSelectionRange(0, textarea.value.length);
 | 
			
		||||
 | 
			
		||||
    success = document.execCommand("copy");
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
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 "../components/App";
 | 
			
		||||
import {
 | 
			
		||||
@@ -18,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,
 | 
			
		||||
@@ -48,9 +49,22 @@ 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")}
 | 
			
		||||
 | 
			
		||||
@@ -144,6 +158,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
            {renderAction("deleteSelectedElements")}
 | 
			
		||||
            {renderAction("group")}
 | 
			
		||||
            {renderAction("ungroup")}
 | 
			
		||||
            {targetElements.length === 1 && renderAction("link")}
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -155,18 +170,20 @@ export const ShapesSwitcher = ({
 | 
			
		||||
  canvas,
 | 
			
		||||
  elementType,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
}: {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  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"
 | 
			
		||||
@@ -180,14 +197,16 @@ 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 });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -7,7 +7,7 @@ export const ButtonIconSelect = <T extends Object>({
 | 
			
		||||
  onChange,
 | 
			
		||||
  group,
 | 
			
		||||
}: {
 | 
			
		||||
  options: { value: T; text: string; icon: JSX.Element }[];
 | 
			
		||||
  options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
 | 
			
		||||
  value: T | null;
 | 
			
		||||
  onChange: (value: T) => void;
 | 
			
		||||
  group: string;
 | 
			
		||||
@@ -24,6 +24,7 @@ export const ButtonIconSelect = <T extends Object>({
 | 
			
		||||
          name={group}
 | 
			
		||||
          onChange={() => onChange(option.value)}
 | 
			
		||||
          checked={value === option.value}
 | 
			
		||||
          data-testid={option.testId}
 | 
			
		||||
        />
 | 
			
		||||
        {option.icon}
 | 
			
		||||
      </label>
 | 
			
		||||
 
 | 
			
		||||
@@ -48,6 +48,10 @@
 | 
			
		||||
      .ToolIcon__label {
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .Spinner {
 | 
			
		||||
        --spinner-color: #fff;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,15 +3,22 @@ import OpenColor from "open-color";
 | 
			
		||||
import "./Card.scss";
 | 
			
		||||
 | 
			
		||||
export const Card: React.FC<{
 | 
			
		||||
  color: keyof OpenColor;
 | 
			
		||||
  color: keyof OpenColor | "primary";
 | 
			
		||||
}> = ({ 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],
 | 
			
		||||
        ["--card-color" as any]:
 | 
			
		||||
          color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
 | 
			
		||||
        ["--card-color-darker" as any]:
 | 
			
		||||
          color === "primary"
 | 
			
		||||
            ? "var(--color-primary-darker)"
 | 
			
		||||
            : OpenColor[color][8],
 | 
			
		||||
        ["--card-color-darkest" as any]:
 | 
			
		||||
          color === "primary"
 | 
			
		||||
            ? "var(--color-primary-darkest)"
 | 
			
		||||
            : OpenColor[color][9],
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,16 +6,19 @@ import "./CheckboxItem.scss";
 | 
			
		||||
 | 
			
		||||
export const CheckboxItem: React.FC<{
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange: (checked: boolean) => void;
 | 
			
		||||
}> = ({ children, checked, onChange }) => {
 | 
			
		||||
  onChange: (checked: boolean, event: React.MouseEvent) => void;
 | 
			
		||||
  className?: string;
 | 
			
		||||
}> = ({ children, checked, onChange, className }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("Checkbox", { "is-checked": checked })}
 | 
			
		||||
      className={clsx("Checkbox", className, { "is-checked": checked })}
 | 
			
		||||
      onClick={(event) => {
 | 
			
		||||
        onChange(!checked);
 | 
			
		||||
        ((event.currentTarget as HTMLDivElement).querySelector(
 | 
			
		||||
          ".Checkbox-box",
 | 
			
		||||
        ) as HTMLButtonElement).focus();
 | 
			
		||||
        onChange(!checked, event);
 | 
			
		||||
        (
 | 
			
		||||
          (event.currentTarget as HTMLDivElement).querySelector(
 | 
			
		||||
            ".Checkbox-box",
 | 
			
		||||
          ) as HTMLButtonElement
 | 
			
		||||
        ).focus();
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										43
									
								
								src/components/ClearCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/components/ClearCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { trash } from "./icons";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
import ConfirmDialog from "./ConfirmDialog";
 | 
			
		||||
 | 
			
		||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
 | 
			
		||||
  const [showDialog, setShowDialog] = useState(false);
 | 
			
		||||
  const toggleDialog = () => {
 | 
			
		||||
    setShowDialog(!showDialog);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        type="button"
 | 
			
		||||
        icon={trash}
 | 
			
		||||
        title={t("buttons.clearReset")}
 | 
			
		||||
        aria-label={t("buttons.clearReset")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        onClick={toggleDialog}
 | 
			
		||||
        data-testid="clear-canvas-button"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      {showDialog && (
 | 
			
		||||
        <ConfirmDialog
 | 
			
		||||
          onConfirm={() => {
 | 
			
		||||
            onConfirm();
 | 
			
		||||
            toggleDialog();
 | 
			
		||||
          }}
 | 
			
		||||
          onCancel={toggleDialog}
 | 
			
		||||
          title={t("clearCanvasDialog.title")}
 | 
			
		||||
        >
 | 
			
		||||
          <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
 | 
			
		||||
        </ConfirmDialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default ClearCanvas;
 | 
			
		||||
							
								
								
									
										37
									
								
								src/components/ConfirmDialog.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/ConfirmDialog.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .confirm-dialog {
 | 
			
		||||
    &-buttons {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      padding: 0.2rem 0;
 | 
			
		||||
      justify-content: flex-end;
 | 
			
		||||
    }
 | 
			
		||||
    .ToolIcon__icon {
 | 
			
		||||
      min-width: 2.5rem;
 | 
			
		||||
      width: auto;
 | 
			
		||||
      font-size: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon_type_button {
 | 
			
		||||
      margin-left: 0.8rem;
 | 
			
		||||
      padding: 0 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__content {
 | 
			
		||||
      font-size: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--confirm.ToolIcon_type_button {
 | 
			
		||||
      background-color: $oc-red-6;
 | 
			
		||||
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: $oc-red-8;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								src/components/ConfirmDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/components/ConfirmDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { Dialog, DialogProps } from "./Dialog";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
import "./ConfirmDialog.scss";
 | 
			
		||||
 | 
			
		||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
 | 
			
		||||
  onConfirm: () => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  confirmText?: string;
 | 
			
		||||
  cancelText?: string;
 | 
			
		||||
}
 | 
			
		||||
const ConfirmDialog = (props: Props) => {
 | 
			
		||||
  const {
 | 
			
		||||
    onConfirm,
 | 
			
		||||
    onCancel,
 | 
			
		||||
    children,
 | 
			
		||||
    confirmText = t("buttons.confirm"),
 | 
			
		||||
    cancelText = t("buttons.cancel"),
 | 
			
		||||
    className = "",
 | 
			
		||||
    ...rest
 | 
			
		||||
  } = props;
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog
 | 
			
		||||
      onCloseRequest={onCancel}
 | 
			
		||||
      small={true}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      className={`confirm-dialog ${className}`}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <div className="confirm-dialog-buttons">
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          type="button"
 | 
			
		||||
          title={cancelText}
 | 
			
		||||
          aria-label={cancelText}
 | 
			
		||||
          label={cancelText}
 | 
			
		||||
          onClick={onCancel}
 | 
			
		||||
          className="confirm-dialog--cancel"
 | 
			
		||||
        />
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          type="button"
 | 
			
		||||
          title={confirmText}
 | 
			
		||||
          aria-label={confirmText}
 | 
			
		||||
          label={confirmText}
 | 
			
		||||
          onClick={onConfirm}
 | 
			
		||||
          className="confirm-dialog--confirm"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export default ConfirmDialog;
 | 
			
		||||
@@ -11,6 +11,7 @@ import {
 | 
			
		||||
import { Action } from "../actions/types";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export type ContextMenuOption = "separator" | Action;
 | 
			
		||||
 | 
			
		||||
@@ -21,6 +22,7 @@ type ContextMenuProps = {
 | 
			
		||||
  left: number;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ContextMenu = ({
 | 
			
		||||
@@ -30,6 +32,7 @@ const ContextMenu = ({
 | 
			
		||||
  left,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
  elements,
 | 
			
		||||
}: ContextMenuProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover
 | 
			
		||||
@@ -37,6 +40,10 @@ const ContextMenu = ({
 | 
			
		||||
      top={top}
 | 
			
		||||
      left={left}
 | 
			
		||||
      fitInViewport={true}
 | 
			
		||||
      offsetLeft={appState.offsetLeft}
 | 
			
		||||
      offsetTop={appState.offsetTop}
 | 
			
		||||
      viewportWidth={appState.width}
 | 
			
		||||
      viewportHeight={appState.height}
 | 
			
		||||
    >
 | 
			
		||||
      <ul
 | 
			
		||||
        className="context-menu"
 | 
			
		||||
@@ -48,9 +55,14 @@ const ContextMenu = ({
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const actionName = option.name;
 | 
			
		||||
          const label = option.contextItemLabel
 | 
			
		||||
            ? t(option.contextItemLabel)
 | 
			
		||||
            : "";
 | 
			
		||||
          let label = "";
 | 
			
		||||
          if (option.contextItemLabel) {
 | 
			
		||||
            if (typeof option.contextItemLabel === "function") {
 | 
			
		||||
              label = t(option.contextItemLabel(elements, appState));
 | 
			
		||||
            } else {
 | 
			
		||||
              label = t(option.contextItemLabel);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          return (
 | 
			
		||||
            <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
			
		||||
              <button
 | 
			
		||||
@@ -97,6 +109,7 @@ type ContextMenuParams = {
 | 
			
		||||
  actionManager: ContextMenuProps["actionManager"];
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
  container: HTMLElement;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleClose = (container: HTMLElement) => {
 | 
			
		||||
@@ -125,6 +138,7 @@ export default {
 | 
			
		||||
          onCloseRequest={() => handleClose(params.container)}
 | 
			
		||||
          actionManager={params.actionManager}
 | 
			
		||||
          appState={params.appState}
 | 
			
		||||
          elements={params.elements}
 | 
			
		||||
        />,
 | 
			
		||||
        getContextMenuNode(params.container),
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ import { Island } from "./Island";
 | 
			
		||||
import { Modal } from "./Modal";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const Dialog = (props: {
 | 
			
		||||
export interface DialogProps {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  small?: boolean;
 | 
			
		||||
@@ -18,7 +18,10 @@ export const Dialog = (props: {
 | 
			
		||||
  title: React.ReactNode;
 | 
			
		||||
  autofocus?: boolean;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
}) => {
 | 
			
		||||
  closeOnClickOutside?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Dialog = (props: DialogProps) => {
 | 
			
		||||
  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
			
		||||
  const [lastActiveElement] = useState(document.activeElement);
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
@@ -81,6 +84,7 @@ export const Dialog = (props: {
 | 
			
		||||
      maxWidth={props.small ? 550 : 800}
 | 
			
		||||
      onCloseRequest={onClose}
 | 
			
		||||
      theme={props.theme}
 | 
			
		||||
      closeOnClickOutside={props.closeOnClickOutside}
 | 
			
		||||
    >
 | 
			
		||||
      <Island ref={setIslandNode}>
 | 
			
		||||
        <h2 id={`${id}-dialog-title`} className="Dialog__title">
 | 
			
		||||
 
 | 
			
		||||
@@ -154,9 +154,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.freedraw")}
 | 
			
		||||
                  shortcuts={["Shift+P", "7"]}
 | 
			
		||||
                  shortcuts={["Shift + P", "X", "7"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.editSelectedShape")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -203,6 +205,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("helpDialog.preventBinding")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.link")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.view")}>
 | 
			
		||||
                <Shortcut
 | 
			
		||||
@@ -258,6 +264,18 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.multiSelect")}
 | 
			
		||||
                  shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.deepSelect")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.deepBoxSelect")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.moveCanvas")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -380,6 +398,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.showBackground")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("G")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.decreaseFontSize")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.increaseFontSize")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
          </Columns>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,17 +4,24 @@ import { getSelectedElements } from "../scene";
 | 
			
		||||
 | 
			
		||||
import "./HintViewer.scss";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  isImageElement,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isTextBindableContainer,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
 | 
			
		||||
interface Hint {
 | 
			
		||||
interface HintViewerProps {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  isMobile: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
  const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
 | 
			
		||||
  const multiMode = appState.multiElement !== null;
 | 
			
		||||
 | 
			
		||||
  if (elementType === "arrow" || elementType === "line") {
 | 
			
		||||
    if (!multiMode) {
 | 
			
		||||
      return t("hints.linearElement");
 | 
			
		||||
@@ -30,7 +37,12 @@ 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 &&
 | 
			
		||||
    lastPointerDownWith === "mouse" &&
 | 
			
		||||
@@ -40,22 +52,15 @@ 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") {
 | 
			
		||||
    return t("hints.rotate");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      return appState.editingLinearElement.activePointIndex
 | 
			
		||||
        ? t("hints.lineEditor_pointSelected")
 | 
			
		||||
        : t("hints.lineEditor_nothingSelected");
 | 
			
		||||
    }
 | 
			
		||||
    return t("hints.lineEditor_info");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
 | 
			
		||||
    return t("hints.text_selected");
 | 
			
		||||
  }
 | 
			
		||||
@@ -64,13 +69,45 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    return t("hints.text_editing");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "selection") {
 | 
			
		||||
    if (
 | 
			
		||||
      appState.draggingElement?.type === "selection" &&
 | 
			
		||||
      !appState.editingElement &&
 | 
			
		||||
      !appState.editingLinearElement
 | 
			
		||||
    ) {
 | 
			
		||||
      return t("hints.deepBoxSelect");
 | 
			
		||||
    }
 | 
			
		||||
    if (!selectedElements.length && !isMobile) {
 | 
			
		||||
      return t("hints.canvasPanning");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1) {
 | 
			
		||||
    if (isLinearElement(selectedElements[0])) {
 | 
			
		||||
      if (appState.editingLinearElement) {
 | 
			
		||||
        return appState.editingLinearElement.selectedPointsIndices
 | 
			
		||||
          ? t("hints.lineEditor_pointSelected")
 | 
			
		||||
          : t("hints.lineEditor_nothingSelected");
 | 
			
		||||
      }
 | 
			
		||||
      return t("hints.lineEditor_info");
 | 
			
		||||
    }
 | 
			
		||||
    if (isTextBindableContainer(selectedElements[0])) {
 | 
			
		||||
      return t("hints.bindTextToElement");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const HintViewer = ({ appState, elements }: Hint) => {
 | 
			
		||||
export const HintViewer = ({
 | 
			
		||||
  appState,
 | 
			
		||||
  elements,
 | 
			
		||||
  isMobile,
 | 
			
		||||
}: HintViewerProps) => {
 | 
			
		||||
  let hint = getHints({
 | 
			
		||||
    appState,
 | 
			
		||||
    elements,
 | 
			
		||||
    isMobile,
 | 
			
		||||
  });
 | 
			
		||||
  if (!hint) {
 | 
			
		||||
    return null;
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
    &:focus-visible {
 | 
			
		||||
      outline: transparent;
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
      & svg {
 | 
			
		||||
@@ -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"] & {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { exportToCanvas } from "../scene/export";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { AppState, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { clipboard, exportImage } from "./icons";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
@@ -79,6 +79,7 @@ const ExportButton: React.FC<{
 | 
			
		||||
const ImageExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
@@ -87,6 +88,7 @@ const ImageExportModal = ({
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
@@ -100,7 +102,7 @@ const ImageExportModal = ({
 | 
			
		||||
  const { exportBackground, viewBackgroundColor } = appState;
 | 
			
		||||
 | 
			
		||||
  const exportedElements = exportSelected
 | 
			
		||||
    ? getSelectedElements(elements, appState)
 | 
			
		||||
    ? getSelectedElements(elements, appState, true)
 | 
			
		||||
    : elements;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@@ -112,29 +114,25 @@ const ImageExportModal = ({
 | 
			
		||||
    if (!previewNode) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      const canvas = exportToCanvas(exportedElements, appState, {
 | 
			
		||||
        exportBackground,
 | 
			
		||||
        viewBackgroundColor,
 | 
			
		||||
        exportPadding,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // if converting to blob fails, there's some problem that will
 | 
			
		||||
      // likely prevent preview and export (e.g. canvas too big)
 | 
			
		||||
      canvasToBlob(canvas)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
    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);
 | 
			
		||||
        });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      renderPreview(new CanvasError(), previewNode);
 | 
			
		||||
    }
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
        renderPreview(new CanvasError(), previewNode);
 | 
			
		||||
      });
 | 
			
		||||
  }, [
 | 
			
		||||
    appState,
 | 
			
		||||
    files,
 | 
			
		||||
    exportedElements,
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    exportPadding,
 | 
			
		||||
@@ -220,6 +218,7 @@ const ImageExportModal = ({
 | 
			
		||||
export const ImageExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
@@ -228,6 +227,7 @@ export const ImageExportDialog = ({
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
@@ -258,6 +258,7 @@ export const ImageExportDialog = ({
 | 
			
		||||
          <ImageExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            files={files}
 | 
			
		||||
            exportPadding={exportPadding}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onExportToPng={onExportToPng}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
    --padding: 0;
 | 
			
		||||
    background-color: var(--island-bg-color);
 | 
			
		||||
    box-shadow: var(--shadow-island);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    border-radius: var(--border-radius-lg);
 | 
			
		||||
    padding: calc(var(--padding) * var(--space-factor));
 | 
			
		||||
    position: relative;
 | 
			
		||||
    transition: box-shadow 0.5s ease-in-out;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { AppState, ExportOpts } from "../types";
 | 
			
		||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { exportFile, exportToFileIcon, link } from "./icons";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
@@ -21,11 +21,13 @@ export type ExportCB = (
 | 
			
		||||
const JSONExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
@@ -68,12 +70,14 @@ const JSONExportModal = ({
 | 
			
		||||
              title={t("exportDialog.link_button")}
 | 
			
		||||
              aria-label={t("exportDialog.link_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() => onExportToBackend(elements, appState, canvas)}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                onExportToBackend(elements, appState, files, canvas)
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
        {exportOpts.renderCustomUI &&
 | 
			
		||||
          exportOpts.renderCustomUI(elements, appState, canvas)}
 | 
			
		||||
          exportOpts.renderCustomUI(elements, appState, files, canvas)}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
@@ -82,12 +86,14 @@ const JSONExportModal = ({
 | 
			
		||||
export const JSONExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
@@ -116,6 +122,7 @@ export const JSONExportDialog = ({
 | 
			
		||||
          <JSONExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            files={files}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
            exportOpts={exportOpts}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,42 +1,6 @@
 | 
			
		||||
@import "open-color/open-color";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .layer-ui__library {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    .layer-ui__library-header {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      margin: 2px 0;
 | 
			
		||||
 | 
			
		||||
      button {
 | 
			
		||||
        // 2px from the left to account for focus border of left-most button
 | 
			
		||||
        margin: 0 2px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      a {
 | 
			
		||||
        margin-inline-start: auto;
 | 
			
		||||
        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
 | 
			
		||||
        padding-inline-end: 18px;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__library-message {
 | 
			
		||||
    padding: 10px 20px;
 | 
			
		||||
    max-width: 200px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__library-items {
 | 
			
		||||
    max-height: 50vh;
 | 
			
		||||
    overflow: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__wrapper {
 | 
			
		||||
    z-index: var(--zIndex-layerUI);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +1,15 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import React, {
 | 
			
		||||
  RefObject,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from "react";
 | 
			
		||||
import React, { useCallback } from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { CLASSES } from "../constants";
 | 
			
		||||
import { exportCanvas } from "../data";
 | 
			
		||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 | 
			
		||||
import { isTextElement, showSelectedShapeActions } from "../element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { Language, t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
 | 
			
		||||
import { ExportType } from "../scene/types";
 | 
			
		||||
import {
 | 
			
		||||
  AppProps,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
@@ -31,10 +18,7 @@ import { ErrorDialog } from "./ErrorDialog";
 | 
			
		||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 | 
			
		||||
import { FixedSideContainer } from "./FixedSideContainer";
 | 
			
		||||
import { HintViewer } from "./HintViewer";
 | 
			
		||||
import { exportFile, load, trash } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import "./LayerUI.scss";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { LockButton } from "./LockButton";
 | 
			
		||||
import { MobileMenu } from "./MobileMenu";
 | 
			
		||||
@@ -42,22 +26,28 @@ import { PasteChartDialog } from "./PasteChartDialog";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
import { HelpDialog } from "./HelpDialog";
 | 
			
		||||
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";
 | 
			
		||||
import { LibraryMenu } from "./LibraryMenu";
 | 
			
		||||
 | 
			
		||||
import "./LayerUI.scss";
 | 
			
		||||
import "./Toolbar.scss";
 | 
			
		||||
import { PenModeButton } from "./PenModeButton";
 | 
			
		||||
 | 
			
		||||
interface LayerUIProps {
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  onCollabButtonClick?: () => void;
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  onPenModeToggle: () => void;
 | 
			
		||||
  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
 | 
			
		||||
  zenModeEnabled: boolean;
 | 
			
		||||
  showExitZenModeBtn: boolean;
 | 
			
		||||
@@ -65,7 +55,10 @@ interface LayerUIProps {
 | 
			
		||||
  toggleZenMode: () => void;
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  renderTopRightUI?: (
 | 
			
		||||
    isMobile: boolean,
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => JSX.Element | null;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  viewModeEnabled: boolean;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
@@ -73,300 +66,19 @@ interface LayerUIProps {
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
  ref: RefObject<HTMLElement>,
 | 
			
		||||
  cb: (event: MouseEvent) => void,
 | 
			
		||||
) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const listener = (event: MouseEvent) => {
 | 
			
		||||
      if (!ref.current) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        event.target instanceof Element &&
 | 
			
		||||
        (ref.current.contains(event.target) ||
 | 
			
		||||
          !document.body.contains(event.target))
 | 
			
		||||
      ) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      cb(event);
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener("pointerdown", listener, false);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener("pointerdown", listener);
 | 
			
		||||
    };
 | 
			
		||||
  }, [ref, cb]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LibraryMenuItems = ({
 | 
			
		||||
  libraryItems,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  setLibraryItems,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
}: {
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: (index: number) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: (elements: LibraryItem) => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  setLibraryItems: (library: LibraryItems) => void;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
 | 
			
		||||
  const CELLS_PER_ROW = isMobile ? 4 : 6;
 | 
			
		||||
  const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
 | 
			
		||||
  const rows = [];
 | 
			
		||||
  let addedPendingElements = false;
 | 
			
		||||
 | 
			
		||||
  const referrer =
 | 
			
		||||
    libraryReturnUrl || window.location.origin + window.location.pathname;
 | 
			
		||||
 | 
			
		||||
  rows.push(
 | 
			
		||||
    <div className="layer-ui__library-header" key="library-header">
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        key="import"
 | 
			
		||||
        type="button"
 | 
			
		||||
        title={t("buttons.load")}
 | 
			
		||||
        aria-label={t("buttons.load")}
 | 
			
		||||
        icon={load}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          importLibraryFromJSON(library)
 | 
			
		||||
            .then(() => {
 | 
			
		||||
              // Close and then open to get the libraries updated
 | 
			
		||||
              setAppState({ isLibraryOpen: false });
 | 
			
		||||
              setAppState({ isLibraryOpen: true });
 | 
			
		||||
            })
 | 
			
		||||
            .catch(muteFSAbortError)
 | 
			
		||||
            .catch((error) => {
 | 
			
		||||
              setAppState({ errorMessage: error.message });
 | 
			
		||||
            });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {!!libraryItems.length && (
 | 
			
		||||
        <>
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="export"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.export")}
 | 
			
		||||
            aria-label={t("buttons.export")}
 | 
			
		||||
            icon={exportFile}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              saveLibraryAsJSON(library)
 | 
			
		||||
                .catch(muteFSAbortError)
 | 
			
		||||
                .catch((error) => {
 | 
			
		||||
                  setAppState({ errorMessage: error.message });
 | 
			
		||||
                });
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="reset"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.resetLibrary")}
 | 
			
		||||
            aria-label={t("buttons.resetLibrary")}
 | 
			
		||||
            icon={trash}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              if (window.confirm(t("alerts.resetLibrary"))) {
 | 
			
		||||
                library.resetLibrary();
 | 
			
		||||
                setLibraryItems([]);
 | 
			
		||||
                focusContainer();
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
      <a
 | 
			
		||||
        href={`https://libraries.excalidraw.com?target=${
 | 
			
		||||
          window.name || "_blank"
 | 
			
		||||
        }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
 | 
			
		||||
        target="_excalidraw_libraries"
 | 
			
		||||
      >
 | 
			
		||||
        {t("labels.libraries")}
 | 
			
		||||
      </a>
 | 
			
		||||
    </div>,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  for (let row = 0; row < numRows; row++) {
 | 
			
		||||
    const y = CELLS_PER_ROW * row;
 | 
			
		||||
    const children = [];
 | 
			
		||||
    for (let x = 0; x < CELLS_PER_ROW; x++) {
 | 
			
		||||
      const shouldAddPendingElements: boolean =
 | 
			
		||||
        pendingElements.length > 0 &&
 | 
			
		||||
        !addedPendingElements &&
 | 
			
		||||
        y + x >= libraryItems.length;
 | 
			
		||||
      addedPendingElements = addedPendingElements || shouldAddPendingElements;
 | 
			
		||||
 | 
			
		||||
      children.push(
 | 
			
		||||
        <Stack.Col key={x}>
 | 
			
		||||
          <LibraryUnit
 | 
			
		||||
            elements={libraryItems[y + x]}
 | 
			
		||||
            pendingElements={
 | 
			
		||||
              shouldAddPendingElements ? pendingElements : undefined
 | 
			
		||||
            }
 | 
			
		||||
            onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
 | 
			
		||||
            onClick={
 | 
			
		||||
              shouldAddPendingElements
 | 
			
		||||
                ? onAddToLibrary.bind(null, pendingElements)
 | 
			
		||||
                : onInsertShape.bind(null, libraryItems[y + x])
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </Stack.Col>,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    rows.push(
 | 
			
		||||
      <Stack.Row align="center" gap={1} key={row}>
 | 
			
		||||
        {children}
 | 
			
		||||
      </Stack.Row>,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack.Col align="start" gap={1} className="layer-ui__library-items">
 | 
			
		||||
      {rows}
 | 
			
		||||
    </Stack.Col>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LibraryMenu = ({
 | 
			
		||||
  onClickOutside,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onClickOutside: (event: MouseEvent) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: () => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useOnClickOutside(ref, (event) => {
 | 
			
		||||
    // If click on the library icon, do nothing.
 | 
			
		||||
    if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    onClickOutside(event);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
 | 
			
		||||
 | 
			
		||||
  const [loadingState, setIsLoading] = useState<
 | 
			
		||||
    "preloading" | "loading" | "ready"
 | 
			
		||||
  >("preloading");
 | 
			
		||||
 | 
			
		||||
  const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    Promise.race([
 | 
			
		||||
      new Promise((resolve) => {
 | 
			
		||||
        loadingTimerRef.current = setTimeout(() => {
 | 
			
		||||
          resolve("loading");
 | 
			
		||||
        }, 100);
 | 
			
		||||
      }),
 | 
			
		||||
      library.loadLibrary().then((items) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setIsLoading("ready");
 | 
			
		||||
      }),
 | 
			
		||||
    ]).then((data) => {
 | 
			
		||||
      if (data === "loading") {
 | 
			
		||||
        setIsLoading("loading");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearTimeout(loadingTimerRef.current!);
 | 
			
		||||
    };
 | 
			
		||||
  }, [library]);
 | 
			
		||||
 | 
			
		||||
  const removeFromLibrary = useCallback(
 | 
			
		||||
    async (indexToRemove) => {
 | 
			
		||||
      const items = await library.loadLibrary();
 | 
			
		||||
      const nextItems = items.filter((_, index) => index !== indexToRemove);
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [library, setAppState],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const addToLibrary = useCallback(
 | 
			
		||||
    async (elements: LibraryItem) => {
 | 
			
		||||
      const items = await library.loadLibrary();
 | 
			
		||||
      const nextItems = [...items, elements];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary, library, setAppState],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return loadingState === "preloading" ? null : (
 | 
			
		||||
    <Island padding={1} ref={ref} className="layer-ui__library">
 | 
			
		||||
      {loadingState === "loading" ? (
 | 
			
		||||
        <div className="layer-ui__library-message">
 | 
			
		||||
          {t("labels.libraryLoadingMessage")}
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LibraryMenuItems
 | 
			
		||||
          libraryItems={libraryItems}
 | 
			
		||||
          onRemoveFromLibrary={removeFromLibrary}
 | 
			
		||||
          onAddToLibrary={addToLibrary}
 | 
			
		||||
          onInsertShape={onInsertShape}
 | 
			
		||||
          pendingElements={pendingElements}
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          setLibraryItems={setLibraryItems}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
          focusContainer={focusContainer}
 | 
			
		||||
          library={library}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
          id={id}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Island>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LayerUI = ({
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  canvas,
 | 
			
		||||
  elements,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
  onPenModeToggle,
 | 
			
		||||
  onInsertElements,
 | 
			
		||||
  zenModeEnabled,
 | 
			
		||||
  showExitZenModeBtn,
 | 
			
		||||
@@ -381,6 +93,7 @@ const LayerUI = ({
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
}: LayerUIProps) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
@@ -393,6 +106,7 @@ const LayerUI = ({
 | 
			
		||||
      <JSONExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        files={files}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        exportOpts={UIOptions.canvasActions.export}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
@@ -405,33 +119,40 @@ const LayerUI = ({
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const createExporter = (type: ExportType): ExportCB => async (
 | 
			
		||||
      exportedElements,
 | 
			
		||||
    ) => {
 | 
			
		||||
      const fileHandle = await exportCanvas(type, exportedElements, appState, {
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        name: appState.name,
 | 
			
		||||
        viewBackgroundColor: appState.viewBackgroundColor,
 | 
			
		||||
      })
 | 
			
		||||
        .catch(muteFSAbortError)
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error(error);
 | 
			
		||||
          setAppState({ errorMessage: error.message });
 | 
			
		||||
        });
 | 
			
		||||
    const createExporter =
 | 
			
		||||
      (type: ExportType): ExportCB =>
 | 
			
		||||
      async (exportedElements) => {
 | 
			
		||||
        const fileHandle = await exportCanvas(
 | 
			
		||||
          type,
 | 
			
		||||
          exportedElements,
 | 
			
		||||
          appState,
 | 
			
		||||
          files,
 | 
			
		||||
          {
 | 
			
		||||
            exportBackground: appState.exportBackground,
 | 
			
		||||
            name: appState.name,
 | 
			
		||||
            viewBackgroundColor: appState.viewBackgroundColor,
 | 
			
		||||
          },
 | 
			
		||||
        )
 | 
			
		||||
          .catch(muteFSAbortError)
 | 
			
		||||
          .catch((error) => {
 | 
			
		||||
            console.error(error);
 | 
			
		||||
            setAppState({ errorMessage: error.message });
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        appState.exportEmbedScene &&
 | 
			
		||||
        fileHandle &&
 | 
			
		||||
        isImageFileHandle(fileHandle)
 | 
			
		||||
      ) {
 | 
			
		||||
        setAppState({ fileHandle });
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
        if (
 | 
			
		||||
          appState.exportEmbedScene &&
 | 
			
		||||
          fileHandle &&
 | 
			
		||||
          isImageFileHandle(fileHandle)
 | 
			
		||||
        ) {
 | 
			
		||||
          setAppState({ fileHandle });
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ImageExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        files={files}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        onExportToPng={createExporter("png")}
 | 
			
		||||
        onExportToSvg={createExporter("svg")}
 | 
			
		||||
@@ -465,6 +186,7 @@ const LayerUI = ({
 | 
			
		||||
      </Section>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderCanvasActions = () => (
 | 
			
		||||
    <Section
 | 
			
		||||
      heading="canvasActions"
 | 
			
		||||
@@ -532,12 +254,15 @@ const LayerUI = ({
 | 
			
		||||
    </Section>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const closeLibrary = useCallback(
 | 
			
		||||
    (event) => {
 | 
			
		||||
      setAppState({ isLibraryOpen: false });
 | 
			
		||||
    },
 | 
			
		||||
    [setAppState],
 | 
			
		||||
  );
 | 
			
		||||
  const closeLibrary = useCallback(() => {
 | 
			
		||||
    const isDialogOpen = !!document.querySelector(".Dialog");
 | 
			
		||||
 | 
			
		||||
    // Prevent closing if any dialog is open
 | 
			
		||||
    if (isDialogOpen) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    setAppState({ isLibraryOpen: false });
 | 
			
		||||
  }, [setAppState]);
 | 
			
		||||
 | 
			
		||||
  const deselectItems = useCallback(() => {
 | 
			
		||||
    setAppState({
 | 
			
		||||
@@ -548,8 +273,8 @@ const LayerUI = ({
 | 
			
		||||
 | 
			
		||||
  const libraryMenu = appState.isLibraryOpen ? (
 | 
			
		||||
    <LibraryMenu
 | 
			
		||||
      pendingElements={getSelectedElements(elements, appState)}
 | 
			
		||||
      onClickOutside={closeLibrary}
 | 
			
		||||
      pendingElements={getSelectedElements(elements, appState, true)}
 | 
			
		||||
      onClose={closeLibrary}
 | 
			
		||||
      onInsertShape={onInsertElements}
 | 
			
		||||
      onAddToLibrary={deselectItems}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
@@ -557,7 +282,9 @@ const LayerUI = ({
 | 
			
		||||
      focusContainer={focusContainer}
 | 
			
		||||
      library={library}
 | 
			
		||||
      theme={appState.theme}
 | 
			
		||||
      files={files}
 | 
			
		||||
      id={id}
 | 
			
		||||
      appState={appState}
 | 
			
		||||
    />
 | 
			
		||||
  ) : null;
 | 
			
		||||
 | 
			
		||||
@@ -583,7 +310,19 @@ const LayerUI = ({
 | 
			
		||||
            <Section heading="shapes">
 | 
			
		||||
              {(heading) => (
 | 
			
		||||
                <Stack.Col gap={4} align="start">
 | 
			
		||||
                  <Stack.Row gap={1}>
 | 
			
		||||
                  <Stack.Row
 | 
			
		||||
                    gap={1}
 | 
			
		||||
                    className={clsx("App-toolbar-container", {
 | 
			
		||||
                      "zen-mode": zenModeEnabled,
 | 
			
		||||
                    })}
 | 
			
		||||
                  >
 | 
			
		||||
                    <PenModeButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.penMode}
 | 
			
		||||
                      onChange={onPenModeToggle}
 | 
			
		||||
                      title={t("toolBar.penMode")}
 | 
			
		||||
                      penDetected={appState.penDetected}
 | 
			
		||||
                    />
 | 
			
		||||
                    <LockButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
@@ -592,15 +331,26 @@ const LayerUI = ({
 | 
			
		||||
                    />
 | 
			
		||||
                    <Island
 | 
			
		||||
                      padding={1}
 | 
			
		||||
                      className={clsx({ "zen-mode": zenModeEnabled })}
 | 
			
		||||
                      className={clsx("App-toolbar", {
 | 
			
		||||
                        "zen-mode": zenModeEnabled,
 | 
			
		||||
                      })}
 | 
			
		||||
                    >
 | 
			
		||||
                      <HintViewer appState={appState} elements={elements} />
 | 
			
		||||
                      <HintViewer
 | 
			
		||||
                        appState={appState}
 | 
			
		||||
                        elements={elements}
 | 
			
		||||
                        isMobile={isMobile}
 | 
			
		||||
                      />
 | 
			
		||||
                      {heading}
 | 
			
		||||
                      <Stack.Row gap={1}>
 | 
			
		||||
                        <ShapesSwitcher
 | 
			
		||||
                          canvas={canvas}
 | 
			
		||||
                          elementType={appState.elementType}
 | 
			
		||||
                          setAppState={setAppState}
 | 
			
		||||
                          onImageAction={({ pointerType }) => {
 | 
			
		||||
                            onImageAction({
 | 
			
		||||
                              insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                            });
 | 
			
		||||
                          }}
 | 
			
		||||
                        />
 | 
			
		||||
                      </Stack.Row>
 | 
			
		||||
                    </Island>
 | 
			
		||||
@@ -670,7 +420,8 @@ const LayerUI = ({
 | 
			
		||||
              {!viewModeEnabled && (
 | 
			
		||||
                <div
 | 
			
		||||
                  className={clsx("undo-redo-buttons zen-mode-transition", {
 | 
			
		||||
                    "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
 | 
			
		||||
                    "layer-ui__wrapper__footer-left--transition-bottom":
 | 
			
		||||
                      zenModeEnabled,
 | 
			
		||||
                  })}
 | 
			
		||||
                >
 | 
			
		||||
                  {actionManager.renderAction("undo", { size: "small" })}
 | 
			
		||||
@@ -684,7 +435,8 @@ const LayerUI = ({
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-center zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
 | 
			
		||||
              "layer-ui__wrapper__footer-left--transition-bottom":
 | 
			
		||||
                zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
@@ -756,11 +508,14 @@ const LayerUI = ({
 | 
			
		||||
        setAppState={setAppState}
 | 
			
		||||
        onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
        onLockToggle={onLockToggle}
 | 
			
		||||
        onPenModeToggle={onPenModeToggle}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
        isCollaborating={isCollaborating}
 | 
			
		||||
        renderCustomFooter={renderCustomFooter}
 | 
			
		||||
        viewModeEnabled={viewModeEnabled}
 | 
			
		||||
        showThemeBtn={showThemeBtn}
 | 
			
		||||
        onImageAction={onImageAction}
 | 
			
		||||
        renderTopRightUI={renderTopRightUI}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ) : (
 | 
			
		||||
@@ -808,6 +563,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
 | 
			
		||||
    prev.renderCustomFooter === next.renderCustomFooter &&
 | 
			
		||||
    prev.langCode === next.langCode &&
 | 
			
		||||
    prev.elements === next.elements &&
 | 
			
		||||
    prev.files === next.files &&
 | 
			
		||||
    keys.every((key) => prevAppState[key] === nextAppState[key])
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
 | 
			
		||||
export const LibraryButton: React.FC<{
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
}> = ({ appState, setAppState }) => {
 | 
			
		||||
  isMobile?: boolean;
 | 
			
		||||
}> = ({ appState, setAppState, isMobile }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
 | 
			
		||||
        "ToolIcon ToolIcon_type_floating ToolIcon__library",
 | 
			
		||||
        `ToolIcon_size_medium`,
 | 
			
		||||
        {
 | 
			
		||||
          "zen-mode-visibility--hidden": appState.zenModeEnabled,
 | 
			
		||||
          "is-mobile": isMobile,
 | 
			
		||||
        },
 | 
			
		||||
      )}
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 9`}
 | 
			
		||||
      style={{ marginInlineStart: "var(--space-factor)" }}
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 0`}
 | 
			
		||||
    >
 | 
			
		||||
      <input
 | 
			
		||||
        className="ToolIcon_type_checkbox"
 | 
			
		||||
@@ -38,7 +38,7 @@ export const LibraryButton: React.FC<{
 | 
			
		||||
        }}
 | 
			
		||||
        checked={appState.isLibraryOpen}
 | 
			
		||||
        aria-label={capitalizeString(t("toolBar.library"))}
 | 
			
		||||
        aria-keyshortcuts="9"
 | 
			
		||||
        aria-keyshortcuts="0"
 | 
			
		||||
      />
 | 
			
		||||
      <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
 | 
			
		||||
    </label>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										55
									
								
								src/components/LibraryMenu.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/components/LibraryMenu.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
@import "open-color/open-color";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .layer-ui__library {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    .layer-ui__library-header {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      margin: 2px 0;
 | 
			
		||||
 | 
			
		||||
      button {
 | 
			
		||||
        // 2px from the left to account for focus border of left-most button
 | 
			
		||||
        margin: 0 2px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      a {
 | 
			
		||||
        margin-inline-start: auto;
 | 
			
		||||
        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
 | 
			
		||||
        padding-inline-end: 18px;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__library-message {
 | 
			
		||||
    padding: 10px 20px;
 | 
			
		||||
    max-width: 200px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .publish-library-success {
 | 
			
		||||
    .Dialog__content {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-close.ToolIcon_type_button {
 | 
			
		||||
      background-color: $oc-blue-6;
 | 
			
		||||
      align-self: flex-end;
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: $oc-blue-8;
 | 
			
		||||
      }
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        width: auto;
 | 
			
		||||
        font-size: 1rem;
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
        padding: 0 0.5rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										326
									
								
								src/components/LibraryMenu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										326
									
								
								src/components/LibraryMenu.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,326 @@
 | 
			
		||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import {
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import PublishLibrary from "./PublishLibrary";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
import "./LibraryMenu.scss";
 | 
			
		||||
import LibraryMenuItems from "./LibraryMenuItems";
 | 
			
		||||
import { EVENT } from "../constants";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
  ref: RefObject<HTMLElement>,
 | 
			
		||||
  cb: (event: MouseEvent) => void,
 | 
			
		||||
) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const listener = (event: MouseEvent) => {
 | 
			
		||||
      if (!ref.current) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        event.target instanceof Element &&
 | 
			
		||||
        (ref.current.contains(event.target) ||
 | 
			
		||||
          !document.body.contains(event.target))
 | 
			
		||||
      ) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      cb(event);
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener("pointerdown", listener, false);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener("pointerdown", listener);
 | 
			
		||||
    };
 | 
			
		||||
  }, [ref, cb]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getSelectedItems = (
 | 
			
		||||
  libraryItems: LibraryItems,
 | 
			
		||||
  selectedItems: LibraryItem["id"][],
 | 
			
		||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
 | 
			
		||||
 | 
			
		||||
export const LibraryMenu = ({
 | 
			
		||||
  onClose,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  files,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  appState,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem["elements"];
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
  onAddToLibrary: () => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
 | 
			
		||||
  useOnClickOutside(ref, (event) => {
 | 
			
		||||
    // If click on the library icon, do nothing.
 | 
			
		||||
    if ((event.target as Element).closest(".ToolIcon__library")) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    onClose();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleKeyDown = (event: KeyboardEvent) => {
 | 
			
		||||
      if (event.key === KEYS.ESCAPE) {
 | 
			
		||||
        onClose();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
 | 
			
		||||
    };
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
 | 
			
		||||
  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
 | 
			
		||||
 | 
			
		||||
  const [loadingState, setIsLoading] = useState<
 | 
			
		||||
    "preloading" | "loading" | "ready"
 | 
			
		||||
  >("preloading");
 | 
			
		||||
  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
 | 
			
		||||
  const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
 | 
			
		||||
    useState(false);
 | 
			
		||||
  const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
 | 
			
		||||
    url: string;
 | 
			
		||||
    authorName: string;
 | 
			
		||||
  }>(null);
 | 
			
		||||
  const loadingTimerRef = useRef<number | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    Promise.race([
 | 
			
		||||
      new Promise((resolve) => {
 | 
			
		||||
        loadingTimerRef.current = window.setTimeout(() => {
 | 
			
		||||
          resolve("loading");
 | 
			
		||||
        }, 100);
 | 
			
		||||
      }),
 | 
			
		||||
      library.loadLibrary().then((items) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setIsLoading("ready");
 | 
			
		||||
      }),
 | 
			
		||||
    ]).then((data) => {
 | 
			
		||||
      if (data === "loading") {
 | 
			
		||||
        setIsLoading("loading");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearTimeout(loadingTimerRef.current!);
 | 
			
		||||
    };
 | 
			
		||||
  }, [library]);
 | 
			
		||||
 | 
			
		||||
  const removeFromLibrary = useCallback(async () => {
 | 
			
		||||
    const items = await library.loadLibrary();
 | 
			
		||||
 | 
			
		||||
    const nextItems = items.filter((item) => !selectedItems.includes(item.id));
 | 
			
		||||
    library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
      setLibraryItems(items);
 | 
			
		||||
      setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
 | 
			
		||||
    });
 | 
			
		||||
    setSelectedItems([]);
 | 
			
		||||
    setLibraryItems(nextItems);
 | 
			
		||||
  }, [library, setAppState, selectedItems, setSelectedItems]);
 | 
			
		||||
 | 
			
		||||
  const resetLibrary = useCallback(() => {
 | 
			
		||||
    library.resetLibrary();
 | 
			
		||||
    setLibraryItems([]);
 | 
			
		||||
    focusContainer();
 | 
			
		||||
  }, [library, focusContainer]);
 | 
			
		||||
 | 
			
		||||
  const addToLibrary = useCallback(
 | 
			
		||||
    async (elements: LibraryItem["elements"]) => {
 | 
			
		||||
      if (elements.some((element) => element.type === "image")) {
 | 
			
		||||
        return setAppState({
 | 
			
		||||
          errorMessage: "Support for adding images to the library coming soon!",
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      const items = await library.loadLibrary();
 | 
			
		||||
      const nextItems: LibraryItems = [
 | 
			
		||||
        {
 | 
			
		||||
          status: "unpublished",
 | 
			
		||||
          elements,
 | 
			
		||||
          id: randomId(),
 | 
			
		||||
          created: Date.now(),
 | 
			
		||||
        },
 | 
			
		||||
        ...items,
 | 
			
		||||
      ];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary, library, setAppState],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderPublishSuccess = useCallback(() => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Dialog
 | 
			
		||||
        onCloseRequest={() => setPublishLibSuccess(null)}
 | 
			
		||||
        title={t("publishSuccessDialog.title")}
 | 
			
		||||
        className="publish-library-success"
 | 
			
		||||
        small={true}
 | 
			
		||||
      >
 | 
			
		||||
        <p>
 | 
			
		||||
          {t("publishSuccessDialog.content", {
 | 
			
		||||
            authorName: publishLibSuccess!.authorName,
 | 
			
		||||
          })}{" "}
 | 
			
		||||
          <a
 | 
			
		||||
            href={publishLibSuccess?.url}
 | 
			
		||||
            target="_blank"
 | 
			
		||||
            rel="noopener noreferrer"
 | 
			
		||||
          >
 | 
			
		||||
            {t("publishSuccessDialog.link")}
 | 
			
		||||
          </a>
 | 
			
		||||
        </p>
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          type="button"
 | 
			
		||||
          title={t("buttons.close")}
 | 
			
		||||
          aria-label={t("buttons.close")}
 | 
			
		||||
          label={t("buttons.close")}
 | 
			
		||||
          onClick={() => setPublishLibSuccess(null)}
 | 
			
		||||
          data-testid="publish-library-success-close"
 | 
			
		||||
          className="publish-library-success-close"
 | 
			
		||||
        />
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    );
 | 
			
		||||
  }, [setPublishLibSuccess, publishLibSuccess]);
 | 
			
		||||
 | 
			
		||||
  const onPublishLibSuccess = useCallback(
 | 
			
		||||
    (data) => {
 | 
			
		||||
      setShowPublishLibraryDialog(false);
 | 
			
		||||
      setPublishLibSuccess({ url: data.url, authorName: data.authorName });
 | 
			
		||||
      const nextLibItems = libraryItems.slice();
 | 
			
		||||
      nextLibItems.forEach((libItem) => {
 | 
			
		||||
        if (selectedItems.includes(libItem.id)) {
 | 
			
		||||
          libItem.status = "published";
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      library.saveLibrary(nextLibItems);
 | 
			
		||||
      setLibraryItems(nextLibItems);
 | 
			
		||||
    },
 | 
			
		||||
    [
 | 
			
		||||
      setShowPublishLibraryDialog,
 | 
			
		||||
      setPublishLibSuccess,
 | 
			
		||||
      libraryItems,
 | 
			
		||||
      selectedItems,
 | 
			
		||||
      library,
 | 
			
		||||
    ],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [lastSelectedItem, setLastSelectedItem] = useState<
 | 
			
		||||
    LibraryItem["id"] | null
 | 
			
		||||
  >(null);
 | 
			
		||||
 | 
			
		||||
  return loadingState === "preloading" ? null : (
 | 
			
		||||
    <Island padding={1} ref={ref} className="layer-ui__library">
 | 
			
		||||
      {showPublishLibraryDialog && (
 | 
			
		||||
        <PublishLibrary
 | 
			
		||||
          onClose={() => setShowPublishLibraryDialog(false)}
 | 
			
		||||
          libraryItems={getSelectedItems(libraryItems, selectedItems)}
 | 
			
		||||
          appState={appState}
 | 
			
		||||
          onSuccess={onPublishLibSuccess}
 | 
			
		||||
          onError={(error) => window.alert(error)}
 | 
			
		||||
          updateItemsInStorage={() => library.saveLibrary(libraryItems)}
 | 
			
		||||
          onRemove={(id: string) =>
 | 
			
		||||
            setSelectedItems(selectedItems.filter((_id) => _id !== id))
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {publishLibSuccess && renderPublishSuccess()}
 | 
			
		||||
 | 
			
		||||
      {loadingState === "loading" ? (
 | 
			
		||||
        <div className="layer-ui__library-message">
 | 
			
		||||
          {t("labels.libraryLoadingMessage")}
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LibraryMenuItems
 | 
			
		||||
          libraryItems={libraryItems}
 | 
			
		||||
          onRemoveFromLibrary={removeFromLibrary}
 | 
			
		||||
          onAddToLibrary={addToLibrary}
 | 
			
		||||
          onInsertShape={onInsertShape}
 | 
			
		||||
          pendingElements={pendingElements}
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
          library={library}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
          files={files}
 | 
			
		||||
          id={id}
 | 
			
		||||
          selectedItems={selectedItems}
 | 
			
		||||
          onToggle={(id, event) => {
 | 
			
		||||
            const shouldSelect = !selectedItems.includes(id);
 | 
			
		||||
 | 
			
		||||
            if (shouldSelect) {
 | 
			
		||||
              if (event.shiftKey && lastSelectedItem) {
 | 
			
		||||
                const rangeStart = libraryItems.findIndex(
 | 
			
		||||
                  (item) => item.id === lastSelectedItem,
 | 
			
		||||
                );
 | 
			
		||||
                const rangeEnd = libraryItems.findIndex(
 | 
			
		||||
                  (item) => item.id === id,
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                if (rangeStart === -1 || rangeEnd === -1) {
 | 
			
		||||
                  setSelectedItems([...selectedItems, id]);
 | 
			
		||||
                  return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const selectedItemsMap = arrayToMap(selectedItems);
 | 
			
		||||
                const nextSelectedIds = libraryItems.reduce(
 | 
			
		||||
                  (acc: LibraryItem["id"][], item, idx) => {
 | 
			
		||||
                    if (
 | 
			
		||||
                      (idx >= rangeStart && idx <= rangeEnd) ||
 | 
			
		||||
                      selectedItemsMap.has(item.id)
 | 
			
		||||
                    ) {
 | 
			
		||||
                      acc.push(item.id);
 | 
			
		||||
                    }
 | 
			
		||||
                    return acc;
 | 
			
		||||
                  },
 | 
			
		||||
                  [],
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                setSelectedItems(nextSelectedIds);
 | 
			
		||||
              } else {
 | 
			
		||||
                setSelectedItems([...selectedItems, id]);
 | 
			
		||||
              }
 | 
			
		||||
              setLastSelectedItem(id);
 | 
			
		||||
            } else {
 | 
			
		||||
              setLastSelectedItem(null);
 | 
			
		||||
              setSelectedItems(selectedItems.filter((_id) => _id !== id));
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          onPublish={() => setShowPublishLibraryDialog(true)}
 | 
			
		||||
          resetLibrary={resetLibrary}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Island>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										102
									
								
								src/components/LibraryMenuItems.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/components/LibraryMenuItems.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,102 @@
 | 
			
		||||
@import "open-color/open-color";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .library-menu-items-container {
 | 
			
		||||
    .library-actions {
 | 
			
		||||
      display: flex;
 | 
			
		||||
 | 
			
		||||
      button .library-actions-counter {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        right: 2px;
 | 
			
		||||
        bottom: 2px;
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
        width: 1em;
 | 
			
		||||
        height: 1em;
 | 
			
		||||
        padding: 1px;
 | 
			
		||||
        font-size: 0.7rem;
 | 
			
		||||
        background: #fff;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--remove {
 | 
			
		||||
        background-color: $oc-red-7;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-red-8;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-red-9;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
        .library-actions-counter {
 | 
			
		||||
          color: $oc-red-7;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--export {
 | 
			
		||||
        background-color: $oc-lime-5;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-lime-7;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-lime-8;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
        .library-actions-counter {
 | 
			
		||||
          color: $oc-lime-5;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--publish {
 | 
			
		||||
        background-color: $oc-cyan-6;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-cyan-7;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-cyan-9;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
        label {
 | 
			
		||||
          margin-left: -0.2em;
 | 
			
		||||
          margin-right: 1.1em;
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
          font-size: 0.86em;
 | 
			
		||||
        }
 | 
			
		||||
        .library-actions-counter {
 | 
			
		||||
          color: $oc-cyan-6;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--load {
 | 
			
		||||
        background-color: $oc-blue-6;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-blue-7;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-blue-9;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    &__items {
 | 
			
		||||
      max-height: 50vh;
 | 
			
		||||
      overflow: auto;
 | 
			
		||||
      margin-top: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .separator {
 | 
			
		||||
      font-weight: 500;
 | 
			
		||||
      font-size: 0.9rem;
 | 
			
		||||
      margin: 0.6em 0.2em;
 | 
			
		||||
      color: var(--text-primary-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										323
									
								
								src/components/LibraryMenuItems.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								src/components/LibraryMenuItems.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,323 @@
 | 
			
		||||
import { chunk } from "lodash";
 | 
			
		||||
import { useCallback, useState } from "react";
 | 
			
		||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import ConfirmDialog from "./ConfirmDialog";
 | 
			
		||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
 | 
			
		||||
import "./LibraryMenuItems.scss";
 | 
			
		||||
import { VERSIONS } from "../constants";
 | 
			
		||||
 | 
			
		||||
const LibraryMenuItems = ({
 | 
			
		||||
  libraryItems,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  library,
 | 
			
		||||
  files,
 | 
			
		||||
  id,
 | 
			
		||||
  selectedItems,
 | 
			
		||||
  onToggle,
 | 
			
		||||
  onPublish,
 | 
			
		||||
  resetLibrary,
 | 
			
		||||
}: {
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
  pendingElements: LibraryItem["elements"];
 | 
			
		||||
  onRemoveFromLibrary: () => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
  onAddToLibrary: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  selectedItems: LibraryItem["id"][];
 | 
			
		||||
  onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
 | 
			
		||||
  onPublish: () => void;
 | 
			
		||||
  resetLibrary: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const renderRemoveLibAlert = useCallback(() => {
 | 
			
		||||
    const content = selectedItems.length
 | 
			
		||||
      ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
 | 
			
		||||
      : t("alerts.resetLibrary");
 | 
			
		||||
    const title = selectedItems.length
 | 
			
		||||
      ? t("confirmDialog.removeItemsFromLib")
 | 
			
		||||
      : t("confirmDialog.resetLibrary");
 | 
			
		||||
    return (
 | 
			
		||||
      <ConfirmDialog
 | 
			
		||||
        onConfirm={() => {
 | 
			
		||||
          if (selectedItems.length) {
 | 
			
		||||
            onRemoveFromLibrary();
 | 
			
		||||
          } else {
 | 
			
		||||
            resetLibrary();
 | 
			
		||||
          }
 | 
			
		||||
          setShowRemoveLibAlert(false);
 | 
			
		||||
        }}
 | 
			
		||||
        onCancel={() => {
 | 
			
		||||
          setShowRemoveLibAlert(false);
 | 
			
		||||
        }}
 | 
			
		||||
        title={title}
 | 
			
		||||
      >
 | 
			
		||||
        <p>{content}</p>
 | 
			
		||||
      </ConfirmDialog>
 | 
			
		||||
    );
 | 
			
		||||
  }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
 | 
			
		||||
 | 
			
		||||
  const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
  const renderLibraryActions = () => {
 | 
			
		||||
    const itemsSelected = !!selectedItems.length;
 | 
			
		||||
    const items = itemsSelected
 | 
			
		||||
      ? libraryItems.filter((item) => selectedItems.includes(item.id))
 | 
			
		||||
      : libraryItems;
 | 
			
		||||
    const resetLabel = itemsSelected
 | 
			
		||||
      ? t("buttons.remove")
 | 
			
		||||
      : t("buttons.resetLibrary");
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="library-actions">
 | 
			
		||||
        {(!itemsSelected || !isMobile) && (
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="import"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.load")}
 | 
			
		||||
            aria-label={t("buttons.load")}
 | 
			
		||||
            icon={load}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              importLibraryFromJSON(library)
 | 
			
		||||
                .then(() => {
 | 
			
		||||
                  // Close and then open to get the libraries updated
 | 
			
		||||
                  setAppState({ isLibraryOpen: false });
 | 
			
		||||
                  setAppState({ isLibraryOpen: true });
 | 
			
		||||
                })
 | 
			
		||||
                .catch(muteFSAbortError)
 | 
			
		||||
                .catch((error) => {
 | 
			
		||||
                  setAppState({ errorMessage: error.message });
 | 
			
		||||
                });
 | 
			
		||||
            }}
 | 
			
		||||
            className="library-actions--load"
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {!!items.length && (
 | 
			
		||||
          <>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              key="export"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("buttons.export")}
 | 
			
		||||
              aria-label={t("buttons.export")}
 | 
			
		||||
              icon={exportToFileIcon}
 | 
			
		||||
              onClick={async () => {
 | 
			
		||||
                const libraryItems = itemsSelected
 | 
			
		||||
                  ? items
 | 
			
		||||
                  : await library.loadLibrary();
 | 
			
		||||
                saveLibraryAsJSON(libraryItems)
 | 
			
		||||
                  .catch(muteFSAbortError)
 | 
			
		||||
                  .catch((error) => {
 | 
			
		||||
                    setAppState({ errorMessage: error.message });
 | 
			
		||||
                  });
 | 
			
		||||
              }}
 | 
			
		||||
              className="library-actions--export"
 | 
			
		||||
            >
 | 
			
		||||
              {selectedItems.length > 0 && (
 | 
			
		||||
                <span className="library-actions-counter">
 | 
			
		||||
                  {selectedItems.length}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </ToolButton>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              key="reset"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={resetLabel}
 | 
			
		||||
              aria-label={resetLabel}
 | 
			
		||||
              icon={trash}
 | 
			
		||||
              onClick={() => setShowRemoveLibAlert(true)}
 | 
			
		||||
              className="library-actions--remove"
 | 
			
		||||
            >
 | 
			
		||||
              {selectedItems.length > 0 && (
 | 
			
		||||
                <span className="library-actions-counter">
 | 
			
		||||
                  {selectedItems.length}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </ToolButton>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        {itemsSelected && !isPublished && (
 | 
			
		||||
          <Tooltip label={t("hints.publishLibrary")}>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              type="button"
 | 
			
		||||
              aria-label={t("buttons.publishLibrary")}
 | 
			
		||||
              label={t("buttons.publishLibrary")}
 | 
			
		||||
              icon={publishIcon}
 | 
			
		||||
              className="library-actions--publish"
 | 
			
		||||
              onClick={onPublish}
 | 
			
		||||
            >
 | 
			
		||||
              {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
 | 
			
		||||
              {selectedItems.length > 0 && (
 | 
			
		||||
                <span className="library-actions-counter">
 | 
			
		||||
                  {selectedItems.length}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </ToolButton>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const CELLS_PER_ROW = isMobile ? 4 : 6;
 | 
			
		||||
 | 
			
		||||
  const referrer =
 | 
			
		||||
    libraryReturnUrl || window.location.origin + window.location.pathname;
 | 
			
		||||
  const isPublished = selectedItems.some(
 | 
			
		||||
    (id) => libraryItems.find((item) => item.id === id)?.status === "published",
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const createLibraryItemCompo = (params: {
 | 
			
		||||
    item:
 | 
			
		||||
      | LibraryItem
 | 
			
		||||
      | /* pending library item */ {
 | 
			
		||||
          id: null;
 | 
			
		||||
          elements: readonly NonDeleted<ExcalidrawElement>[];
 | 
			
		||||
        }
 | 
			
		||||
      | null;
 | 
			
		||||
    onClick?: () => void;
 | 
			
		||||
    key: string;
 | 
			
		||||
  }) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Stack.Col key={params.key}>
 | 
			
		||||
        <LibraryUnit
 | 
			
		||||
          elements={params.item?.elements}
 | 
			
		||||
          files={files}
 | 
			
		||||
          isPending={!params.item?.id && !!params.item?.elements}
 | 
			
		||||
          onClick={params.onClick || (() => {})}
 | 
			
		||||
          id={params.item?.id || null}
 | 
			
		||||
          selected={!!params.item?.id && selectedItems.includes(params.item.id)}
 | 
			
		||||
          onToggle={(id, event) => {
 | 
			
		||||
            onToggle(id, event);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderLibrarySection = (
 | 
			
		||||
    items: (
 | 
			
		||||
      | LibraryItem
 | 
			
		||||
      | /* pending library item */ {
 | 
			
		||||
          id: null;
 | 
			
		||||
          elements: readonly NonDeleted<ExcalidrawElement>[];
 | 
			
		||||
        }
 | 
			
		||||
    )[],
 | 
			
		||||
  ) => {
 | 
			
		||||
    const _items = items.map((item) => {
 | 
			
		||||
      if (item.id) {
 | 
			
		||||
        return createLibraryItemCompo({
 | 
			
		||||
          item,
 | 
			
		||||
          onClick: () => onInsertShape(item.elements),
 | 
			
		||||
          key: item.id,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      return createLibraryItemCompo({
 | 
			
		||||
        key: "__pending__item__",
 | 
			
		||||
        item,
 | 
			
		||||
        onClick: () => onAddToLibrary(pendingElements),
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // ensure we render all empty cells if no items are present
 | 
			
		||||
    let rows = chunk(_items, CELLS_PER_ROW);
 | 
			
		||||
    if (!rows.length) {
 | 
			
		||||
      rows = [[]];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return rows.map((rowItems, index, rows) => {
 | 
			
		||||
      if (index === rows.length - 1) {
 | 
			
		||||
        // pad row with empty cells
 | 
			
		||||
        rowItems = rowItems.concat(
 | 
			
		||||
          new Array(CELLS_PER_ROW - rowItems.length)
 | 
			
		||||
            .fill(null)
 | 
			
		||||
            .map((_, index) => {
 | 
			
		||||
              return createLibraryItemCompo({
 | 
			
		||||
                key: `empty_${index}`,
 | 
			
		||||
                item: null,
 | 
			
		||||
              });
 | 
			
		||||
            }),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      return (
 | 
			
		||||
        <Stack.Row align="center" gap={1} key={index}>
 | 
			
		||||
          {rowItems}
 | 
			
		||||
        </Stack.Row>
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const publishedItems = libraryItems.filter(
 | 
			
		||||
    (item) => item.status === "published",
 | 
			
		||||
  );
 | 
			
		||||
  const unpublishedItems = [
 | 
			
		||||
    // append pending library item
 | 
			
		||||
    ...(pendingElements.length
 | 
			
		||||
      ? [{ id: null, elements: pendingElements }]
 | 
			
		||||
      : []),
 | 
			
		||||
    ...libraryItems.filter((item) => item.status !== "published"),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="library-menu-items-container">
 | 
			
		||||
      {showRemoveLibAlert && renderRemoveLibAlert()}
 | 
			
		||||
      <div className="layer-ui__library-header" key="library-header">
 | 
			
		||||
        {renderLibraryActions()}
 | 
			
		||||
        <a
 | 
			
		||||
          href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
 | 
			
		||||
            window.name || "_blank"
 | 
			
		||||
          }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
 | 
			
		||||
            VERSIONS.excalidrawLibrary
 | 
			
		||||
          }`}
 | 
			
		||||
          target="_excalidraw_libraries"
 | 
			
		||||
        >
 | 
			
		||||
          {t("labels.libraries")}
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Stack.Col
 | 
			
		||||
        className="library-menu-items-container__items"
 | 
			
		||||
        align="start"
 | 
			
		||||
        gap={1}
 | 
			
		||||
      >
 | 
			
		||||
        <>
 | 
			
		||||
          <div className="separator">{t("labels.personalLib")}</div>
 | 
			
		||||
          {renderLibrarySection(unpublishedItems)}
 | 
			
		||||
        </>
 | 
			
		||||
 | 
			
		||||
        <>
 | 
			
		||||
          <div className="separator">{t("labels.excalidrawLib")} </div>
 | 
			
		||||
 | 
			
		||||
          {renderLibrarySection(publishedItems)}
 | 
			
		||||
        </>
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default LibraryMenuItems;
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .library-unit {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
@@ -7,10 +9,26 @@
 | 
			
		||||
    position: relative;
 | 
			
		||||
    width: 63px;
 | 
			
		||||
    height: 63px; // match width
 | 
			
		||||
 | 
			
		||||
    &--hover {
 | 
			
		||||
      box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
 | 
			
		||||
      border-color: $oc-blue-5;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--selected {
 | 
			
		||||
      box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
 | 
			
		||||
      border-color: $oc-blue-8;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.theme--dark .library-unit {
 | 
			
		||||
    border-color: rgb(48, 48, 48);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__dragger {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
@@ -22,9 +40,9 @@
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__removeFromLibrary,
 | 
			
		||||
  .library-unit__removeFromLibrary:hover,
 | 
			
		||||
  .library-unit__removeFromLibrary:active {
 | 
			
		||||
  .library-unit__checkbox-container,
 | 
			
		||||
  .library-unit__checkbox-container:hover,
 | 
			
		||||
  .library-unit__checkbox-container:active {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    background: none;
 | 
			
		||||
    border: none;
 | 
			
		||||
@@ -32,10 +50,35 @@
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    padding: 0.5rem;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 5px;
 | 
			
		||||
    top: 5px;
 | 
			
		||||
    left: 2rem;
 | 
			
		||||
    bottom: 2rem;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    input {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__checkbox {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 2.3rem;
 | 
			
		||||
    bottom: 2.3rem;
 | 
			
		||||
 | 
			
		||||
    .Checkbox-box {
 | 
			
		||||
      width: 13px;
 | 
			
		||||
      height: 13px;
 | 
			
		||||
      border-radius: 2px;
 | 
			
		||||
      margin: 0.5em 0.5em 0.2em 0.2em;
 | 
			
		||||
      background-color: $oc-blue-1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.Checkbox:hover {
 | 
			
		||||
      .Checkbox-box {
 | 
			
		||||
        background-color: $oc-blue-2;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__removeFromLibrary > svg {
 | 
			
		||||
@@ -43,29 +86,37 @@
 | 
			
		||||
    width: 16px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__pulse {
 | 
			
		||||
  .library-unit__adder {
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
    animation: library-unit__pulse-animation 1s ease-in infinite;
 | 
			
		||||
    animation: library-unit__adder-animation 1s ease-in infinite;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__adder {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    width: 20px;
 | 
			
		||||
    height: 20px;
 | 
			
		||||
    left: 40%;
 | 
			
		||||
    top: 40%;
 | 
			
		||||
    width: 2rem;
 | 
			
		||||
    height: 2rem;
 | 
			
		||||
    margin-left: -10px;
 | 
			
		||||
    margin-top: -10px;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
  }
 | 
			
		||||
  .library-unit:hover .library-unit__adder {
 | 
			
		||||
    fill: $oc-blue-7;
 | 
			
		||||
  }
 | 
			
		||||
  .library-unit:active .library-unit__adder {
 | 
			
		||||
    animation: none;
 | 
			
		||||
    transform: scale(0.8);
 | 
			
		||||
    fill: $oc-black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__active {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @keyframes library-unit__pulse-animation {
 | 
			
		||||
  @keyframes library-unit__adder-animation {
 | 
			
		||||
    0% {
 | 
			
		||||
      transform: scale(0.95);
 | 
			
		||||
      transform: scale(0.85);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    50% {
 | 
			
		||||
@@ -73,7 +124,7 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    100% {
 | 
			
		||||
      transform: scale(0.95);
 | 
			
		||||
      transform: scale(0.85);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,87 +1,103 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { close } from "../components/icons";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import { BinaryFiles, LibraryItem } from "../types";
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
 | 
			
		||||
// fa-plus
 | 
			
		||||
const PLUS_ICON = (
 | 
			
		||||
  <svg viewBox="0 0 1792 1792">
 | 
			
		||||
    <path
 | 
			
		||||
      fill="currentColor"
 | 
			
		||||
      d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
 | 
			
		||||
      d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
 | 
			
		||||
      style={{
 | 
			
		||||
        stroke: "#fff",
 | 
			
		||||
        strokeWidth: 140,
 | 
			
		||||
      }}
 | 
			
		||||
      transform="translate(0 64)"
 | 
			
		||||
    />
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const LibraryUnit = ({
 | 
			
		||||
  id,
 | 
			
		||||
  elements,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  files,
 | 
			
		||||
  isPending,
 | 
			
		||||
  onClick,
 | 
			
		||||
  selected,
 | 
			
		||||
  onToggle,
 | 
			
		||||
}: {
 | 
			
		||||
  elements?: LibraryItem;
 | 
			
		||||
  pendingElements?: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: () => void;
 | 
			
		||||
  id: LibraryItem["id"] | /** for pending item */ null;
 | 
			
		||||
  elements?: LibraryItem["elements"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  isPending?: boolean;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  selected: boolean;
 | 
			
		||||
  onToggle: (id: string, event: React.MouseEvent) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const elementsToRender = elements || pendingElements;
 | 
			
		||||
    if (!elementsToRender) {
 | 
			
		||||
    const node = ref.current;
 | 
			
		||||
    if (!node) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    let svg: SVGSVGElement;
 | 
			
		||||
    const current = ref.current!;
 | 
			
		||||
 | 
			
		||||
    (async () => {
 | 
			
		||||
      svg = await exportToSvg(elementsToRender, {
 | 
			
		||||
        exportBackground: false,
 | 
			
		||||
        viewBackgroundColor: oc.white,
 | 
			
		||||
      });
 | 
			
		||||
      for (const child of ref.current!.children) {
 | 
			
		||||
        if (child.tagName !== "svg") {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
        current!.removeChild(child);
 | 
			
		||||
      if (!elements) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      current!.appendChild(svg);
 | 
			
		||||
      const svg = await exportToSvg(
 | 
			
		||||
        elements,
 | 
			
		||||
        {
 | 
			
		||||
          exportBackground: false,
 | 
			
		||||
          viewBackgroundColor: oc.white,
 | 
			
		||||
        },
 | 
			
		||||
        files,
 | 
			
		||||
      );
 | 
			
		||||
      node.innerHTML = svg.outerHTML;
 | 
			
		||||
    })();
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (svg) {
 | 
			
		||||
        current.removeChild(svg);
 | 
			
		||||
      }
 | 
			
		||||
      node.innerHTML = "";
 | 
			
		||||
    };
 | 
			
		||||
  }, [elements, pendingElements]);
 | 
			
		||||
  }, [elements, files]);
 | 
			
		||||
 | 
			
		||||
  const [isHovered, setIsHovered] = useState(false);
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
  const adder = (isHovered || isMobile) && pendingElements && (
 | 
			
		||||
  const adder = isPending && (
 | 
			
		||||
    <div className="library-unit__adder">{PLUS_ICON}</div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("library-unit", {
 | 
			
		||||
        "library-unit__active": elements || pendingElements,
 | 
			
		||||
        "library-unit__active": elements,
 | 
			
		||||
        "library-unit--hover": elements && isHovered,
 | 
			
		||||
        "library-unit--selected": selected,
 | 
			
		||||
      })}
 | 
			
		||||
      onMouseEnter={() => setIsHovered(true)}
 | 
			
		||||
      onMouseLeave={() => setIsHovered(false)}
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("library-unit__dragger", {
 | 
			
		||||
          "library-unit__pulse": !!pendingElements,
 | 
			
		||||
          "library-unit__pulse": !!isPending,
 | 
			
		||||
        })}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
        draggable={!!elements}
 | 
			
		||||
        onClick={!!elements || !!pendingElements ? onClick : undefined}
 | 
			
		||||
        onClick={
 | 
			
		||||
          !!elements || !!isPending
 | 
			
		||||
            ? (event) => {
 | 
			
		||||
                if (id && event.shiftKey) {
 | 
			
		||||
                  onToggle(id, event);
 | 
			
		||||
                } else {
 | 
			
		||||
                  onClick();
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
        onDragStart={(event) => {
 | 
			
		||||
          setIsHovered(false);
 | 
			
		||||
          event.dataTransfer.setData(
 | 
			
		||||
@@ -91,14 +107,12 @@ export const LibraryUnit = ({
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {adder}
 | 
			
		||||
      {elements && (isHovered || isMobile) && (
 | 
			
		||||
        <button
 | 
			
		||||
          className="library-unit__removeFromLibrary"
 | 
			
		||||
          aria-label={t("labels.removeFromLibrary")}
 | 
			
		||||
          onClick={onRemoveFromLibrary}
 | 
			
		||||
        >
 | 
			
		||||
          {close}
 | 
			
		||||
        </button>
 | 
			
		||||
      {id && elements && (isHovered || isMobile || selected) && (
 | 
			
		||||
        <CheckboxItem
 | 
			
		||||
          checked={selected}
 | 
			
		||||
          onChange={(checked, event) => onToggle(id, event)}
 | 
			
		||||
          className="library-unit__checkbox"
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ type LockIconProps = {
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange?(): void;
 | 
			
		||||
  zenModeEnabled?: boolean;
 | 
			
		||||
  isMobile?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
 | 
			
		||||
@@ -42,10 +43,10 @@ export const LockButton = (props: LockIconProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
 | 
			
		||||
        "ToolIcon ToolIcon__lock ToolIcon_type_floating",
 | 
			
		||||
        `ToolIcon_size_${DEFAULT_SIZE}`,
 | 
			
		||||
        {
 | 
			
		||||
          "zen-mode-visibility--hidden": props.zenModeEnabled,
 | 
			
		||||
          "is-mobile": props.isMobile,
 | 
			
		||||
        },
 | 
			
		||||
      )}
 | 
			
		||||
      title={`${props.title} — Q`}
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ import { LockButton } from "./LockButton";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import { LibraryButton } from "./LibraryButton";
 | 
			
		||||
import { PenModeButton } from "./PenModeButton";
 | 
			
		||||
 | 
			
		||||
type MobileMenuProps = {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
@@ -28,11 +29,17 @@ type MobileMenuProps = {
 | 
			
		||||
  libraryMenu: JSX.Element | null;
 | 
			
		||||
  onCollabButtonClick?: () => void;
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  onPenModeToggle: () => void;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  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 = ({
 | 
			
		||||
@@ -45,11 +52,14 @@ export const MobileMenu = ({
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
  onPenModeToggle,
 | 
			
		||||
  canvas,
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
  viewModeEnabled,
 | 
			
		||||
  showThemeBtn,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  renderTopRightUI,
 | 
			
		||||
}: MobileMenuProps) => {
 | 
			
		||||
  const renderToolbar = () => {
 | 
			
		||||
    return (
 | 
			
		||||
@@ -57,29 +67,47 @@ export const MobileMenu = ({
 | 
			
		||||
        <Section heading="shapes">
 | 
			
		||||
          {(heading) => (
 | 
			
		||||
            <Stack.Col gap={4} align="center">
 | 
			
		||||
              <Stack.Row gap={1}>
 | 
			
		||||
                <Island padding={1}>
 | 
			
		||||
              <Stack.Row gap={1} className="App-toolbar-container">
 | 
			
		||||
                <Island padding={1} className="App-toolbar">
 | 
			
		||||
                  {heading}
 | 
			
		||||
                  <Stack.Row gap={1}>
 | 
			
		||||
                    <ShapesSwitcher
 | 
			
		||||
                      canvas={canvas}
 | 
			
		||||
                      elementType={appState.elementType}
 | 
			
		||||
                      setAppState={setAppState}
 | 
			
		||||
                      onImageAction={({ pointerType }) => {
 | 
			
		||||
                        onImageAction({
 | 
			
		||||
                          insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                        });
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Stack.Row>
 | 
			
		||||
                </Island>
 | 
			
		||||
                {renderTopRightUI && renderTopRightUI(true, appState)}
 | 
			
		||||
                <LockButton
 | 
			
		||||
                  checked={appState.elementLocked}
 | 
			
		||||
                  onChange={onLockToggle}
 | 
			
		||||
                  title={t("toolBar.lock")}
 | 
			
		||||
                  isMobile
 | 
			
		||||
                />
 | 
			
		||||
                <LibraryButton
 | 
			
		||||
                  appState={appState}
 | 
			
		||||
                  setAppState={setAppState}
 | 
			
		||||
                  isMobile
 | 
			
		||||
                />
 | 
			
		||||
                <PenModeButton
 | 
			
		||||
                  checked={appState.penMode}
 | 
			
		||||
                  onChange={onPenModeToggle}
 | 
			
		||||
                  title={t("toolBar.penMode")}
 | 
			
		||||
                  isMobile
 | 
			
		||||
                  penDetected={appState.penDetected}
 | 
			
		||||
                />
 | 
			
		||||
                <LibraryButton appState={appState} setAppState={setAppState} />
 | 
			
		||||
              </Stack.Row>
 | 
			
		||||
              {libraryMenu}
 | 
			
		||||
            </Stack.Col>
 | 
			
		||||
          )}
 | 
			
		||||
        </Section>
 | 
			
		||||
        <HintViewer appState={appState} elements={elements} />
 | 
			
		||||
        <HintViewer appState={appState} elements={elements} isMobile={true} />
 | 
			
		||||
      </FixedSideContainer>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 
 | 
			
		||||
@@ -15,8 +15,9 @@ export const Modal = (props: {
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  labelledBy: string;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
  closeOnClickOutside?: boolean;
 | 
			
		||||
}) => {
 | 
			
		||||
  const { theme = THEME.LIGHT } = props;
 | 
			
		||||
  const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
 | 
			
		||||
  const modalRoot = useBodyRoot(theme);
 | 
			
		||||
 | 
			
		||||
  if (!modalRoot) {
 | 
			
		||||
@@ -39,7 +40,10 @@ export const Modal = (props: {
 | 
			
		||||
      onKeyDown={handleKeydown}
 | 
			
		||||
      aria-labelledby={props.labelledBy}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="Modal__background" onClick={props.onCloseRequest}></div>
 | 
			
		||||
      <div
 | 
			
		||||
        className="Modal__background"
 | 
			
		||||
        onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
 | 
			
		||||
      ></div>
 | 
			
		||||
      <div
 | 
			
		||||
        className="Modal__content"
 | 
			
		||||
        style={{ "--max-width": `${props.maxWidth}px` }}
 | 
			
		||||
 
 | 
			
		||||
@@ -38,10 +38,14 @@ const ChartPreviewBtn = (props: {
 | 
			
		||||
    const previewNode = previewRef.current!;
 | 
			
		||||
 | 
			
		||||
    (async () => {
 | 
			
		||||
      svg = await exportToSvg(elements, {
 | 
			
		||||
        exportBackground: false,
 | 
			
		||||
        viewBackgroundColor: oc.white,
 | 
			
		||||
      });
 | 
			
		||||
      svg = await exportToSvg(
 | 
			
		||||
        elements,
 | 
			
		||||
        {
 | 
			
		||||
          exportBackground: false,
 | 
			
		||||
          viewBackgroundColor: oc.white,
 | 
			
		||||
        },
 | 
			
		||||
        null, // files
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      previewNode.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
@@ -78,7 +82,7 @@ export const PasteChartDialog = ({
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  onInsertChart: (elements: LibraryItem) => void;
 | 
			
		||||
  onInsertChart: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    if (onClose) {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/PenModeButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/PenModeButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
			
		||||
import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { ToolButtonSize } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
type PenModeIconProps = {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange?(): void;
 | 
			
		||||
  zenModeEnabled?: boolean;
 | 
			
		||||
  isMobile?: boolean;
 | 
			
		||||
  penDetected: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
 | 
			
		||||
 | 
			
		||||
const ICONS = {
 | 
			
		||||
  CHECKED: (
 | 
			
		||||
    <svg
 | 
			
		||||
      width="205"
 | 
			
		||||
      height="205"
 | 
			
		||||
      viewBox="0 0 205 205"
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
    >
 | 
			
		||||
      <path d="m35 195-25-29.17V50h50v115l-25 30" />
 | 
			
		||||
      <path d="M10 40V10h50v30H10" />
 | 
			
		||||
      <path d="M125 145h70v50h-70" />
 | 
			
		||||
      <path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" />
 | 
			
		||||
    </svg>
 | 
			
		||||
  ),
 | 
			
		||||
  UNCHECKED: (
 | 
			
		||||
    <svg
 | 
			
		||||
      width="205"
 | 
			
		||||
      height="205"
 | 
			
		||||
      viewBox="0 0 205 205"
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      className="unlocked-icon rtl-mirror"
 | 
			
		||||
    >
 | 
			
		||||
      <path d="m35 195-25-29.17V50h50v115l-25 30" />
 | 
			
		||||
      <path d="M10 40V10h50v30H10" />
 | 
			
		||||
      <path d="M125 145h70v50h-70" />
 | 
			
		||||
      <path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" />
 | 
			
		||||
    </svg>
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const PenModeButton = (props: PenModeIconProps) => {
 | 
			
		||||
  if (!props.penDetected) {
 | 
			
		||||
    if (props.isMobile) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <label
 | 
			
		||||
        className={clsx(
 | 
			
		||||
          "ToolIcon ToolIcon__penMode ToolIcon_type_floating",
 | 
			
		||||
          `ToolIcon_size_${DEFAULT_SIZE}`,
 | 
			
		||||
          {
 | 
			
		||||
            "is-mobile": props.isMobile,
 | 
			
		||||
          },
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        <div className="ToolIcon__icon ToolIcon__hidden" />
 | 
			
		||||
      </label>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon__penMode ToolIcon_type_floating",
 | 
			
		||||
        `ToolIcon_size_${DEFAULT_SIZE}`,
 | 
			
		||||
        {
 | 
			
		||||
          "is-mobile": props.isMobile,
 | 
			
		||||
        },
 | 
			
		||||
      )}
 | 
			
		||||
      title={`${props.title}`}
 | 
			
		||||
    >
 | 
			
		||||
      <input
 | 
			
		||||
        className="ToolIcon_type_checkbox"
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        name={props.name}
 | 
			
		||||
        onChange={props.onChange}
 | 
			
		||||
        checked={props.checked}
 | 
			
		||||
        aria-label={props.title}
 | 
			
		||||
      />
 | 
			
		||||
      <div className="ToolIcon__icon">
 | 
			
		||||
        {props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
 | 
			
		||||
      </div>
 | 
			
		||||
    </label>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -8,6 +8,10 @@ type Props = {
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
  onCloseRequest?(event: PointerEvent): void;
 | 
			
		||||
  fitInViewport?: boolean;
 | 
			
		||||
  offsetLeft?: number;
 | 
			
		||||
  offsetTop?: number;
 | 
			
		||||
  viewportWidth?: number;
 | 
			
		||||
  viewportHeight?: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Popover = ({
 | 
			
		||||
@@ -16,6 +20,10 @@ export const Popover = ({
 | 
			
		||||
  top,
 | 
			
		||||
  onCloseRequest,
 | 
			
		||||
  fitInViewport = false,
 | 
			
		||||
  offsetLeft = 0,
 | 
			
		||||
  offsetTop = 0,
 | 
			
		||||
  viewportWidth = window.innerWidth,
 | 
			
		||||
  viewportHeight = window.innerHeight,
 | 
			
		||||
}: Props) => {
 | 
			
		||||
  const popoverRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
@@ -24,17 +32,14 @@ export const Popover = ({
 | 
			
		||||
    if (fitInViewport && popoverRef.current) {
 | 
			
		||||
      const element = popoverRef.current;
 | 
			
		||||
      const { x, y, width, height } = element.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
      const viewportWidth = window.innerWidth;
 | 
			
		||||
      if (x + width > viewportWidth) {
 | 
			
		||||
      if (x + width - offsetLeft > viewportWidth) {
 | 
			
		||||
        element.style.left = `${viewportWidth - width}px`;
 | 
			
		||||
      }
 | 
			
		||||
      const viewportHeight = window.innerHeight;
 | 
			
		||||
      if (y + height > viewportHeight) {
 | 
			
		||||
      if (y + height - offsetTop > viewportHeight) {
 | 
			
		||||
        element.style.top = `${viewportHeight - height}px`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, [fitInViewport]);
 | 
			
		||||
  }, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (onCloseRequest) {
 | 
			
		||||
 
 | 
			
		||||
@@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
 | 
			
		||||
      </label>
 | 
			
		||||
      {props.isNameEditable ? (
 | 
			
		||||
        <input
 | 
			
		||||
          type="text"
 | 
			
		||||
          className="TextInput"
 | 
			
		||||
          onBlur={handleBlur}
 | 
			
		||||
          onKeyDown={handleKeyDown}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										92
									
								
								src/components/PublishLibrary.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/PublishLibrary.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .publish-library {
 | 
			
		||||
    &__fields {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
 | 
			
		||||
      label {
 | 
			
		||||
        padding: 1em;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        justify-content: space-between;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        span {
 | 
			
		||||
          font-weight: 500;
 | 
			
		||||
          font-size: 1rem;
 | 
			
		||||
          color: $oc-gray-6;
 | 
			
		||||
        }
 | 
			
		||||
        input,
 | 
			
		||||
        textarea {
 | 
			
		||||
          width: 70%;
 | 
			
		||||
          padding: 0.6em;
 | 
			
		||||
          font-family: var(--ui-font);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .required {
 | 
			
		||||
          color: $oc-red-8;
 | 
			
		||||
          margin: 0.2rem;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__buttons {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      padding: 0.2rem 0;
 | 
			
		||||
      justify-content: flex-end;
 | 
			
		||||
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        min-width: 2.5rem;
 | 
			
		||||
        width: auto;
 | 
			
		||||
        font-size: 1rem;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ToolIcon_type_button {
 | 
			
		||||
        margin-left: 1rem;
 | 
			
		||||
        padding: 0 0.5rem;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--confirm.ToolIcon_type_button {
 | 
			
		||||
        background-color: $oc-blue-6;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-blue-8;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--cancel.ToolIcon_type_button {
 | 
			
		||||
        background-color: $oc-gray-5;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-gray-6;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: #fff;
 | 
			
		||||
          svg {
 | 
			
		||||
            padding: 0.5rem;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .selected-library-items {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      padding: 0 0.8rem;
 | 
			
		||||
      flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
      .single-library-item-wrapper {
 | 
			
		||||
        width: 9rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-note {
 | 
			
		||||
      padding: 1em;
 | 
			
		||||
      font-style: italic;
 | 
			
		||||
      font-size: 14px;
 | 
			
		||||
      display: block;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										455
									
								
								src/components/PublishLibrary.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										455
									
								
								src/components/PublishLibrary.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,455 @@
 | 
			
		||||
import { ReactNode, useCallback, useEffect, useState } from "react";
 | 
			
		||||
import OpenColor from "open-color";
 | 
			
		||||
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
import { AppState, LibraryItems, LibraryItem } from "../types";
 | 
			
		||||
import { exportToCanvas } from "../packages/utils";
 | 
			
		||||
import {
 | 
			
		||||
  EXPORT_DATA_TYPES,
 | 
			
		||||
  EXPORT_SOURCE,
 | 
			
		||||
  MIME_TYPES,
 | 
			
		||||
  VERSIONS,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { ExportedLibraryData } from "../data/types";
 | 
			
		||||
 | 
			
		||||
import "./PublishLibrary.scss";
 | 
			
		||||
import SingleLibraryItem from "./SingleLibraryItem";
 | 
			
		||||
import { canvasToBlob, resizeImageFile } from "../data/blob";
 | 
			
		||||
import { chunk } from "../utils";
 | 
			
		||||
 | 
			
		||||
interface PublishLibraryDataParams {
 | 
			
		||||
  authorName: string;
 | 
			
		||||
  githubHandle: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  twitterHandle: string;
 | 
			
		||||
  website: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
 | 
			
		||||
 | 
			
		||||
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
 | 
			
		||||
  try {
 | 
			
		||||
    localStorage.setItem(
 | 
			
		||||
      LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
 | 
			
		||||
      JSON.stringify(data),
 | 
			
		||||
    );
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // Unable to access window.localStorage
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const importPublishLibDataFromStorage = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
 | 
			
		||||
    if (data) {
 | 
			
		||||
      return JSON.parse(data);
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // Unable to access localStorage
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const generatePreviewImage = async (libraryItems: LibraryItems) => {
 | 
			
		||||
  const MAX_ITEMS_PER_ROW = 6;
 | 
			
		||||
  const BOX_SIZE = 128;
 | 
			
		||||
  const BOX_PADDING = Math.round(BOX_SIZE / 16);
 | 
			
		||||
  const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
 | 
			
		||||
 | 
			
		||||
  const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
 | 
			
		||||
 | 
			
		||||
  const canvas = document.createElement("canvas");
 | 
			
		||||
 | 
			
		||||
  canvas.width =
 | 
			
		||||
    rows[0].length * BOX_SIZE +
 | 
			
		||||
    (rows[0].length + 1) * (BOX_PADDING * 2) -
 | 
			
		||||
    BOX_PADDING * 2;
 | 
			
		||||
  canvas.height =
 | 
			
		||||
    rows.length * BOX_SIZE +
 | 
			
		||||
    (rows.length + 1) * (BOX_PADDING * 2) -
 | 
			
		||||
    BOX_PADDING * 2;
 | 
			
		||||
 | 
			
		||||
  const ctx = canvas.getContext("2d")!;
 | 
			
		||||
 | 
			
		||||
  ctx.fillStyle = OpenColor.white;
 | 
			
		||||
  ctx.fillRect(0, 0, canvas.width, canvas.height);
 | 
			
		||||
 | 
			
		||||
  // draw items
 | 
			
		||||
  // ---------------------------------------------------------------------------
 | 
			
		||||
  for (const [index, item] of libraryItems.entries()) {
 | 
			
		||||
    const itemCanvas = await exportToCanvas({
 | 
			
		||||
      elements: item.elements,
 | 
			
		||||
      files: null,
 | 
			
		||||
      maxWidthOrHeight: BOX_SIZE,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const { width, height } = itemCanvas;
 | 
			
		||||
 | 
			
		||||
    // draw item
 | 
			
		||||
    // -------------------------------------------------------------------------
 | 
			
		||||
    const rowOffset =
 | 
			
		||||
      Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
 | 
			
		||||
    const colOffset =
 | 
			
		||||
      (index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
 | 
			
		||||
 | 
			
		||||
    ctx.drawImage(
 | 
			
		||||
      itemCanvas,
 | 
			
		||||
      colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
 | 
			
		||||
      rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // draw item border
 | 
			
		||||
    // -------------------------------------------------------------------------
 | 
			
		||||
    ctx.lineWidth = BORDER_WIDTH;
 | 
			
		||||
    ctx.strokeStyle = OpenColor.gray[4];
 | 
			
		||||
    ctx.strokeRect(
 | 
			
		||||
      colOffset + BOX_PADDING / 2,
 | 
			
		||||
      rowOffset + BOX_PADDING / 2,
 | 
			
		||||
      BOX_SIZE + BOX_PADDING,
 | 
			
		||||
      BOX_SIZE + BOX_PADDING,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return await resizeImageFile(
 | 
			
		||||
    new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
 | 
			
		||||
    {
 | 
			
		||||
      outputType: MIME_TYPES.jpg,
 | 
			
		||||
      maxWidthOrHeight: 5000,
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const PublishLibrary = ({
 | 
			
		||||
  onClose,
 | 
			
		||||
  libraryItems,
 | 
			
		||||
  appState,
 | 
			
		||||
  onSuccess,
 | 
			
		||||
  onError,
 | 
			
		||||
  updateItemsInStorage,
 | 
			
		||||
  onRemove,
 | 
			
		||||
}: {
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  onSuccess: (data: {
 | 
			
		||||
    url: string;
 | 
			
		||||
    authorName: string;
 | 
			
		||||
    items: LibraryItems;
 | 
			
		||||
  }) => void;
 | 
			
		||||
 | 
			
		||||
  onError: (error: Error) => void;
 | 
			
		||||
  updateItemsInStorage: (items: LibraryItems) => void;
 | 
			
		||||
  onRemove: (id: string) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
 | 
			
		||||
    authorName: "",
 | 
			
		||||
    githubHandle: "",
 | 
			
		||||
    name: "",
 | 
			
		||||
    description: "",
 | 
			
		||||
    twitterHandle: "",
 | 
			
		||||
    website: "",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const [isSubmitting, setIsSubmitting] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const data = importPublishLibDataFromStorage();
 | 
			
		||||
    if (data) {
 | 
			
		||||
      setLibraryData(data);
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
 | 
			
		||||
    libraryItems.slice(),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setClonedLibItems(libraryItems.slice());
 | 
			
		||||
  }, [libraryItems]);
 | 
			
		||||
 | 
			
		||||
  const onInputChange = (event: any) => {
 | 
			
		||||
    setLibraryData({
 | 
			
		||||
      ...libraryData,
 | 
			
		||||
      [event.target.name]: event.target.value,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    setIsSubmitting(true);
 | 
			
		||||
    const erroredLibItems: LibraryItem[] = [];
 | 
			
		||||
    let isError = false;
 | 
			
		||||
    clonedLibItems.forEach((libItem) => {
 | 
			
		||||
      let error = "";
 | 
			
		||||
      if (!libItem.name) {
 | 
			
		||||
        error = t("publishDialog.errors.required");
 | 
			
		||||
        isError = true;
 | 
			
		||||
      }
 | 
			
		||||
      erroredLibItems.push({ ...libItem, error });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (isError) {
 | 
			
		||||
      setClonedLibItems(erroredLibItems);
 | 
			
		||||
      setIsSubmitting(false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const previewImage = await generatePreviewImage(clonedLibItems);
 | 
			
		||||
 | 
			
		||||
    const libContent: ExportedLibraryData = {
 | 
			
		||||
      type: EXPORT_DATA_TYPES.excalidrawLibrary,
 | 
			
		||||
      version: VERSIONS.excalidrawLibrary,
 | 
			
		||||
      source: EXPORT_SOURCE,
 | 
			
		||||
      libraryItems: clonedLibItems,
 | 
			
		||||
    };
 | 
			
		||||
    const content = JSON.stringify(libContent, null, 2);
 | 
			
		||||
    const lib = new Blob([content], { type: "application/json" });
 | 
			
		||||
 | 
			
		||||
    const formData = new FormData();
 | 
			
		||||
    formData.append("excalidrawLib", lib);
 | 
			
		||||
    formData.append("previewImage", previewImage);
 | 
			
		||||
    formData.append("previewImageType", previewImage.type);
 | 
			
		||||
    formData.append("title", libraryData.name);
 | 
			
		||||
    formData.append("authorName", libraryData.authorName);
 | 
			
		||||
    formData.append("githubHandle", libraryData.githubHandle);
 | 
			
		||||
    formData.append("name", libraryData.name);
 | 
			
		||||
    formData.append("description", libraryData.description);
 | 
			
		||||
    formData.append("twitterHandle", libraryData.twitterHandle);
 | 
			
		||||
    formData.append("website", libraryData.website);
 | 
			
		||||
 | 
			
		||||
    fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
 | 
			
		||||
      method: "post",
 | 
			
		||||
      body: formData,
 | 
			
		||||
    })
 | 
			
		||||
      .then(
 | 
			
		||||
        (response) => {
 | 
			
		||||
          if (response.ok) {
 | 
			
		||||
            return response.json().then(({ url }) => {
 | 
			
		||||
              // flush data from local storage
 | 
			
		||||
              localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
 | 
			
		||||
              onSuccess({
 | 
			
		||||
                url,
 | 
			
		||||
                authorName: libraryData.authorName,
 | 
			
		||||
                items: clonedLibItems,
 | 
			
		||||
              });
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
          return response
 | 
			
		||||
            .json()
 | 
			
		||||
            .catch(() => {
 | 
			
		||||
              throw new Error(response.statusText || "something went wrong");
 | 
			
		||||
            })
 | 
			
		||||
            .then((error) => {
 | 
			
		||||
              throw new Error(
 | 
			
		||||
                error.message || response.statusText || "something went wrong",
 | 
			
		||||
              );
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        (err) => {
 | 
			
		||||
          console.error(err);
 | 
			
		||||
          onError(err);
 | 
			
		||||
          setIsSubmitting(false);
 | 
			
		||||
        },
 | 
			
		||||
      )
 | 
			
		||||
      .catch((err) => {
 | 
			
		||||
        console.error(err);
 | 
			
		||||
        onError(err);
 | 
			
		||||
        setIsSubmitting(false);
 | 
			
		||||
      });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderLibraryItems = () => {
 | 
			
		||||
    const items: ReactNode[] = [];
 | 
			
		||||
    clonedLibItems.forEach((libItem, index) => {
 | 
			
		||||
      items.push(
 | 
			
		||||
        <div className="single-library-item-wrapper" key={index}>
 | 
			
		||||
          <SingleLibraryItem
 | 
			
		||||
            libItem={libItem}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            index={index}
 | 
			
		||||
            onChange={(val, index) => {
 | 
			
		||||
              const items = clonedLibItems.slice();
 | 
			
		||||
              items[index].name = val;
 | 
			
		||||
              setClonedLibItems(items);
 | 
			
		||||
            }}
 | 
			
		||||
            onRemove={onRemove}
 | 
			
		||||
          />
 | 
			
		||||
        </div>,
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
    return <div className="selected-library-items">{items}</div>;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onDialogClose = useCallback(() => {
 | 
			
		||||
    updateItemsInStorage(clonedLibItems);
 | 
			
		||||
    savePublishLibDataToStorage(libraryData);
 | 
			
		||||
    onClose();
 | 
			
		||||
  }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
 | 
			
		||||
 | 
			
		||||
  const shouldRenderForm = !!libraryItems.length;
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog
 | 
			
		||||
      onCloseRequest={onDialogClose}
 | 
			
		||||
      title={t("publishDialog.title")}
 | 
			
		||||
      className="publish-library"
 | 
			
		||||
    >
 | 
			
		||||
      {shouldRenderForm ? (
 | 
			
		||||
        <form onSubmit={onSubmit}>
 | 
			
		||||
          <div className="publish-library-note">
 | 
			
		||||
            {t("publishDialog.noteDescription.pre")}
 | 
			
		||||
            <a
 | 
			
		||||
              href="https://libraries.excalidraw.com"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              {t("publishDialog.noteDescription.link")}
 | 
			
		||||
            </a>{" "}
 | 
			
		||||
            {t("publishDialog.noteDescription.post")}
 | 
			
		||||
          </div>
 | 
			
		||||
          <span className="publish-library-note">
 | 
			
		||||
            {t("publishDialog.noteGuidelines.pre")}
 | 
			
		||||
            <a
 | 
			
		||||
              href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              {t("publishDialog.noteGuidelines.link")}
 | 
			
		||||
            </a>
 | 
			
		||||
            {t("publishDialog.noteGuidelines.post")}
 | 
			
		||||
          </span>
 | 
			
		||||
 | 
			
		||||
          <div className="publish-library-note">
 | 
			
		||||
            {t("publishDialog.noteItems")}
 | 
			
		||||
          </div>
 | 
			
		||||
          {renderLibraryItems()}
 | 
			
		||||
          <div className="publish-library__fields">
 | 
			
		||||
            <label>
 | 
			
		||||
              <div>
 | 
			
		||||
                <span>{t("publishDialog.libraryName")}</span>
 | 
			
		||||
                <span aria-hidden="true" className="required">
 | 
			
		||||
                  *
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <input
 | 
			
		||||
                type="text"
 | 
			
		||||
                name="name"
 | 
			
		||||
                required
 | 
			
		||||
                value={libraryData.name}
 | 
			
		||||
                onChange={onInputChange}
 | 
			
		||||
                placeholder={t("publishDialog.placeholder.libraryName")}
 | 
			
		||||
              />
 | 
			
		||||
            </label>
 | 
			
		||||
            <label style={{ alignItems: "flex-start" }}>
 | 
			
		||||
              <div>
 | 
			
		||||
                <span>{t("publishDialog.libraryDesc")}</span>
 | 
			
		||||
                <span aria-hidden="true" className="required">
 | 
			
		||||
                  *
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <textarea
 | 
			
		||||
                name="description"
 | 
			
		||||
                rows={4}
 | 
			
		||||
                required
 | 
			
		||||
                value={libraryData.description}
 | 
			
		||||
                onChange={onInputChange}
 | 
			
		||||
                placeholder={t("publishDialog.placeholder.libraryDesc")}
 | 
			
		||||
              />
 | 
			
		||||
            </label>
 | 
			
		||||
            <label>
 | 
			
		||||
              <div>
 | 
			
		||||
                <span>{t("publishDialog.authorName")}</span>
 | 
			
		||||
                <span aria-hidden="true" className="required">
 | 
			
		||||
                  *
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <input
 | 
			
		||||
                type="text"
 | 
			
		||||
                name="authorName"
 | 
			
		||||
                required
 | 
			
		||||
                value={libraryData.authorName}
 | 
			
		||||
                onChange={onInputChange}
 | 
			
		||||
                placeholder={t("publishDialog.placeholder.authorName")}
 | 
			
		||||
              />
 | 
			
		||||
            </label>
 | 
			
		||||
            <label>
 | 
			
		||||
              <span>{t("publishDialog.githubUsername")}</span>
 | 
			
		||||
              <input
 | 
			
		||||
                type="text"
 | 
			
		||||
                name="githubHandle"
 | 
			
		||||
                value={libraryData.githubHandle}
 | 
			
		||||
                onChange={onInputChange}
 | 
			
		||||
                placeholder={t("publishDialog.placeholder.githubHandle")}
 | 
			
		||||
              />
 | 
			
		||||
            </label>
 | 
			
		||||
            <label>
 | 
			
		||||
              <span>{t("publishDialog.twitterUsername")}</span>
 | 
			
		||||
              <input
 | 
			
		||||
                type="text"
 | 
			
		||||
                name="twitterHandle"
 | 
			
		||||
                value={libraryData.twitterHandle}
 | 
			
		||||
                onChange={onInputChange}
 | 
			
		||||
                placeholder={t("publishDialog.placeholder.twitterHandle")}
 | 
			
		||||
              />
 | 
			
		||||
            </label>
 | 
			
		||||
            <label>
 | 
			
		||||
              <span>{t("publishDialog.website")}</span>
 | 
			
		||||
              <input
 | 
			
		||||
                type="text"
 | 
			
		||||
                name="website"
 | 
			
		||||
                pattern="https?://.+"
 | 
			
		||||
                title={t("publishDialog.errors.website")}
 | 
			
		||||
                value={libraryData.website}
 | 
			
		||||
                onChange={onInputChange}
 | 
			
		||||
                placeholder={t("publishDialog.placeholder.website")}
 | 
			
		||||
              />
 | 
			
		||||
            </label>
 | 
			
		||||
            <span className="publish-library-note">
 | 
			
		||||
              {t("publishDialog.noteLicense.pre")}
 | 
			
		||||
              <a
 | 
			
		||||
                href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
 | 
			
		||||
                target="_blank"
 | 
			
		||||
                rel="noopener noreferrer"
 | 
			
		||||
              >
 | 
			
		||||
                {t("publishDialog.noteLicense.link")}
 | 
			
		||||
              </a>
 | 
			
		||||
              {t("publishDialog.noteLicense.post")}
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="publish-library__buttons">
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("buttons.cancel")}
 | 
			
		||||
              aria-label={t("buttons.cancel")}
 | 
			
		||||
              label={t("buttons.cancel")}
 | 
			
		||||
              onClick={onDialogClose}
 | 
			
		||||
              data-testid="cancel-clear-canvas-button"
 | 
			
		||||
              className="publish-library__buttons--cancel"
 | 
			
		||||
            />
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              type="submit"
 | 
			
		||||
              title={t("buttons.submit")}
 | 
			
		||||
              aria-label={t("buttons.submit")}
 | 
			
		||||
              label={t("buttons.submit")}
 | 
			
		||||
              className="publish-library__buttons--confirm"
 | 
			
		||||
              isLoading={isSubmitting}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
 | 
			
		||||
          {t("publishDialog.atleastOneLibItem")}
 | 
			
		||||
        </p>
 | 
			
		||||
      )}
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default PublishLibrary;
 | 
			
		||||
							
								
								
									
										66
									
								
								src/components/SingleLibraryItem.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/components/SingleLibraryItem.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .single-library-item {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    &__svg {
 | 
			
		||||
      width: 7.5rem;
 | 
			
		||||
      height: 7.5rem;
 | 
			
		||||
      border: 1px solid var(--button-gray-2);
 | 
			
		||||
      margin: 0.3rem;
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon__icon {
 | 
			
		||||
      background-color: $oc-white;
 | 
			
		||||
      width: auto;
 | 
			
		||||
      height: auto;
 | 
			
		||||
      margin: 0 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
    .ToolIcon,
 | 
			
		||||
    .ToolIcon_type_button:hover {
 | 
			
		||||
      background-color: white;
 | 
			
		||||
    }
 | 
			
		||||
    .required,
 | 
			
		||||
    .error {
 | 
			
		||||
      color: $oc-red-8;
 | 
			
		||||
      font-weight: bold;
 | 
			
		||||
      font-size: 1rem;
 | 
			
		||||
      margin: 0.2rem;
 | 
			
		||||
    }
 | 
			
		||||
    .error {
 | 
			
		||||
      font-weight: 500;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      padding: 0.3em 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--remove {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: 0.2rem;
 | 
			
		||||
      right: 1.3rem;
 | 
			
		||||
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
      }
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        background-color: $oc-red-6;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-red-7;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-red-8;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      svg {
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
        padding: 0.26rem;
 | 
			
		||||
        border-radius: 0.3em;
 | 
			
		||||
        width: 1rem;
 | 
			
		||||
        height: 1rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										99
									
								
								src/components/SingleLibraryItem.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/components/SingleLibraryItem.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import { useEffect, useRef } from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { exportToSvg } from "../packages/utils";
 | 
			
		||||
import { AppState, LibraryItem } from "../types";
 | 
			
		||||
import { close } from "./icons";
 | 
			
		||||
 | 
			
		||||
import "./SingleLibraryItem.scss";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
const SingleLibraryItem = ({
 | 
			
		||||
  libItem,
 | 
			
		||||
  appState,
 | 
			
		||||
  index,
 | 
			
		||||
  onChange,
 | 
			
		||||
  onRemove,
 | 
			
		||||
}: {
 | 
			
		||||
  libItem: LibraryItem;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  index: number;
 | 
			
		||||
  onChange: (val: string, index: number) => void;
 | 
			
		||||
  onRemove: (id: string) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const svgRef = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  const inputRef = useRef<HTMLInputElement | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const node = svgRef.current;
 | 
			
		||||
    if (!node) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    (async () => {
 | 
			
		||||
      const svg = await exportToSvg({
 | 
			
		||||
        elements: libItem.elements,
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          viewBackgroundColor: oc.white,
 | 
			
		||||
          exportBackground: true,
 | 
			
		||||
        },
 | 
			
		||||
        files: null,
 | 
			
		||||
      });
 | 
			
		||||
      node.innerHTML = svg.outerHTML;
 | 
			
		||||
    })();
 | 
			
		||||
  }, [libItem.elements, appState]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="single-library-item">
 | 
			
		||||
      <div ref={svgRef} className="single-library-item__svg" />
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        aria-label={t("buttons.remove")}
 | 
			
		||||
        type="button"
 | 
			
		||||
        icon={close}
 | 
			
		||||
        className="single-library-item--remove"
 | 
			
		||||
        onClick={onRemove.bind(null, libItem.id)}
 | 
			
		||||
        title={t("buttons.remove")}
 | 
			
		||||
      />
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          margin: "0.8rem 0.3rem",
 | 
			
		||||
          width: "100%",
 | 
			
		||||
          fontSize: "14px",
 | 
			
		||||
          fontWeight: 500,
 | 
			
		||||
          flexDirection: "column",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <label
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            justifyContent: "space-between",
 | 
			
		||||
            flexDirection: "column",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <div style={{ padding: "0.5em 0" }}>
 | 
			
		||||
            <span style={{ fontWeight: 500, color: oc.gray[6] }}>
 | 
			
		||||
              {t("publishDialog.itemName")}
 | 
			
		||||
            </span>
 | 
			
		||||
            <span aria-hidden="true" className="required">
 | 
			
		||||
              *
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            ref={inputRef}
 | 
			
		||||
            style={{ width: "80%", padding: "0.2rem" }}
 | 
			
		||||
            defaultValue={libItem.name}
 | 
			
		||||
            placeholder="Item name"
 | 
			
		||||
            onChange={(event) => {
 | 
			
		||||
              onChange(event.target.value, index);
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </label>
 | 
			
		||||
        <span className="error">{libItem.error}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default SingleLibraryItem;
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
@@ -2,24 +2,6 @@
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .TextInput {
 | 
			
		||||
    color: var(--text-primary-color);
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    border: 1.5px solid var(--button-gray-1);
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
    padding: 0.75rem;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    border-radius: var(--space-factor);
 | 
			
		||||
    background-color: var(--input-bg-color);
 | 
			
		||||
 | 
			
		||||
    &:not(:focus) {
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: var(--input-hover-bg-color);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,11 @@
 | 
			
		||||
import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
import { AbortError } from "../errors";
 | 
			
		||||
import Spinner from "./Spinner";
 | 
			
		||||
import { PointerType } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export type ToolButtonSize = "small" | "medium";
 | 
			
		||||
 | 
			
		||||
@@ -22,13 +25,19 @@ type ToolButtonBaseProps = {
 | 
			
		||||
  visible?: boolean;
 | 
			
		||||
  selected?: boolean;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  isLoading?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ToolButtonProps =
 | 
			
		||||
  | (ToolButtonBaseProps & {
 | 
			
		||||
      type: "button";
 | 
			
		||||
      children?: React.ReactNode;
 | 
			
		||||
      onClick?(): void;
 | 
			
		||||
      onClick?(event: React.MouseEvent): void;
 | 
			
		||||
    })
 | 
			
		||||
  | (ToolButtonBaseProps & {
 | 
			
		||||
      type: "submit";
 | 
			
		||||
      children?: React.ReactNode;
 | 
			
		||||
      onClick?(event: React.MouseEvent): void;
 | 
			
		||||
    })
 | 
			
		||||
  | (ToolButtonBaseProps & {
 | 
			
		||||
      type: "icon";
 | 
			
		||||
@@ -38,7 +47,7 @@ type ToolButtonProps =
 | 
			
		||||
  | (ToolButtonBaseProps & {
 | 
			
		||||
      type: "radio";
 | 
			
		||||
      checked: boolean;
 | 
			
		||||
      onChange?(): void;
 | 
			
		||||
      onChange?(data: { pointerType: PointerType | null }): void;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
@@ -47,7 +56,48 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
  React.useImperativeHandle(ref, () => innerRef.current);
 | 
			
		||||
  const sizeCn = `ToolIcon_size_${props.size}`;
 | 
			
		||||
 | 
			
		||||
  if (props.type === "button" || props.type === "icon") {
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const isMountedRef = useRef(true);
 | 
			
		||||
 | 
			
		||||
  const onClick = async (event: React.MouseEvent) => {
 | 
			
		||||
    const ret = "onClick" in props && props.onClick?.(event);
 | 
			
		||||
 | 
			
		||||
    if (ret && "then" in ret) {
 | 
			
		||||
      try {
 | 
			
		||||
        setIsLoading(true);
 | 
			
		||||
        await ret;
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        if (!(error instanceof AbortError)) {
 | 
			
		||||
          throw error;
 | 
			
		||||
        } else {
 | 
			
		||||
          console.warn(error);
 | 
			
		||||
        }
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (isMountedRef.current) {
 | 
			
		||||
          setIsLoading(false);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(
 | 
			
		||||
    () => () => {
 | 
			
		||||
      isMountedRef.current = false;
 | 
			
		||||
    },
 | 
			
		||||
    [],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const lastPointerTypeRef = useRef<PointerType | null>(null);
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    props.type === "button" ||
 | 
			
		||||
    props.type === "icon" ||
 | 
			
		||||
    props.type === "submit"
 | 
			
		||||
  ) {
 | 
			
		||||
    const type = (props.type === "icon" ? "button" : props.type) as
 | 
			
		||||
      | "button"
 | 
			
		||||
      | "submit";
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        className={clsx(
 | 
			
		||||
@@ -67,9 +117,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
        hidden={props.hidden}
 | 
			
		||||
        title={props.title}
 | 
			
		||||
        aria-label={props["aria-label"]}
 | 
			
		||||
        type="button"
 | 
			
		||||
        onClick={props.onClick}
 | 
			
		||||
        type={type}
 | 
			
		||||
        onClick={onClick}
 | 
			
		||||
        ref={innerRef}
 | 
			
		||||
        disabled={isLoading || props.isLoading}
 | 
			
		||||
      >
 | 
			
		||||
        {(props.icon || props.label) && (
 | 
			
		||||
          <div className="ToolIcon__icon" aria-hidden="true">
 | 
			
		||||
@@ -79,10 +130,13 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
                {props.keyBindingLabel}
 | 
			
		||||
              </span>
 | 
			
		||||
            )}
 | 
			
		||||
            {props.isLoading && <Spinner />}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {props.showAriaLabel && (
 | 
			
		||||
          <div className="ToolIcon__label">{props["aria-label"]}</div>
 | 
			
		||||
          <div className="ToolIcon__label">
 | 
			
		||||
            {props["aria-label"]} {isLoading && <Spinner />}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </button>
 | 
			
		||||
@@ -90,7 +144,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <label className={clsx("ToolIcon", props.className)} title={props.title}>
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx("ToolIcon", props.className)}
 | 
			
		||||
      title={props.title}
 | 
			
		||||
      onPointerDown={(event) => {
 | 
			
		||||
        lastPointerTypeRef.current = event.pointerType || null;
 | 
			
		||||
      }}
 | 
			
		||||
      onPointerUp={() => {
 | 
			
		||||
        requestAnimationFrame(() => {
 | 
			
		||||
          lastPointerTypeRef.current = null;
 | 
			
		||||
        });
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <input
 | 
			
		||||
        className={`ToolIcon_type_radio ${sizeCn}`}
 | 
			
		||||
        type="radio"
 | 
			
		||||
@@ -99,7 +164,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
        aria-keyshortcuts={props["aria-keyshortcuts"]}
 | 
			
		||||
        data-testid={props["data-testid"]}
 | 
			
		||||
        id={`${excalId}-${props.id}`}
 | 
			
		||||
        onChange={props.onChange}
 | 
			
		||||
        onChange={() => {
 | 
			
		||||
          props.onChange?.({ pointerType: lastPointerTypeRef.current });
 | 
			
		||||
        }}
 | 
			
		||||
        checked={props.checked}
 | 
			
		||||
        ref={innerRef}
 | 
			
		||||
      />
 | 
			
		||||
 
 | 
			
		||||
@@ -6,20 +6,9 @@
 | 
			
		||||
    display: inline-flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    font-family: Cascadia;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    -webkit-tap-highlight-color: transparent;
 | 
			
		||||
    border-radius: var(--space-factor);
 | 
			
		||||
    user-select: none;
 | 
			
		||||
 | 
			
		||||
    background-color: var(--button-gray-1);
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
    }
 | 
			
		||||
    &:active {
 | 
			
		||||
      background-color: var(--button-gray-3);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon--plain {
 | 
			
		||||
@@ -30,6 +19,20 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon_type_radio,
 | 
			
		||||
  .ToolIcon_type_checkbox {
 | 
			
		||||
    & + .ToolIcon__icon {
 | 
			
		||||
      background-color: var(--button-gray-1);
 | 
			
		||||
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: var(--button-gray-2);
 | 
			
		||||
      }
 | 
			
		||||
      &:active {
 | 
			
		||||
        background-color: var(--button-gray-3);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon__icon {
 | 
			
		||||
    width: 2.5rem;
 | 
			
		||||
    height: 2.5rem;
 | 
			
		||||
@@ -39,7 +42,11 @@
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
 | 
			
		||||
    border-radius: var(--space-factor);
 | 
			
		||||
    border-radius: var(--border-radius-lg);
 | 
			
		||||
 | 
			
		||||
    & + .ToolIcon__label {
 | 
			
		||||
      margin-inline-start: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    svg {
 | 
			
		||||
      position: relative;
 | 
			
		||||
@@ -47,17 +54,19 @@
 | 
			
		||||
      fill: var(--icon-fill-color);
 | 
			
		||||
      color: var(--icon-fill-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & + .ToolIcon__label {
 | 
			
		||||
      margin-inline-start: 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon__label {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    color: var(--icon-fill-color);
 | 
			
		||||
    font-family: var(--ui-font);
 | 
			
		||||
    margin: 0 0.8em;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
    .Spinner {
 | 
			
		||||
      margin-left: 0.6em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon_size_small .ToolIcon__icon {
 | 
			
		||||
@@ -74,7 +83,7 @@
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: inherit;
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
    &:focus-visible {
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -116,7 +125,7 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus + .ToolIcon__icon {
 | 
			
		||||
    &:focus-visible + .ToolIcon__icon {
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -136,10 +145,6 @@
 | 
			
		||||
      background-color: transparent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      box-shadow: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon__icon {
 | 
			
		||||
      background-color: var(--button-gray-1);
 | 
			
		||||
      &:hover {
 | 
			
		||||
@@ -154,13 +159,6 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon.ToolIcon__lock {
 | 
			
		||||
    margin-inline-end: var(--space-factor);
 | 
			
		||||
    &.ToolIcon_type_floating {
 | 
			
		||||
      margin-left: 0.1rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon__keybinding {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    bottom: 2px;
 | 
			
		||||
@@ -221,6 +219,10 @@
 | 
			
		||||
      margin-inline-end: 0;
 | 
			
		||||
      top: 60px;
 | 
			
		||||
    }
 | 
			
		||||
    .ToolIcon.ToolIcon__penMode {
 | 
			
		||||
      margin-inline-end: 0;
 | 
			
		||||
      top: 140px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .unlocked-icon {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										118
									
								
								src/components/Toolbar.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/components/Toolbar.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,118 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
 | 
			
		||||
@mixin toolbarButtonColorStates {
 | 
			
		||||
  .ToolIcon_type_radio,
 | 
			
		||||
  .ToolIcon_type_checkbox {
 | 
			
		||||
    & + .ToolIcon__icon:active {
 | 
			
		||||
      background: var(--color-primary-light);
 | 
			
		||||
    }
 | 
			
		||||
    &:checked + .ToolIcon__icon {
 | 
			
		||||
      background: var(--color-primary);
 | 
			
		||||
      --icon-fill-color: #{$oc-white};
 | 
			
		||||
      --keybinding-color: #{$oc-white};
 | 
			
		||||
    }
 | 
			
		||||
    &:checked + .ToolIcon__icon:active {
 | 
			
		||||
      background: var(--color-primary-darker);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon__keybinding {
 | 
			
		||||
    bottom: 4px;
 | 
			
		||||
    right: 4px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .App-toolbar-container {
 | 
			
		||||
    .ToolIcon_type_floating {
 | 
			
		||||
      @include toolbarButtonColorStates;
 | 
			
		||||
 | 
			
		||||
      &:not(.is-mobile) {
 | 
			
		||||
        .ToolIcon__icon {
 | 
			
		||||
          padding: 1px;
 | 
			
		||||
          background-color: var(--island-bg-color);
 | 
			
		||||
          box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
 | 
			
		||||
          border-radius: 50%;
 | 
			
		||||
          transition: box-shadow 0.5s ease, transform 0.5s ease;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ToolIcon_type_radio,
 | 
			
		||||
      .ToolIcon_type_checkbox {
 | 
			
		||||
        &:focus-within + .ToolIcon__icon {
 | 
			
		||||
          // override for custom floating button shadow
 | 
			
		||||
          box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon__hidden {
 | 
			
		||||
      box-shadow: none !important;
 | 
			
		||||
      background-color: transparent !important;
 | 
			
		||||
      pointer-events: none !important;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon.ToolIcon__lock {
 | 
			
		||||
      margin-inline-end: var(--space-factor);
 | 
			
		||||
      &.ToolIcon_type_floating {
 | 
			
		||||
        margin-left: 0.1rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon__library {
 | 
			
		||||
      margin-inline-start: var(--space-factor);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.zen-mode {
 | 
			
		||||
      .ToolIcon_type_floating {
 | 
			
		||||
        .ToolIcon__icon {
 | 
			
		||||
          box-shadow: none;
 | 
			
		||||
          transform: scale(0.9);
 | 
			
		||||
        }
 | 
			
		||||
        .ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
 | 
			
		||||
          & + .ToolIcon__icon {
 | 
			
		||||
            svg {
 | 
			
		||||
              fill: $oc-gray-5;
 | 
			
		||||
              color: $oc-gray-5;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .App-toolbar {
 | 
			
		||||
    border-radius: var(--border-radius-lg);
 | 
			
		||||
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
 | 
			
		||||
 | 
			
		||||
    .ToolIcon {
 | 
			
		||||
      &:hover {
 | 
			
		||||
        --icon-fill-color: var(--color-primary-chubb);
 | 
			
		||||
        --keybinding-color: var(--color-primary-chubb);
 | 
			
		||||
      }
 | 
			
		||||
      &:active {
 | 
			
		||||
        --icon-fill-color: #{$oc-gray-9};
 | 
			
		||||
        --keybinding-color: #{$oc-gray-9};
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        border-radius: var(--border-radius-lg);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      @include toolbarButtonColorStates;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.zen-mode {
 | 
			
		||||
      .ToolIcon__keybinding,
 | 
			
		||||
      .HintViewer {
 | 
			
		||||
        display: none;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.theme--dark .App-toolbar .ToolIcon:active {
 | 
			
		||||
    --icon-fill-color: #{$oc-gray-3};
 | 
			
		||||
    --keybinding-color: #{$oc-gray-3};
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -29,7 +29,6 @@
 | 
			
		||||
// wraps the element we want to apply the tooltip to
 | 
			
		||||
.excalidraw-tooltip-wrapper {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.excalidraw-tooltip-icon {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import "./Tooltip.scss";
 | 
			
		||||
 | 
			
		||||
import React, { useEffect } from "react";
 | 
			
		||||
 | 
			
		||||
const getTooltipDiv = () => {
 | 
			
		||||
export const getTooltipDiv = () => {
 | 
			
		||||
  const existingDiv = document.querySelector<HTMLDivElement>(
 | 
			
		||||
    ".excalidraw-tooltip",
 | 
			
		||||
  );
 | 
			
		||||
@@ -15,6 +15,50 @@ const getTooltipDiv = () => {
 | 
			
		||||
  return div;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateTooltipPosition = (
 | 
			
		||||
  tooltip: HTMLDivElement,
 | 
			
		||||
  item: {
 | 
			
		||||
    left: number;
 | 
			
		||||
    top: number;
 | 
			
		||||
    width: number;
 | 
			
		||||
    height: number;
 | 
			
		||||
  },
 | 
			
		||||
  position: "bottom" | "top" = "bottom",
 | 
			
		||||
) => {
 | 
			
		||||
  const tooltipRect = tooltip.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
  const viewportWidth = window.innerWidth;
 | 
			
		||||
  const viewportHeight = window.innerHeight;
 | 
			
		||||
 | 
			
		||||
  const margin = 5;
 | 
			
		||||
 | 
			
		||||
  let left = item.left + item.width / 2 - tooltipRect.width / 2;
 | 
			
		||||
  if (left < 0) {
 | 
			
		||||
    left = margin;
 | 
			
		||||
  } else if (left + tooltipRect.width >= viewportWidth) {
 | 
			
		||||
    left = viewportWidth - tooltipRect.width - margin;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let top: number;
 | 
			
		||||
 | 
			
		||||
  if (position === "bottom") {
 | 
			
		||||
    top = item.top + item.height + margin;
 | 
			
		||||
    if (top + tooltipRect.height >= viewportHeight) {
 | 
			
		||||
      top = item.top - tooltipRect.height - margin;
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    top = item.top - tooltipRect.height - margin;
 | 
			
		||||
    if (top < 0) {
 | 
			
		||||
      top = item.top + item.height + margin;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Object.assign(tooltip.style, {
 | 
			
		||||
    top: `${top}px`,
 | 
			
		||||
    left: `${left}px`,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateTooltip = (
 | 
			
		||||
  item: HTMLDivElement,
 | 
			
		||||
  tooltip: HTMLDivElement,
 | 
			
		||||
@@ -27,51 +71,27 @@ const updateTooltip = (
 | 
			
		||||
 | 
			
		||||
  tooltip.textContent = label;
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    x: itemX,
 | 
			
		||||
    bottom: itemBottom,
 | 
			
		||||
    top: itemTop,
 | 
			
		||||
    width: itemWidth,
 | 
			
		||||
  } = item.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    width: labelWidth,
 | 
			
		||||
    height: labelHeight,
 | 
			
		||||
  } = tooltip.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
  const viewportWidth = window.innerWidth;
 | 
			
		||||
  const viewportHeight = window.innerHeight;
 | 
			
		||||
 | 
			
		||||
  const margin = 5;
 | 
			
		||||
 | 
			
		||||
  const left = itemX + itemWidth / 2 - labelWidth / 2;
 | 
			
		||||
  const offsetLeft =
 | 
			
		||||
    left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
 | 
			
		||||
 | 
			
		||||
  const top = itemBottom + margin;
 | 
			
		||||
  const offsetTop =
 | 
			
		||||
    top + labelHeight >= viewportHeight
 | 
			
		||||
      ? itemBottom - itemTop + labelHeight + margin * 2
 | 
			
		||||
      : 0;
 | 
			
		||||
 | 
			
		||||
  Object.assign(tooltip.style, {
 | 
			
		||||
    top: `${top - offsetTop}px`,
 | 
			
		||||
    left: `${left - offsetLeft}px`,
 | 
			
		||||
  });
 | 
			
		||||
  const itemRect = item.getBoundingClientRect();
 | 
			
		||||
  updateTooltipPosition(tooltip, itemRect);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TooltipProps = {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  label: string;
 | 
			
		||||
  long?: boolean;
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
 | 
			
		||||
export const Tooltip = ({
 | 
			
		||||
  children,
 | 
			
		||||
  label,
 | 
			
		||||
  long = false,
 | 
			
		||||
  style,
 | 
			
		||||
}: TooltipProps) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    return () =>
 | 
			
		||||
      getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className="excalidraw-tooltip-wrapper"
 | 
			
		||||
@@ -86,6 +106,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
 | 
			
		||||
      onPointerLeave={() =>
 | 
			
		||||
        getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
 | 
			
		||||
      }
 | 
			
		||||
      style={style}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component<
 | 
			
		||||
    for (const [key, value] of Object.entries({ ...localStorage })) {
 | 
			
		||||
      try {
 | 
			
		||||
        _localStorage[key] = JSON.parse(value);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        _localStorage[key] = value;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component<
 | 
			
		||||
        )
 | 
			
		||||
      ).default;
 | 
			
		||||
      body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component<
 | 
			
		||||
                try {
 | 
			
		||||
                  localStorage.clear();
 | 
			
		||||
                  window.location.reload();
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                } catch (error: any) {
 | 
			
		||||
                  console.error(error);
 | 
			
		||||
                }
 | 
			
		||||
              }}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,10 @@
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    justify-content: flex-end;
 | 
			
		||||
 | 
			
		||||
    &:empty {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .UserList > * {
 | 
			
		||||
 
 | 
			
		||||
@@ -15,8 +15,9 @@ import { THEME } from "../constants";
 | 
			
		||||
 | 
			
		||||
const activeElementColor = (theme: Theme) =>
 | 
			
		||||
  theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
 | 
			
		||||
const iconFillColor = (theme: Theme) =>
 | 
			
		||||
  theme === THEME.LIGHT ? oc.black : oc.gray[4];
 | 
			
		||||
 | 
			
		||||
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
 | 
			
		||||
 | 
			
		||||
const handlerColor = (theme: Theme) =>
 | 
			
		||||
  theme === THEME.LIGHT ? oc.white : "#1e1e1e";
 | 
			
		||||
 | 
			
		||||
@@ -30,8 +31,12 @@ export const createIcon = (
 | 
			
		||||
  d: string | React.ReactNode,
 | 
			
		||||
  opts: number | Opts = 512,
 | 
			
		||||
) => {
 | 
			
		||||
  const { width = 512, height = width, mirror, style } =
 | 
			
		||||
    typeof opts === "number" ? ({ width: opts } as Opts) : opts;
 | 
			
		||||
  const {
 | 
			
		||||
    width = 512,
 | 
			
		||||
    height = width,
 | 
			
		||||
    mirror,
 | 
			
		||||
    style,
 | 
			
		||||
  } = typeof opts === "number" ? ({ width: opts } as Opts) : opts;
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      aria-hidden="true"
 | 
			
		||||
@@ -81,6 +86,7 @@ export const clipboard = createIcon(
 | 
			
		||||
 | 
			
		||||
export const trash = createIcon(
 | 
			
		||||
  "M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
 | 
			
		||||
 | 
			
		||||
  { width: 448, height: 512 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@@ -752,6 +758,21 @@ export const ArrowheadBarIcon = React.memo(
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const ArrowheadTriangleIcon = React.memo(
 | 
			
		||||
  ({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <g
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
 | 
			
		||||
      >
 | 
			
		||||
        <path d="M32 10L6 10" strokeWidth={2} />
 | 
			
		||||
        <path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
 | 
			
		||||
      </g>,
 | 
			
		||||
      { width: 40, height: 20 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
 | 
			
		||||
  createIcon(
 | 
			
		||||
    <path
 | 
			
		||||
@@ -863,3 +884,19 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
 | 
			
		||||
    { width: 448, height: 512 },
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const publishIcon = createIcon(
 | 
			
		||||
  <path
 | 
			
		||||
    d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
 | 
			
		||||
    fill="currentColor"
 | 
			
		||||
  />,
 | 
			
		||||
  { width: 640, height: 512 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const editIcon = createIcon(
 | 
			
		||||
  <path
 | 
			
		||||
    fill="currentColor"
 | 
			
		||||
    d="M402.3 344.9l32-32c5-5 13.7-1.5 13.7 5.7V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h273.5c7.1 0 10.7 8.6 5.7 13.7l-32 32c-1.5 1.5-3.5 2.3-5.7 2.3H48v352h352V350.5c0-2.1.8-4.1 2.3-5.6zm156.6-201.8L296.3 405.7l-90.4 10c-26.2 2.9-48.5-19.2-45.6-45.6l10-90.4L432.9 17.1c22.9-22.9 59.9-22.9 82.7 0l43.2 43.2c22.9 22.9 22.9 60 .1 82.8zM460.1 174L402 115.9 216.2 301.8l-7.3 65.3 65.3-7.3L460.1 174zm64.8-79.7l-43.2-43.2c-4.1-4.1-10.8-4.1-14.8 0L436 82l58.1 58.1 30.9-30.9c4-4.2 4-10.8-.1-14.9z"
 | 
			
		||||
  ></path>,
 | 
			
		||||
  { width: 640, height: 512 },
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,7 @@ export const POINTER_BUTTON = {
 | 
			
		||||
  WHEEL: 1,
 | 
			
		||||
  SECONDARY: 2,
 | 
			
		||||
  TOUCH: -1,
 | 
			
		||||
};
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export enum EVENT {
 | 
			
		||||
  COPY = "copy",
 | 
			
		||||
@@ -52,6 +52,8 @@ export enum EVENT {
 | 
			
		||||
  HASHCHANGE = "hashchange",
 | 
			
		||||
  VISIBILITY_CHANGE = "visibilitychange",
 | 
			
		||||
  SCROLL = "scroll",
 | 
			
		||||
  // custom events
 | 
			
		||||
  EXCALIDRAW_LINK = "excalidraw-link",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ENV = {
 | 
			
		||||
@@ -90,6 +92,12 @@ export const GRID_SIZE = 20; // TODO make it configurable?
 | 
			
		||||
export const MIME_TYPES = {
 | 
			
		||||
  excalidraw: "application/vnd.excalidraw+json",
 | 
			
		||||
  excalidrawlib: "application/vnd.excalidrawlib+json",
 | 
			
		||||
  json: "application/json",
 | 
			
		||||
  svg: "image/svg+xml",
 | 
			
		||||
  png: "image/png",
 | 
			
		||||
  jpg: "image/jpeg",
 | 
			
		||||
  gif: "image/gif",
 | 
			
		||||
  binary: "application/octet-stream",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export const EXPORT_DATA_TYPES = {
 | 
			
		||||
@@ -100,11 +108,8 @@ export const EXPORT_DATA_TYPES = {
 | 
			
		||||
 | 
			
		||||
export const EXPORT_SOURCE = window.location.origin;
 | 
			
		||||
 | 
			
		||||
export const STORAGE_KEYS = {
 | 
			
		||||
  LOCAL_STORAGE_LIBRARY: "excalidraw-library",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
// time in milliseconds
 | 
			
		||||
export const IMAGE_RENDER_TIMEOUT = 500;
 | 
			
		||||
export const TAP_TWICE_TIMEOUT = 300;
 | 
			
		||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
 | 
			
		||||
export const TITLE_TIMEOUT = 10000;
 | 
			
		||||
@@ -112,6 +117,7 @@ export const TOAST_TIMEOUT = 5000;
 | 
			
		||||
export const VERSION_TIMEOUT = 30000;
 | 
			
		||||
export const SCROLL_TIMEOUT = 100;
 | 
			
		||||
export const ZOOM_STEP = 0.1;
 | 
			
		||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
 | 
			
		||||
 | 
			
		||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
 | 
			
		||||
export const IDLE_THRESHOLD = 60_000;
 | 
			
		||||
@@ -154,3 +160,25 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
 | 
			
		||||
 | 
			
		||||
export const EXPORT_SCALES = [1, 2, 3];
 | 
			
		||||
export const DEFAULT_EXPORT_PADDING = 10; // px
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
 | 
			
		||||
 | 
			
		||||
export const ALLOWED_IMAGE_MIME_TYPES = [
 | 
			
		||||
  MIME_TYPES.png,
 | 
			
		||||
  MIME_TYPES.jpg,
 | 
			
		||||
  MIME_TYPES.svg,
 | 
			
		||||
  MIME_TYPES.gif,
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
 | 
			
		||||
 | 
			
		||||
export const SVG_NS = "http://www.w3.org/2000/svg";
 | 
			
		||||
 | 
			
		||||
export const ENCRYPTION_KEY_BITS = 128;
 | 
			
		||||
 | 
			
		||||
export const VERSIONS = {
 | 
			
		||||
  excalidraw: 2,
 | 
			
		||||
  excalidrawLibrary: 2,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export const BOUND_TEXT_PADDING = 5;
 | 
			
		||||
 
 | 
			
		||||
@@ -180,7 +180,7 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .buttonList label:focus-within,
 | 
			
		||||
  input:focus {
 | 
			
		||||
  input:focus-visible {
 | 
			
		||||
    outline: transparent;
 | 
			
		||||
    box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
  }
 | 
			
		||||
@@ -190,14 +190,14 @@
 | 
			
		||||
    user-select: none;
 | 
			
		||||
    background-color: var(--button-gray-1);
 | 
			
		||||
    border: 0;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    border-radius: var(--border-radius-md);
 | 
			
		||||
    margin: 0.125rem 0;
 | 
			
		||||
    padding: 0.25rem;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
    &:focus-visible {
 | 
			
		||||
      outline: transparent;
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
    }
 | 
			
		||||
@@ -217,14 +217,16 @@
 | 
			
		||||
 | 
			
		||||
  .active,
 | 
			
		||||
  .buttonList label.active {
 | 
			
		||||
    background-color: var(--button-gray-2);
 | 
			
		||||
    background-color: var(--color-primary);
 | 
			
		||||
 | 
			
		||||
    --icon-fill-color: #{$oc-white};
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
      background-color: var(--color-primary-darker);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:active {
 | 
			
		||||
      background-color: var(--button-gray-3);
 | 
			
		||||
      background-color: var(--color-primary-darkest);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -234,7 +236,7 @@
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 36px;
 | 
			
		||||
        width: 35px;
 | 
			
		||||
        height: 14px;
 | 
			
		||||
        padding: 2px;
 | 
			
		||||
        opacity: 0.6;
 | 
			
		||||
@@ -311,7 +313,7 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .App-menu_top {
 | 
			
		||||
    grid-template-columns: 1fr auto 1fr;
 | 
			
		||||
    grid-template-columns: auto max-content auto;
 | 
			
		||||
    grid-gap: 4px;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
    cursor: default;
 | 
			
		||||
@@ -517,6 +519,27 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type="text"],
 | 
			
		||||
  textarea:not(.excalidraw-wysiwyg) {
 | 
			
		||||
    color: var(--text-primary-color);
 | 
			
		||||
    border: 1.5px solid var(--input-border-color);
 | 
			
		||||
    padding: 0.75rem;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    border-radius: var(--space-factor);
 | 
			
		||||
    background-color: var(--input-bg-color);
 | 
			
		||||
 | 
			
		||||
    &:not(:focus) {
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: var(--input-hover-bg-color);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media print {
 | 
			
		||||
    .App-bottom-bar,
 | 
			
		||||
    .FixedSideContainer,
 | 
			
		||||
 
 | 
			
		||||
@@ -12,11 +12,11 @@
 | 
			
		||||
  --dialog-border-color: #{$oc-gray-6};
 | 
			
		||||
  --dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
 | 
			
		||||
  --focus-highlight-color: #{$oc-blue-2};
 | 
			
		||||
  --icon-fill-color: #{$oc-black};
 | 
			
		||||
  --icon-fill-color: #{$oc-gray-9};
 | 
			
		||||
  --icon-green-fill-color: #{$oc-green-9};
 | 
			
		||||
  --default-bg-color: #{$oc-white};
 | 
			
		||||
  --input-bg-color: #{$oc-white};
 | 
			
		||||
  --input-border-color: #{$oc-gray-3};
 | 
			
		||||
  --input-border-color: #{$oc-gray-4};
 | 
			
		||||
  --input-hover-bg-color: #{$oc-gray-1};
 | 
			
		||||
  --input-label-color: #{$oc-gray-7};
 | 
			
		||||
  --island-bg-color: rgba(255, 255, 255, 0.96);
 | 
			
		||||
@@ -32,10 +32,20 @@
 | 
			
		||||
  --sar: env(safe-area-inset-right);
 | 
			
		||||
  --sat: env(safe-area-inset-top);
 | 
			
		||||
  --select-highlight-color: #{$oc-blue-5};
 | 
			
		||||
  --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
 | 
			
		||||
  --shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
 | 
			
		||||
 | 
			
		||||
  --space-factor: 0.25rem;
 | 
			
		||||
  --text-primary-color: #{$oc-gray-8};
 | 
			
		||||
 | 
			
		||||
  --color-primary: #6965db;
 | 
			
		||||
  --color-primary-chubb: #625ee0; // to offset Chubb illusion
 | 
			
		||||
  --color-primary-darker: #5b57d1;
 | 
			
		||||
  --color-primary-darkest: #4a47b1;
 | 
			
		||||
  --color-primary-light: #e2e1fc;
 | 
			
		||||
 | 
			
		||||
  --border-radius-md: 0.375rem;
 | 
			
		||||
  --border-radius-lg: 0.5rem;
 | 
			
		||||
 | 
			
		||||
  &.theme--dark {
 | 
			
		||||
    background: $oc-black;
 | 
			
		||||
 | 
			
		||||
@@ -64,13 +74,20 @@
 | 
			
		||||
    --input-label-color: #{$oc-gray-2};
 | 
			
		||||
    --island-bg-color: rgba(30, 30, 30, 0.98);
 | 
			
		||||
    --keybinding-color: #{$oc-gray-6};
 | 
			
		||||
    --link-color: #{$oc-blue-4};
 | 
			
		||||
    --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
 | 
			
		||||
    --popup-bg-color: #2c2c2c;
 | 
			
		||||
    --popup-secondary-bg-color: #222;
 | 
			
		||||
    --popup-text-color: #{$oc-gray-4};
 | 
			
		||||
    --popup-text-inverted-color: #2c2c2c;
 | 
			
		||||
    --select-highlight-color: #{$oc-blue-4};
 | 
			
		||||
    --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
 | 
			
		||||
    --shadow-island: 1px 1px 5px #{transparentize($oc-black, 0.7)};
 | 
			
		||||
    --text-primary-color: #{$oc-gray-4};
 | 
			
		||||
 | 
			
		||||
    --color-primary: #5650f0;
 | 
			
		||||
    --color-primary-chubb: #726dff; // to offset Chubb illusion
 | 
			
		||||
    --color-primary-darker: #4b46d8;
 | 
			
		||||
    --color-primary-darkest: #3e39be;
 | 
			
		||||
    --color-primary-light: #3f3d64;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										158
									
								
								src/data/blob.ts
									
									
									
									
									
								
							
							
						
						
									
										158
									
								
								src/data/blob.ts
									
									
									
									
									
								
							@@ -1,11 +1,17 @@
 | 
			
		||||
import { nanoid } from "nanoid";
 | 
			
		||||
import { cleanAppStateForExport } from "../appState";
 | 
			
		||||
import { EXPORT_DATA_TYPES } from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  ALLOWED_IMAGE_MIME_TYPES,
 | 
			
		||||
  EXPORT_DATA_TYPES,
 | 
			
		||||
  MIME_TYPES,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { clearElementsForExport } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { ExcalidrawElement, FileId } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { calculateScrollCenter } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { AppState, DataURL } from "../types";
 | 
			
		||||
import { bytesToHexString } from "../utils";
 | 
			
		||||
import { FileSystemHandle } from "./filesystem";
 | 
			
		||||
import { isValidExcalidrawData } from "./json";
 | 
			
		||||
import { restore } from "./restore";
 | 
			
		||||
@@ -14,16 +20,22 @@ import { ImportedLibraryData } from "./types";
 | 
			
		||||
const parseFileContents = async (blob: Blob | File) => {
 | 
			
		||||
  let contents: string;
 | 
			
		||||
 | 
			
		||||
  if (blob.type === "image/png") {
 | 
			
		||||
  if (blob.type === MIME_TYPES.png) {
 | 
			
		||||
    try {
 | 
			
		||||
      return await (
 | 
			
		||||
        await import(/* webpackChunkName: "image" */ "./image")
 | 
			
		||||
      ).decodePngMetadata(blob);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      if (error.message === "INVALID") {
 | 
			
		||||
        throw new Error(t("alerts.imageDoesNotContainScene"));
 | 
			
		||||
        throw new DOMException(
 | 
			
		||||
          t("alerts.imageDoesNotContainScene"),
 | 
			
		||||
          "EncodingError",
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        throw new Error(t("alerts.cannotRestoreFromImage"));
 | 
			
		||||
        throw new DOMException(
 | 
			
		||||
          t("alerts.cannotRestoreFromImage"),
 | 
			
		||||
          "EncodingError",
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
@@ -40,18 +52,24 @@ const parseFileContents = async (blob: Blob | File) => {
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    if (blob.type === "image/svg+xml") {
 | 
			
		||||
    if (blob.type === MIME_TYPES.svg) {
 | 
			
		||||
      try {
 | 
			
		||||
        return await (
 | 
			
		||||
          await import(/* webpackChunkName: "image" */ "./image")
 | 
			
		||||
        ).decodeSvgMetadata({
 | 
			
		||||
          svg: contents,
 | 
			
		||||
        });
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        if (error.message === "INVALID") {
 | 
			
		||||
          throw new Error(t("alerts.imageDoesNotContainScene"));
 | 
			
		||||
          throw new DOMException(
 | 
			
		||||
            t("alerts.imageDoesNotContainScene"),
 | 
			
		||||
            "EncodingError",
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          throw new Error(t("alerts.cannotRestoreFromImage"));
 | 
			
		||||
          throw new DOMException(
 | 
			
		||||
            t("alerts.cannotRestoreFromImage"),
 | 
			
		||||
            "EncodingError",
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -70,13 +88,13 @@ export const getMimeType = (blob: Blob | string): string => {
 | 
			
		||||
    name = blob.name || "";
 | 
			
		||||
  }
 | 
			
		||||
  if (/\.(excalidraw|json)$/.test(name)) {
 | 
			
		||||
    return "application/json";
 | 
			
		||||
    return MIME_TYPES.json;
 | 
			
		||||
  } else if (/\.png$/.test(name)) {
 | 
			
		||||
    return "image/png";
 | 
			
		||||
    return MIME_TYPES.png;
 | 
			
		||||
  } else if (/\.jpe?g$/.test(name)) {
 | 
			
		||||
    return "image/jpeg";
 | 
			
		||||
    return MIME_TYPES.jpg;
 | 
			
		||||
  } else if (/\.svg$/.test(name)) {
 | 
			
		||||
    return "image/svg+xml";
 | 
			
		||||
    return MIME_TYPES.svg;
 | 
			
		||||
  }
 | 
			
		||||
  return "";
 | 
			
		||||
};
 | 
			
		||||
@@ -100,6 +118,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
 | 
			
		||||
  return type === "png" || type === "svg";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isSupportedImageFile = (
 | 
			
		||||
  blob: Blob | null | undefined,
 | 
			
		||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
 | 
			
		||||
  const { type } = blob || {};
 | 
			
		||||
  return (
 | 
			
		||||
    !!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loadFromBlob = async (
 | 
			
		||||
  blob: Blob,
 | 
			
		||||
  /** @see restore.localAppState */
 | 
			
		||||
@@ -123,13 +150,14 @@ export const loadFromBlob = async (
 | 
			
		||||
            ? calculateScrollCenter(data.elements || [], localAppState, null)
 | 
			
		||||
            : {}),
 | 
			
		||||
        },
 | 
			
		||||
        files: data.files,
 | 
			
		||||
      },
 | 
			
		||||
      localAppState,
 | 
			
		||||
      localElements,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    console.error(error.message);
 | 
			
		||||
    throw new Error(t("alerts.couldNotLoadInvalidFile"));
 | 
			
		||||
  }
 | 
			
		||||
@@ -160,8 +188,104 @@ export const canvasToBlob = async (
 | 
			
		||||
        }
 | 
			
		||||
        resolve(blob);
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      reject(error);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** generates SHA-1 digest from supplied file (if not supported, falls back
 | 
			
		||||
    to a 40-char base64 random id) */
 | 
			
		||||
export const generateIdFromFile = async (file: File): Promise<FileId> => {
 | 
			
		||||
  try {
 | 
			
		||||
    const hashBuffer = await window.crypto.subtle.digest(
 | 
			
		||||
      "SHA-1",
 | 
			
		||||
      await file.arrayBuffer(),
 | 
			
		||||
    );
 | 
			
		||||
    return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
 | 
			
		||||
    return nanoid(40) as FileId;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const reader = new FileReader();
 | 
			
		||||
    reader.onload = () => {
 | 
			
		||||
      const dataURL = reader.result as DataURL;
 | 
			
		||||
      resolve(dataURL);
 | 
			
		||||
    };
 | 
			
		||||
    reader.onerror = (error) => reject(error);
 | 
			
		||||
    reader.readAsDataURL(file);
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
 | 
			
		||||
  const dataIndexStart = dataURL.indexOf(",");
 | 
			
		||||
  const byteString = atob(dataURL.slice(dataIndexStart + 1));
 | 
			
		||||
  const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
 | 
			
		||||
 | 
			
		||||
  const ab = new ArrayBuffer(byteString.length);
 | 
			
		||||
  const ia = new Uint8Array(ab);
 | 
			
		||||
  for (let i = 0; i < byteString.length; i++) {
 | 
			
		||||
    ia[i] = byteString.charCodeAt(i);
 | 
			
		||||
  }
 | 
			
		||||
  return new File([ab], filename, { type: mimeType });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const resizeImageFile = async (
 | 
			
		||||
  file: File,
 | 
			
		||||
  opts: {
 | 
			
		||||
    /** undefined indicates auto */
 | 
			
		||||
    outputType?: typeof MIME_TYPES["jpg"];
 | 
			
		||||
    maxWidthOrHeight: number;
 | 
			
		||||
  },
 | 
			
		||||
): Promise<File> => {
 | 
			
		||||
  // SVG files shouldn't a can't be resized
 | 
			
		||||
  if (file.type === MIME_TYPES.svg) {
 | 
			
		||||
    return file;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const [pica, imageBlobReduce] = await Promise.all([
 | 
			
		||||
    import("pica").then((res) => res.default),
 | 
			
		||||
    // a wrapper for pica for better API
 | 
			
		||||
    import("image-blob-reduce").then((res) => res.default),
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  // CRA's minification settings break pica in WebWorkers, so let's disable
 | 
			
		||||
  // them for now
 | 
			
		||||
  // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
 | 
			
		||||
  const reduce = imageBlobReduce({
 | 
			
		||||
    pica: pica({ features: ["js", "wasm"] }),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (opts.outputType) {
 | 
			
		||||
    const { outputType } = opts;
 | 
			
		||||
    reduce._create_blob = function (env) {
 | 
			
		||||
      return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
 | 
			
		||||
        env.out_blob = blob;
 | 
			
		||||
        return env;
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!isSupportedImageFile(file)) {
 | 
			
		||||
    throw new Error(t("errors.unsupportedFileType"));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return new File(
 | 
			
		||||
    [await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
 | 
			
		||||
    file.name,
 | 
			
		||||
    {
 | 
			
		||||
      type: opts.outputType || file.type,
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const SVGStringToFile = (SVGString: string, filename: string = "") => {
 | 
			
		||||
  return new File([new TextEncoder().encode(SVGString)], filename, {
 | 
			
		||||
    type: MIME_TYPES.svg,
 | 
			
		||||
  }) as File & { type: typeof MIME_TYPES.svg };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,19 @@
 | 
			
		||||
import { deflate, inflate } from "pako";
 | 
			
		||||
import { encryptData, decryptData } from "./encryption";
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
// byte (binary) strings
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
// fast, Buffer-compatible implem
 | 
			
		||||
export const toByteString = (data: string | Uint8Array): Promise<string> => {
 | 
			
		||||
export const toByteString = (
 | 
			
		||||
  data: string | Uint8Array | ArrayBuffer,
 | 
			
		||||
): Promise<string> => {
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const blob =
 | 
			
		||||
      typeof data === "string"
 | 
			
		||||
        ? new Blob([new TextEncoder().encode(data)])
 | 
			
		||||
        : new Blob([data]);
 | 
			
		||||
        : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
 | 
			
		||||
    const reader = new FileReader();
 | 
			
		||||
    reader.onload = (event) => {
 | 
			
		||||
      if (!event.target || typeof event.target.result !== "string") {
 | 
			
		||||
@@ -44,12 +47,14 @@ const byteStringToString = (byteString: string) => {
 | 
			
		||||
 *  due to reencoding
 | 
			
		||||
 */
 | 
			
		||||
export const stringToBase64 = async (str: string, isByteString = false) => {
 | 
			
		||||
  return isByteString ? btoa(str) : btoa(await toByteString(str));
 | 
			
		||||
  return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// async to align with stringToBase64
 | 
			
		||||
export const base64ToString = async (base64: string, isByteString = false) => {
 | 
			
		||||
  return isByteString ? atob(base64) : byteStringToString(atob(base64));
 | 
			
		||||
  return isByteString
 | 
			
		||||
    ? window.atob(base64)
 | 
			
		||||
    : byteStringToString(window.atob(base64));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
@@ -80,7 +85,7 @@ export const encode = async ({
 | 
			
		||||
  if (compress !== false) {
 | 
			
		||||
    try {
 | 
			
		||||
      deflated = await toByteString(deflate(text));
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error("encode: cannot deflate", error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -114,3 +119,273 @@ export const decode = async (data: EncodedData): Promise<string> => {
 | 
			
		||||
 | 
			
		||||
  return decoded;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
// binary encoding
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
type FileEncodingInfo = {
 | 
			
		||||
  /* version 2 is the version we're shipping the initial image support with.
 | 
			
		||||
    version 1 was a PR version that a lot of people were using anyway.
 | 
			
		||||
    Thus, if there are issues we can check whether they're not using the
 | 
			
		||||
    unoffic version */
 | 
			
		||||
  version: 1 | 2;
 | 
			
		||||
  compression: "pako@1" | null;
 | 
			
		||||
  encryption: "AES-GCM" | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
const CONCAT_BUFFERS_VERSION = 1;
 | 
			
		||||
/** how many bytes we use to encode how many bytes the next chunk has.
 | 
			
		||||
 * Corresponds to DataView setter methods (setUint32, setUint16, etc).
 | 
			
		||||
 *
 | 
			
		||||
 * NOTE ! values must not be changed, which would be backwards incompatible !
 | 
			
		||||
 */
 | 
			
		||||
const VERSION_DATAVIEW_BYTES = 4;
 | 
			
		||||
const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
 | 
			
		||||
 | 
			
		||||
// getter
 | 
			
		||||
function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
 | 
			
		||||
// setter
 | 
			
		||||
function dataView(
 | 
			
		||||
  buffer: Uint8Array,
 | 
			
		||||
  bytes: 1 | 2 | 4,
 | 
			
		||||
  offset: number,
 | 
			
		||||
  value: number,
 | 
			
		||||
): Uint8Array;
 | 
			
		||||
/**
 | 
			
		||||
 * abstraction over DataView that serves as a typed getter/setter in case
 | 
			
		||||
 * you're using constants for the byte size and want to ensure there's no
 | 
			
		||||
 * discrepenancy in the encoding across refactors.
 | 
			
		||||
 *
 | 
			
		||||
 * DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
 | 
			
		||||
 */
 | 
			
		||||
function dataView(
 | 
			
		||||
  buffer: Uint8Array,
 | 
			
		||||
  bytes: 1 | 2 | 4,
 | 
			
		||||
  offset: number,
 | 
			
		||||
  value?: number,
 | 
			
		||||
): Uint8Array | number {
 | 
			
		||||
  if (value != null) {
 | 
			
		||||
    if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
 | 
			
		||||
      throw new Error(
 | 
			
		||||
        `attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
 | 
			
		||||
    new DataView(buffer.buffer)[method](offset, value);
 | 
			
		||||
    return buffer;
 | 
			
		||||
  }
 | 
			
		||||
  const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
 | 
			
		||||
  return new DataView(buffer.buffer)[method](offset);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Resulting concatenated buffer has this format:
 | 
			
		||||
 *
 | 
			
		||||
 * [
 | 
			
		||||
 *   VERSION chunk (4 bytes)
 | 
			
		||||
 *   LENGTH chunk 1 (4 bytes)
 | 
			
		||||
 *   DATA chunk 1 (up to 2^32 bits)
 | 
			
		||||
 *   LENGTH chunk 2 (4 bytes)
 | 
			
		||||
 *   DATA chunk 2 (up to 2^32 bits)
 | 
			
		||||
 *   ...
 | 
			
		||||
 * ]
 | 
			
		||||
 *
 | 
			
		||||
 * @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
 | 
			
		||||
 */
 | 
			
		||||
const concatBuffers = (...buffers: Uint8Array[]) => {
 | 
			
		||||
  const bufferView = new Uint8Array(
 | 
			
		||||
    VERSION_DATAVIEW_BYTES +
 | 
			
		||||
      NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
 | 
			
		||||
      buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  let cursor = 0;
 | 
			
		||||
 | 
			
		||||
  // as the first chunk we'll encode the version for backwards compatibility
 | 
			
		||||
  dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
 | 
			
		||||
  cursor += VERSION_DATAVIEW_BYTES;
 | 
			
		||||
 | 
			
		||||
  for (const buffer of buffers) {
 | 
			
		||||
    dataView(
 | 
			
		||||
      bufferView,
 | 
			
		||||
      NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
 | 
			
		||||
      cursor,
 | 
			
		||||
      buffer.byteLength,
 | 
			
		||||
    );
 | 
			
		||||
    cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
 | 
			
		||||
 | 
			
		||||
    bufferView.set(buffer, cursor);
 | 
			
		||||
    cursor += buffer.byteLength;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return bufferView;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** can only be used on buffers created via `concatBuffers()` */
 | 
			
		||||
const splitBuffers = (concatenatedBuffer: Uint8Array) => {
 | 
			
		||||
  const buffers = [];
 | 
			
		||||
 | 
			
		||||
  let cursor = 0;
 | 
			
		||||
 | 
			
		||||
  // first chunk is the version
 | 
			
		||||
  const version = dataView(
 | 
			
		||||
    concatenatedBuffer,
 | 
			
		||||
    NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
 | 
			
		||||
    cursor,
 | 
			
		||||
  );
 | 
			
		||||
  // If version is outside of the supported versions, throw an error.
 | 
			
		||||
  // This usually means the buffer wasn't encoded using this API, so we'd only
 | 
			
		||||
  // waste compute.
 | 
			
		||||
  if (version > CONCAT_BUFFERS_VERSION) {
 | 
			
		||||
    throw new Error(`invalid version ${version}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  cursor += VERSION_DATAVIEW_BYTES;
 | 
			
		||||
 | 
			
		||||
  while (true) {
 | 
			
		||||
    const chunkSize = dataView(
 | 
			
		||||
      concatenatedBuffer,
 | 
			
		||||
      NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
 | 
			
		||||
      cursor,
 | 
			
		||||
    );
 | 
			
		||||
    cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
 | 
			
		||||
 | 
			
		||||
    buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
 | 
			
		||||
    cursor += chunkSize;
 | 
			
		||||
    if (cursor >= concatenatedBuffer.byteLength) {
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return buffers;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// helpers for (de)compressing data with JSON metadata including encryption
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
/** @private */
 | 
			
		||||
const _encryptAndCompress = async (
 | 
			
		||||
  data: Uint8Array | string,
 | 
			
		||||
  encryptionKey: string,
 | 
			
		||||
) => {
 | 
			
		||||
  const { encryptedBuffer, iv } = await encryptData(
 | 
			
		||||
    encryptionKey,
 | 
			
		||||
    deflate(data),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return { iv, buffer: new Uint8Array(encryptedBuffer) };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The returned buffer has following format:
 | 
			
		||||
 * `[]` refers to a buffers wrapper (see `concatBuffers`)
 | 
			
		||||
 *
 | 
			
		||||
 * [
 | 
			
		||||
 *   encodingMetadataBuffer,
 | 
			
		||||
 *   iv,
 | 
			
		||||
 *   [
 | 
			
		||||
 *      contentsMetadataBuffer
 | 
			
		||||
 *      contentsBuffer
 | 
			
		||||
 *   ]
 | 
			
		||||
 * ]
 | 
			
		||||
 */
 | 
			
		||||
export const compressData = async <T extends Record<string, any> = never>(
 | 
			
		||||
  dataBuffer: Uint8Array,
 | 
			
		||||
  options: {
 | 
			
		||||
    encryptionKey: string;
 | 
			
		||||
  } & ([T] extends [never]
 | 
			
		||||
    ? {
 | 
			
		||||
        metadata?: T;
 | 
			
		||||
      }
 | 
			
		||||
    : {
 | 
			
		||||
        metadata: T;
 | 
			
		||||
      }),
 | 
			
		||||
): Promise<Uint8Array> => {
 | 
			
		||||
  const fileInfo: FileEncodingInfo = {
 | 
			
		||||
    version: 2,
 | 
			
		||||
    compression: "pako@1",
 | 
			
		||||
    encryption: "AES-GCM",
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const encodingMetadataBuffer = new TextEncoder().encode(
 | 
			
		||||
    JSON.stringify(fileInfo),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const contentsMetadataBuffer = new TextEncoder().encode(
 | 
			
		||||
    JSON.stringify(options.metadata || null),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const { iv, buffer } = await _encryptAndCompress(
 | 
			
		||||
    concatBuffers(contentsMetadataBuffer, dataBuffer),
 | 
			
		||||
    options.encryptionKey,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return concatBuffers(encodingMetadataBuffer, iv, buffer);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** @private */
 | 
			
		||||
const _decryptAndDecompress = async (
 | 
			
		||||
  iv: Uint8Array,
 | 
			
		||||
  decryptedBuffer: Uint8Array,
 | 
			
		||||
  decryptionKey: string,
 | 
			
		||||
  isCompressed: boolean,
 | 
			
		||||
) => {
 | 
			
		||||
  decryptedBuffer = new Uint8Array(
 | 
			
		||||
    await decryptData(iv, decryptedBuffer, decryptionKey),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (isCompressed) {
 | 
			
		||||
    return inflate(decryptedBuffer);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return decryptedBuffer;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const decompressData = async <T extends Record<string, any>>(
 | 
			
		||||
  bufferView: Uint8Array,
 | 
			
		||||
  options: { decryptionKey: string },
 | 
			
		||||
) => {
 | 
			
		||||
  // first chunk is encoding metadata (ignored for now)
 | 
			
		||||
  const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
 | 
			
		||||
 | 
			
		||||
  const encodingMetadata: FileEncodingInfo = JSON.parse(
 | 
			
		||||
    new TextDecoder().decode(encodingMetadataBuffer),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
 | 
			
		||||
      await _decryptAndDecompress(
 | 
			
		||||
        iv,
 | 
			
		||||
        buffer,
 | 
			
		||||
        options.decryptionKey,
 | 
			
		||||
        !!encodingMetadata.compression,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const metadata = JSON.parse(
 | 
			
		||||
      new TextDecoder().decode(contentsMetadataBuffer),
 | 
			
		||||
    ) as T;
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      /** metadata source is always JSON so we can decode it here */
 | 
			
		||||
      metadata,
 | 
			
		||||
      /** data can be anything so the caller must decode it */
 | 
			
		||||
      data: contentsBuffer,
 | 
			
		||||
    };
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    console.error(
 | 
			
		||||
      `Error during decompressing and decrypting the file.`,
 | 
			
		||||
      encodingMetadata,
 | 
			
		||||
    );
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										92
									
								
								src/data/encryption.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/data/encryption.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
			
		||||
import { ENCRYPTION_KEY_BITS } from "../constants";
 | 
			
		||||
 | 
			
		||||
export const IV_LENGTH_BYTES = 12;
 | 
			
		||||
 | 
			
		||||
export const createIV = () => {
 | 
			
		||||
  const arr = new Uint8Array(IV_LENGTH_BYTES);
 | 
			
		||||
  return window.crypto.getRandomValues(arr);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const generateEncryptionKey = async <
 | 
			
		||||
  T extends "string" | "cryptoKey" = "string",
 | 
			
		||||
>(
 | 
			
		||||
  returnAs?: T,
 | 
			
		||||
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
 | 
			
		||||
  const key = await window.crypto.subtle.generateKey(
 | 
			
		||||
    {
 | 
			
		||||
      name: "AES-GCM",
 | 
			
		||||
      length: ENCRYPTION_KEY_BITS,
 | 
			
		||||
    },
 | 
			
		||||
    true, // extractable
 | 
			
		||||
    ["encrypt", "decrypt"],
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    returnAs === "cryptoKey"
 | 
			
		||||
      ? key
 | 
			
		||||
      : (await window.crypto.subtle.exportKey("jwk", key)).k
 | 
			
		||||
  ) as T extends "cryptoKey" ? CryptoKey : string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getCryptoKey = (key: string, usage: KeyUsage) =>
 | 
			
		||||
  window.crypto.subtle.importKey(
 | 
			
		||||
    "jwk",
 | 
			
		||||
    {
 | 
			
		||||
      alg: "A128GCM",
 | 
			
		||||
      ext: true,
 | 
			
		||||
      k: key,
 | 
			
		||||
      key_ops: ["encrypt", "decrypt"],
 | 
			
		||||
      kty: "oct",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: "AES-GCM",
 | 
			
		||||
      length: ENCRYPTION_KEY_BITS,
 | 
			
		||||
    },
 | 
			
		||||
    false, // extractable
 | 
			
		||||
    [usage],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const encryptData = async (
 | 
			
		||||
  key: string | CryptoKey,
 | 
			
		||||
  data: Uint8Array | ArrayBuffer | Blob | File | string,
 | 
			
		||||
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
 | 
			
		||||
  const importedKey =
 | 
			
		||||
    typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
 | 
			
		||||
  const iv = createIV();
 | 
			
		||||
  const buffer: ArrayBuffer | Uint8Array =
 | 
			
		||||
    typeof data === "string"
 | 
			
		||||
      ? new TextEncoder().encode(data)
 | 
			
		||||
      : data instanceof Uint8Array
 | 
			
		||||
      ? data
 | 
			
		||||
      : data instanceof Blob
 | 
			
		||||
      ? await data.arrayBuffer()
 | 
			
		||||
      : data;
 | 
			
		||||
 | 
			
		||||
  // We use symmetric encryption. AES-GCM is the recommended algorithm and
 | 
			
		||||
  // includes checks that the ciphertext has not been modified by an attacker.
 | 
			
		||||
  const encryptedBuffer = await window.crypto.subtle.encrypt(
 | 
			
		||||
    {
 | 
			
		||||
      name: "AES-GCM",
 | 
			
		||||
      iv,
 | 
			
		||||
    },
 | 
			
		||||
    importedKey,
 | 
			
		||||
    buffer as ArrayBuffer | Uint8Array,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return { encryptedBuffer, iv };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const decryptData = async (
 | 
			
		||||
  iv: Uint8Array,
 | 
			
		||||
  encrypted: Uint8Array | ArrayBuffer,
 | 
			
		||||
  privateKey: string,
 | 
			
		||||
): Promise<ArrayBuffer> => {
 | 
			
		||||
  const key = await getCryptoKey(privateKey, "decrypt");
 | 
			
		||||
  return window.crypto.subtle.decrypt(
 | 
			
		||||
    {
 | 
			
		||||
      name: "AES-GCM",
 | 
			
		||||
      iv,
 | 
			
		||||
    },
 | 
			
		||||
    key,
 | 
			
		||||
    encrypted,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -4,12 +4,13 @@ import {
 | 
			
		||||
  fileSave as _fileSave,
 | 
			
		||||
  FileSystemHandle,
 | 
			
		||||
  supported as nativeFileSystemSupported,
 | 
			
		||||
} from "@dwelle/browser-fs-access";
 | 
			
		||||
} from "browser-fs-access";
 | 
			
		||||
import { EVENT, MIME_TYPES } from "../constants";
 | 
			
		||||
import { AbortError } from "../errors";
 | 
			
		||||
import { debounce } from "../utils";
 | 
			
		||||
 | 
			
		||||
type FILE_EXTENSION =
 | 
			
		||||
  | "gif"
 | 
			
		||||
  | "jpg"
 | 
			
		||||
  | "png"
 | 
			
		||||
  | "svg"
 | 
			
		||||
@@ -17,20 +18,11 @@ type FILE_EXTENSION =
 | 
			
		||||
  | "excalidraw"
 | 
			
		||||
  | "excalidrawlib";
 | 
			
		||||
 | 
			
		||||
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
 | 
			
		||||
  jpg: "image/jpeg",
 | 
			
		||||
  png: "image/png",
 | 
			
		||||
  svg: "image/svg+xml",
 | 
			
		||||
  json: "application/json",
 | 
			
		||||
  excalidraw: MIME_TYPES.excalidraw,
 | 
			
		||||
  excalidrawlib: MIME_TYPES.excalidrawlib,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const INPUT_CHANGE_INTERVAL_MS = 500;
 | 
			
		||||
 | 
			
		||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
 | 
			
		||||
  extensions?: FILE_EXTENSION[];
 | 
			
		||||
  description?: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  multiple?: M;
 | 
			
		||||
}): Promise<
 | 
			
		||||
  M extends false | undefined ? FileWithHandle : FileWithHandle[]
 | 
			
		||||
@@ -41,7 +33,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
 | 
			
		||||
    : FileWithHandle[];
 | 
			
		||||
 | 
			
		||||
  const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
 | 
			
		||||
    mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
 | 
			
		||||
    mimeTypes.push(MIME_TYPES[type]);
 | 
			
		||||
 | 
			
		||||
    return mimeTypes;
 | 
			
		||||
  }, [] as string[]);
 | 
			
		||||
@@ -102,7 +94,7 @@ export const fileSave = (
 | 
			
		||||
    name: string;
 | 
			
		||||
    /** file extension */
 | 
			
		||||
    extension: FILE_EXTENSION;
 | 
			
		||||
    description?: string;
 | 
			
		||||
    description: string;
 | 
			
		||||
    /** existing FileSystemHandle */
 | 
			
		||||
    fileHandle?: FileSystemHandle | null;
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
 | 
			
		||||
  // insert metadata before last chunk (iEND)
 | 
			
		||||
  chunks.splice(-1, 0, metadataChunk);
 | 
			
		||||
 | 
			
		||||
  return new Blob([encodePng(chunks)], { type: "image/png" });
 | 
			
		||||
  return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const decodePngMetadata = async (blob: Blob) => {
 | 
			
		||||
@@ -76,7 +76,7 @@ export const decodePngMetadata = async (blob: Blob) => {
 | 
			
		||||
        throw new Error("FAILED");
 | 
			
		||||
      }
 | 
			
		||||
      return await decode(encodedData);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      throw new Error("FAILED");
 | 
			
		||||
    }
 | 
			
		||||
@@ -127,7 +127,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
 | 
			
		||||
        throw new Error("FAILED");
 | 
			
		||||
      }
 | 
			
		||||
      return await decode(encodedData);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      throw new Error("FAILED");
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user