mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	Compare commits
	
		
			304 Commits
		
	
	
		
			draft/wond
			...
			perf_debug
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					1d65536360 | ||
| 
						 | 
					d731a6463c | ||
| 
						 | 
					9f325a626e | ||
| 
						 | 
					41200ea28d | ||
| 
						 | 
					46a61ad4df | ||
| 
						 | 
					f4b1a30bef | ||
| 
						 | 
					32aa79164b | ||
| 
						 | 
					b5fd904808 | ||
| 
						 | 
					8f8dd1105f | ||
| 
						 | 
					b914ad41fc | ||
| 
						 | 
					551c38f60b | ||
| 
						 | 
					38e8ae46c9 | ||
| 
						 | 
					ad0c4c4c78 | ||
| 
						 | 
					27cf5ed17e | ||
| 
						 | 
					fd946adbae | ||
| 
						 | 
					c37977af4b | ||
| 
						 | 
					a0d413ab4e | ||
| 
						 | 
					b67a2b4f65 | ||
| 
						 | 
					5a8dbe8030 | ||
| 
						 | 
					731093f631 | ||
| 
						 | 
					fe56975f19 | ||
| 
						 | 
					2d800feeeb | ||
| 
						 | 
					93cccd596a | ||
| 
						 | 
					45b592227d | ||
| 
						 | 
					b818df1098 | ||
| 
						 | 
					4359e2935d | ||
| 
						 | 
					3d9d398378 | ||
| 
						 | 
					0a5da0269f | ||
| 
						 | 
					08ce7c7fc3 | ||
| 
						 | 
					fe7fbff7f6 | ||
| 
						 | 
					501397cb61 | ||
| 
						 | 
					865d29388c | ||
| 
						 | 
					54c7ec416a | ||
| 
						 | 
					aca284057d | ||
| 
						 | 
					2820cd112e | ||
| 
						 | 
					426b5d9537 | ||
| 
						 | 
					e7d34677c6 | ||
| 
						 | 
					3d5356cb8e | ||
| 
						 | 
					46f5ce5ce0 | ||
| 
						 | 
					b00bd3d6c0 | ||
| 
						 | 
					91fc22182c | ||
| 
						 | 
					966ca2ffa6 | ||
| 
						 | 
					2b049b4a65 | ||
| 
						 | 
					339212e563 | ||
| 
						 | 
					f8b4bb66b4 | ||
| 
						 | 
					f4312bba5e | ||
| 
						 | 
					ac66665b64 | ||
| 
						 | 
					2b71a1f0bd | ||
| 
						 | 
					58845e450a | ||
| 
						 | 
					15d79f8fee | ||
| 
						 | 
					958ebeae61 | ||
| 
						 | 
					31f51ca53b | ||
| 
						 | 
					5abbf73050 | ||
| 
						 | 
					7cf766630d | ||
| 
						 | 
					59fccafeac | ||
| 
						 | 
					19a6996e6b | ||
| 
						 | 
					86c4f90910 | ||
| 
						 | 
					4d88112021 | ||
| 
						 | 
					de5c63e299 | ||
| 
						 | 
					da0853a121 | ||
| 
						 | 
					57cc4b6a29 | ||
| 
						 | 
					e2ddd7b27a | ||
| 
						 | 
					693de8501e | ||
| 
						 | 
					c6df6d444e | ||
| 
						 | 
					ad5692c5f8 | ||
| 
						 | 
					60ab3337af | ||
| 
						 | 
					dd847793d2 | ||
| 
						 | 
					6d6e9f0dd3 | ||
| 
						 | 
					0fe0d7ca6b | ||
| 
						 | 
					abcf1f1bae | ||
| 
						 | 
					7d0b03f754 | ||
| 
						 | 
					bd8931d3d1 | ||
| 
						 | 
					0d86c04939 | ||
| 
						 | 
					8436ebbf68 | ||
| 
						 | 
					824f94b3df | ||
| 
						 | 
					f9a8e686b2 | ||
| 
						 | 
					e442a44ba8 | ||
| 
						 | 
					f1fd29571a | ||
| 
						 | 
					6a482a7ba2 | ||
| 
						 | 
					bfea434a55 | ||
| 
						 | 
					acb22c5a64 | ||
| 
						 | 
					7cd1b621d1 | ||
| 
						 | 
					9c37b25bab | ||
| 
						 | 
					a8bb9a78ef | ||
| 
						 | 
					e4aff04061 | ||
| 
						 | 
					c5cadc7de3 | ||
| 
						 | 
					7dc0c0d96a | ||
| 
						 | 
					2c9c8c8e05 | ||
| 
						 | 
					b5d7ae57e5 | ||
| 
						 | 
					0f66ee3a41 | ||
| 
						 | 
					771372c66b | ||
| 
						 | 
					a7937681e9 | ||
| 
						 | 
					792f238d16 | ||
| 
						 | 
					ba16416c75 | ||
| 
						 | 
					6e0ac52a64 | ||
| 
						 | 
					5bc40402a6 | ||
| 
						 | 
					df14c69977 | ||
| 
						 | 
					1ea67ba93d | ||
| 
						 | 
					a7153d9d1d | ||
| 
						 | 
					e885057a71 | ||
| 
						 | 
					7efa081976 | ||
| 
						 | 
					5deb93a083 | ||
| 
						 | 
					e3908e6fe3 | ||
| 
						 | 
					fe3d0b5e8b | ||
| 
						 | 
					b6bb74d08d | ||
| 
						 | 
					c725f84334 | ||
| 
						 | 
					11a3380d83 | ||
| 
						 | 
					76a5bb060e | ||
| 
						 | 
					dac8dda4d4 | ||
| 
						 | 
					a1a62468a6 | ||
| 
						 | 
					ba3a723e99 | ||
| 
						 | 
					c5355c08cf | ||
| 
						 | 
					6102380051 | ||
| 
						 | 
					655e59a707 | ||
| 
						 | 
					d05745070b | ||
| 
						 | 
					88c313bf86 | ||
| 
						 | 
					a7705848ec | ||
| 
						 | 
					69e1bae8dd | ||
| 
						 | 
					d361757e4a | ||
| 
						 | 
					0ef202f2df | ||
| 
						 | 
					bbfd2b3cd3 | ||
| 
						 | 
					120c8f373c | ||
| 
						 | 
					9135ebf2e2 | ||
| 
						 | 
					af31e9dcc2 | ||
| 
						 | 
					50bc7e099a | ||
| 
						 | 
					39d17c4a3c | ||
| 
						 | 
					d34c2a75db | ||
| 
						 | 
					de95c68d75 | ||
| 
						 | 
					cdf352d4c3 | ||
| 
						 | 
					4712393b62 | ||
| 
						 | 
					fd48c2cf79 | ||
| 
						 | 
					5feacd9a3b | ||
| 
						 | 
					ec35d5db51 | ||
| 
						 | 
					ddf088e428 | ||
| 
						 | 
					adc1e585ff | ||
| 
						 | 
					84b47a2ed5 | ||
| 
						 | 
					6196fba286 | ||
| 
						 | 
					5daff2d3cd | ||
| 
						 | 
					f1bc90e08a | ||
| 
						 | 
					aabcdc20fd | ||
| 
						 | 
					269fbcc2f3 | ||
| 
						 | 
					d08179c215 | ||
| 
						 | 
					90e739d444 | ||
| 
						 | 
					4a9fac2d1e | ||
| 
						 | 
					07ebd7c68c | ||
| 
						 | 
					92f30f7ed6 | ||
| 
						 | 
					605aa554d0 | ||
| 
						 | 
					bed9fca4a5 | ||
| 
						 | 
					b9968e2e72 | ||
| 
						 | 
					ab1a30073c | ||
| 
						 | 
					31049d06e8 | ||
| 
						 | 
					ef8559d060 | ||
| 
						 | 
					33bb23d2f3 | ||
| 
						 | 
					b27ac257e7 | ||
| 
						 | 
					d2cc76e52e | ||
| 
						 | 
					cad6097d60 | ||
| 
						 | 
					2537b225ac | ||
| 
						 | 
					4ee48d2729 | ||
| 
						 | 
					68f23d652f | ||
| 
						 | 
					a078508c05 | ||
| 
						 | 
					abf4dc9256 | ||
| 
						 | 
					ba8f12d588 | ||
| 
						 | 
					d57560db06 | ||
| 
						 | 
					0d26049b4e | ||
| 
						 | 
					f72e9b6ea5 | ||
| 
						 | 
					029cfb31b0 | ||
| 
						 | 
					3a288eb09c | ||
| 
						 | 
					803909abb6 | ||
| 
						 | 
					56c75b769c | ||
| 
						 | 
					eea48d94d3 | ||
| 
						 | 
					e29152ab30 | ||
| 
						 | 
					f4aa36b35d | ||
| 
						 | 
					2903a763a7 | ||
| 
						 | 
					4a980ed5db | ||
| 
						 | 
					d2e687ed0a | ||
| 
						 | 
					0d70690ec8 | ||
| 
						 | 
					a524eeb66e | ||
| 
						 | 
					3d56ceb794 | ||
| 
						 | 
					65c32b3319 | ||
| 
						 | 
					9e8e047aae | ||
| 
						 | 
					64d330a332 | ||
| 
						 | 
					1ed1529f96 | ||
| 
						 | 
					b30066ca19 | ||
| 
						 | 
					aae8e2fa5d | ||
| 
						 | 
					9e6d5fdbcb | ||
| 
						 | 
					22b2e10ddb | ||
| 
						 | 
					d53ac2a61e | ||
| 
						 | 
					6a0f800716 | ||
| 
						 | 
					aee1e2451e | ||
| 
						 | 
					da94eb1284 | ||
| 
						 | 
					ea51251fe6 | ||
| 
						 | 
					399ce1e01a | ||
| 
						 | 
					7df8302ba2 | ||
| 
						 | 
					af8c59b5bb | ||
| 
						 | 
					cf0f00285b | ||
| 
						 | 
					b5c67a384c | ||
| 
						 | 
					af93cedc08 | ||
| 
						 | 
					b6a6f2d465 | ||
| 
						 | 
					6bcbf8b50a | ||
| 
						 | 
					666516d7e9 | ||
| 
						 | 
					b941c5b661 | ||
| 
						 | 
					8f8c85c64e | ||
| 
						 | 
					116b0c48da | ||
| 
						 | 
					aa2971e8c5 | ||
| 
						 | 
					5656ac1e3e | ||
| 
						 | 
					e6a9ff1b96 | ||
| 
						 | 
					832b88249c | ||
| 
						 | 
					9902092fd1 | ||
| 
						 | 
					8f0863d335 | ||
| 
						 | 
					9423ac3263 | ||
| 
						 | 
					a66cfe2627 | ||
| 
						 | 
					86cf28f2b4 | ||
| 
						 | 
					b5a46dd671 | ||
| 
						 | 
					cd942c3e3b | ||
| 
						 | 
					55ccd5b79b | ||
| 
						 | 
					4348c55c31 | ||
| 
						 | 
					a3fbe40b26 | ||
| 
						 | 
					7431ca81d1 | ||
| 
						 | 
					4d13dbf625 | ||
| 
						 | 
					3840e2f4e6 | ||
| 
						 | 
					52d10bb41e | ||
| 
						 | 
					96c87f920a | ||
| 
						 | 
					7d4189c624 | ||
| 
						 | 
					f3e17c90d3 | ||
| 
						 | 
					70b3a9de49 | ||
| 
						 | 
					bf6d0eeef7 | ||
| 
						 | 
					5359e4fec9 | ||
| 
						 | 
					58fe639b8d | ||
| 
						 | 
					327ed0e2d1 | ||
| 
						 | 
					c2fce6d8c4 | ||
| 
						 | 
					cb6b7559b4 | ||
| 
						 | 
					77d789ed8e | ||
| 
						 | 
					89471094ce | ||
| 
						 | 
					670ceafc84 | ||
| 
						 | 
					873afdacd3 | ||
| 
						 | 
					880e4feede | ||
| 
						 | 
					9ba7ca3845 | ||
| 
						 | 
					734bb4d2ed | ||
| 
						 | 
					f2d2f97546 | ||
| 
						 | 
					2fa69ddc32 | ||
| 
						 | 
					1331cffe93 | ||
| 
						 | 
					f242721f3b | ||
| 
						 | 
					e940aeb1a3 | ||
| 
						 | 
					580e719580 | ||
| 
						 | 
					127af9db23 | ||
| 
						 | 
					2209e2c1e8 | ||
| 
						 | 
					ed31980f84 | ||
| 
						 | 
					db28595302 | ||
| 
						 | 
					cded1cd63d | ||
| 
						 | 
					8e447b4c32 | ||
| 
						 | 
					e29d3fc5e6 | ||
| 
						 | 
					9da56e46f0 | ||
| 
						 | 
					625ecc64ed | ||
| 
						 | 
					ceb43ed8fb | ||
| 
						 | 
					8c0a0415de | ||
| 
						 | 
					192debd829 | ||
| 
						 | 
					1cfb4dfd8b | ||
| 
						 | 
					fb32886355 | ||
| 
						 | 
					065df495ba | ||
| 
						 | 
					558227f744 | ||
| 
						 | 
					6d45430344 | ||
| 
						 | 
					3aa0c5ebc0 | ||
| 
						 | 
					e940993e0e | ||
| 
						 | 
					8f90aeb8d5 | ||
| 
						 | 
					e92d133973 | ||
| 
						 | 
					b682d88167 | ||
| 
						 | 
					7daf1a7944 | ||
| 
						 | 
					5c0eff50a0 | ||
| 
						 | 
					19056d635b | ||
| 
						 | 
					4d5f00ff08 | ||
| 
						 | 
					20de06ef50 | ||
| 
						 | 
					1849ff6ee2 | ||
| 
						 | 
					6765fc16be | ||
| 
						 | 
					5ca4f5bbf4 | ||
| 
						 | 
					9392ec276d | ||
| 
						 | 
					b26e4fcf99 | ||
| 
						 | 
					45f3410da8 | ||
| 
						 | 
					94b387ef7b | ||
| 
						 | 
					6d0716eb6b | ||
| 
						 | 
					8e26d5b500 | ||
| 
						 | 
					c5a7723185 | ||
| 
						 | 
					49172ac2d3 | ||
| 
						 | 
					618a846451 | ||
| 
						 | 
					d9f49ffd67 | ||
| 
						 | 
					46e43baad1 | ||
| 
						 | 
					bd35b682fa | ||
| 
						 | 
					b6f9a8005e | ||
| 
						 | 
					1acfaf6b6e | ||
| 
						 | 
					5cf7087754 | ||
| 
						 | 
					b2d49155ef | ||
| 
						 | 
					9745461db7 | ||
| 
						 | 
					21e9fcb2f5 | ||
| 
						 | 
					e203203993 | ||
| 
						 | 
					f224e4d596 | ||
| 
						 | 
					e0ca689759 | ||
| 
						 | 
					f792eb5ae7 | ||
| 
						 | 
					4604c8d823 | ||
| 
						 | 
					0896892f8a | ||
| 
						 | 
					7fe225ee99 | ||
| 
						 | 
					d2fd7be457 | ||
| 
						 | 
					5c61613a2e | ||
| 
						 | 
					b2767924de | ||
| 
						 | 
					59d0a77862 | ||
| 
						 | 
					987526d1e5 | 
@@ -4,5 +4,19 @@ 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
 | 
			
		||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
 | 
			
		||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
 | 
			
		||||
 | 
			
		||||
# set this only if using the collaboration workflow we use on excalidraw.com
 | 
			
		||||
REACT_APP_PORTAL_URL=
 | 
			
		||||
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
 | 
			
		||||
 | 
			
		||||
# put these in your .env.local, or make sure you don't commit!
 | 
			
		||||
# must be lowercase `true` when turned on
 | 
			
		||||
#
 | 
			
		||||
# whether to enable Service Workers in development
 | 
			
		||||
REACT_APP_DEV_ENABLE_SW=
 | 
			
		||||
# whether to disable live reload / HMR. Usuaully what you want to do when
 | 
			
		||||
# debugging Service Workers.
 | 
			
		||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,14 @@ 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_PORTAL_URL=https://portal.excalidraw.com
 | 
			
		||||
# Fill to set socket server URL used for collaboration.
 | 
			
		||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
 | 
			
		||||
REACT_APP_WS_SERVER_URL=
 | 
			
		||||
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 | 
			
		||||
 | 
			
		||||
# production-only vars
 | 
			
		||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
			
		||||
 | 
			
		||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,37 +0,0 @@
 | 
			
		||||
version: 2
 | 
			
		||||
updates:
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: weekly
 | 
			
		||||
      day: sunday
 | 
			
		||||
      time: "01:00"
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - lipis
 | 
			
		||||
    assignees:
 | 
			
		||||
      - lipis
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/excalidraw/
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: weekly
 | 
			
		||||
      day: sunday
 | 
			
		||||
      time: "01:00"
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/utils/
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: weekly
 | 
			
		||||
      day: sunday
 | 
			
		||||
      time: "01:00"
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,4 @@
 | 
			
		||||
name: Auto release @excalidraw/excalidraw-next
 | 
			
		||||
name: Auto release excalidraw next
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,4 @@
 | 
			
		||||
name: Auto release preview @excalidraw/excalidraw-preview
 | 
			
		||||
name: Auto release excalidraw preview
 | 
			
		||||
on:
 | 
			
		||||
  issue_comment:
 | 
			
		||||
    types: [created, edited]
 | 
			
		||||
@@ -6,7 +6,7 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  Auto-release-excalidraw-preview:
 | 
			
		||||
    name: Auto release preview
 | 
			
		||||
    if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
 | 
			
		||||
    if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: React to release comment
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								.github/workflows/build-packages.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/build-packages.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,29 +0,0 @@
 | 
			
		||||
name: Build packages
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - master
 | 
			
		||||
  pull_request:
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  packages:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v2
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          yarn --frozen-lockfile
 | 
			
		||||
          yarn --cwd src/packages/excalidraw
 | 
			
		||||
          yarn --cwd src/packages/utils
 | 
			
		||||
      - name: Build @excalidraw/excalidraw
 | 
			
		||||
        run: |
 | 
			
		||||
          yarn --cwd src/packages/excalidraw run pack
 | 
			
		||||
      - name: Build @excalidraw/utils
 | 
			
		||||
        run: |
 | 
			
		||||
          yarn --cwd src/packages/utils run pack
 | 
			
		||||
							
								
								
									
										15
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -10,11 +10,16 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: docker/build-push-action@v2
 | 
			
		||||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
      - name: Login to DockerHub
 | 
			
		||||
        uses: docker/login-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
          repository: excalidraw/excalidraw
 | 
			
		||||
          tag_with_ref: true
 | 
			
		||||
          tag_with_sha: true
 | 
			
		||||
      - name: Build and push
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          context: .
 | 
			
		||||
          push: true
 | 
			
		||||
          tags: excalidraw/excalidraw:latest
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -19,7 +19,6 @@ logs
 | 
			
		||||
node_modules
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
package-lock.json
 | 
			
		||||
static
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										47
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								README.md
									
									
									
									
									
								
							@@ -32,6 +32,10 @@ Last but not least, we're thankful to these companies for offering their service
 | 
			
		||||
 | 
			
		||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
 | 
			
		||||
 | 
			
		||||
## Who's integrating Excalidraw
 | 
			
		||||
 | 
			
		||||
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
### Shortcuts
 | 
			
		||||
@@ -124,14 +128,41 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
 | 
			
		||||
 | 
			
		||||
#### Commands
 | 
			
		||||
 | 
			
		||||
| Command            | Description                       |
 | 
			
		||||
| ------------------ | --------------------------------- |
 | 
			
		||||
| `yarn`             | Install the dependencies          |
 | 
			
		||||
| `yarn start`       | Run the project                   |
 | 
			
		||||
| `yarn fix`         | Reformat all files with Prettier  |
 | 
			
		||||
| `yarn test`        | Run tests                         |
 | 
			
		||||
| `yarn test:update` | Update test snapshots             |
 | 
			
		||||
| `yarn test:code`   | Test for formatting with Prettier |
 | 
			
		||||
##### Install the dependencies
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Run the project
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn start
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Reformat all files with Prettier
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn fix
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Run tests
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn test
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Update test snapshots
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn test:update
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Test for formatting with Prettier
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn test:code
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Docker Compose
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								dev-docs/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								dev-docs/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
# Dependencies
 | 
			
		||||
/node_modules
 | 
			
		||||
 | 
			
		||||
# Production
 | 
			
		||||
/build
 | 
			
		||||
 | 
			
		||||
# Generated files
 | 
			
		||||
.docusaurus
 | 
			
		||||
.cache-loader
 | 
			
		||||
 | 
			
		||||
# Misc
 | 
			
		||||
.DS_Store
 | 
			
		||||
.env.local
 | 
			
		||||
.env.development.local
 | 
			
		||||
.env.test.local
 | 
			
		||||
.env.production.local
 | 
			
		||||
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
							
								
								
									
										41
									
								
								dev-docs/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								dev-docs/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
# Website
 | 
			
		||||
 | 
			
		||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
 | 
			
		||||
 | 
			
		||||
### Installation
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ yarn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Local Development
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ yarn start
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
 | 
			
		||||
 | 
			
		||||
### Build
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ yarn build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
 | 
			
		||||
 | 
			
		||||
### Deployment
 | 
			
		||||
 | 
			
		||||
Using SSH:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ USE_SSH=true yarn deploy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Not using SSH:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ GIT_USER=<Your GitHub username> yarn deploy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
 | 
			
		||||
							
								
								
									
										3
									
								
								dev-docs/babel.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								dev-docs/babel.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
  presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										6
									
								
								dev-docs/docs/codebase/overview.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								dev-docs/docs/codebase/overview.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
---
 | 
			
		||||
sidebar_position: 1
 | 
			
		||||
title: Overview
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).
 | 
			
		||||
							
								
								
									
										8
									
								
								dev-docs/docs/get-started.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								dev-docs/docs/get-started.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
---
 | 
			
		||||
sidebar_position: 1
 | 
			
		||||
title: Introduction
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
 | 
			
		||||
 | 
			
		||||
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).
 | 
			
		||||
							
								
								
									
										6
									
								
								dev-docs/docs/package/overview.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								dev-docs/docs/package/overview.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
---
 | 
			
		||||
sidebar_position: 1
 | 
			
		||||
title: Overview
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).
 | 
			
		||||
							
								
								
									
										121
									
								
								dev-docs/docusaurus.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								dev-docs/docusaurus.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,121 @@
 | 
			
		||||
// @ts-check
 | 
			
		||||
// Note: type annotations allow type checking and IDEs autocompletion
 | 
			
		||||
 | 
			
		||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
 | 
			
		||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
 | 
			
		||||
 | 
			
		||||
/** @type {import('@docusaurus/types').Config} */
 | 
			
		||||
const config = {
 | 
			
		||||
  title: "Excalidraw developer docs",
 | 
			
		||||
  tagline:
 | 
			
		||||
    "For Excalidraw contributors or those integrating the Excalidraw editor",
 | 
			
		||||
  url: "https://docs.excalidraw.com.com",
 | 
			
		||||
  baseUrl: "/",
 | 
			
		||||
  onBrokenLinks: "throw",
 | 
			
		||||
  onBrokenMarkdownLinks: "warn",
 | 
			
		||||
  favicon: "img/favicon.ico",
 | 
			
		||||
  organizationName: "Excalidraw", // Usually your GitHub org/user name.
 | 
			
		||||
  projectName: "excalidraw", // Usually your repo name.
 | 
			
		||||
 | 
			
		||||
  // Even if you don't use internalization, you can use this field to set useful
 | 
			
		||||
  // metadata like html lang. For example, if your site is Chinese, you may want
 | 
			
		||||
  // to replace "en" with "zh-Hans".
 | 
			
		||||
  i18n: {
 | 
			
		||||
    defaultLocale: "en",
 | 
			
		||||
    locales: ["en"],
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  presets: [
 | 
			
		||||
    [
 | 
			
		||||
      "classic",
 | 
			
		||||
      /** @type {import('@docusaurus/preset-classic').Options} */
 | 
			
		||||
      ({
 | 
			
		||||
        docs: {
 | 
			
		||||
          sidebarPath: require.resolve("./sidebars.js"),
 | 
			
		||||
          // Please change this to your repo.
 | 
			
		||||
          editUrl: "https://github.com/excalidraw/docs/tree/master/",
 | 
			
		||||
        },
 | 
			
		||||
        theme: {
 | 
			
		||||
          customCss: require.resolve("./src/css/custom.css"),
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    ],
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  themeConfig:
 | 
			
		||||
    /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
 | 
			
		||||
    ({
 | 
			
		||||
      navbar: {
 | 
			
		||||
        title: "Excalidraw Docs",
 | 
			
		||||
        logo: {
 | 
			
		||||
          alt: "Excalidraw Logo",
 | 
			
		||||
          src: "img/logo.svg",
 | 
			
		||||
        },
 | 
			
		||||
        items: [
 | 
			
		||||
          {
 | 
			
		||||
            type: "doc",
 | 
			
		||||
            docId: "get-started",
 | 
			
		||||
            position: "left",
 | 
			
		||||
            label: "Get started",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            to: "https://blog.excalidraw.com",
 | 
			
		||||
            label: "Blog",
 | 
			
		||||
            position: "left",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            to: "https://github.com/excalidraw/excalidraw",
 | 
			
		||||
            label: "GitHub",
 | 
			
		||||
            position: "right",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      },
 | 
			
		||||
      footer: {
 | 
			
		||||
        style: "dark",
 | 
			
		||||
        links: [
 | 
			
		||||
          {
 | 
			
		||||
            title: "Docs",
 | 
			
		||||
            items: [
 | 
			
		||||
              {
 | 
			
		||||
                label: "Get Started",
 | 
			
		||||
                to: "/docs/get-started",
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            title: "Community",
 | 
			
		||||
            items: [
 | 
			
		||||
              {
 | 
			
		||||
                label: "Discord",
 | 
			
		||||
                href: "https://discord.gg/UexuTaE",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                label: "Twitter",
 | 
			
		||||
                href: "https://twitter.com/excalidraw",
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            title: "More",
 | 
			
		||||
            items: [
 | 
			
		||||
              {
 | 
			
		||||
                label: "Blog",
 | 
			
		||||
                to: "https://blog.excalidraw.com",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                label: "GitHub",
 | 
			
		||||
                to: "https://github.com/excalidraw/excalidraw",
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        copyright: `Made with ❤️ Built with Docusaurus`,
 | 
			
		||||
      },
 | 
			
		||||
      prism: {
 | 
			
		||||
        theme: lightCodeTheme,
 | 
			
		||||
        darkTheme: darkCodeTheme,
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = config;
 | 
			
		||||
							
								
								
									
										46
									
								
								dev-docs/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								dev-docs/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "docs",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "docusaurus": "docusaurus",
 | 
			
		||||
    "start": "docusaurus start --port 3003",
 | 
			
		||||
    "build": "docusaurus build",
 | 
			
		||||
    "swizzle": "docusaurus swizzle",
 | 
			
		||||
    "deploy": "docusaurus deploy",
 | 
			
		||||
    "clear": "docusaurus clear",
 | 
			
		||||
    "serve": "docusaurus serve",
 | 
			
		||||
    "write-translations": "docusaurus write-translations",
 | 
			
		||||
    "write-heading-ids": "docusaurus write-heading-ids",
 | 
			
		||||
    "typecheck": "tsc"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@docusaurus/core": "2.0.0-rc.1",
 | 
			
		||||
    "@docusaurus/preset-classic": "2.0.0-rc.1",
 | 
			
		||||
    "@mdx-js/react": "^1.6.22",
 | 
			
		||||
    "clsx": "^1.2.1",
 | 
			
		||||
    "prism-react-renderer": "^1.3.5",
 | 
			
		||||
    "react": "^17.0.2",
 | 
			
		||||
    "react-dom": "^17.0.2"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@docusaurus/module-type-aliases": "2.0.0-rc.1",
 | 
			
		||||
    "@tsconfig/docusaurus": "^1.0.5",
 | 
			
		||||
    "typescript": "^4.7.4"
 | 
			
		||||
  },
 | 
			
		||||
  "browserslist": {
 | 
			
		||||
    "production": [
 | 
			
		||||
      ">0.5%",
 | 
			
		||||
      "not dead",
 | 
			
		||||
      "not op_mini all"
 | 
			
		||||
    ],
 | 
			
		||||
    "development": [
 | 
			
		||||
      "last 1 chrome version",
 | 
			
		||||
      "last 1 firefox version",
 | 
			
		||||
      "last 1 safari version"
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=16.14"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								dev-docs/sidebars.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								dev-docs/sidebars.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Creating a sidebar enables you to:
 | 
			
		||||
 - create an ordered group of docs
 | 
			
		||||
 - render a sidebar for each doc of that group
 | 
			
		||||
 - provide next/previous navigation
 | 
			
		||||
 | 
			
		||||
 The sidebars can be generated from the filesystem, or explicitly defined here.
 | 
			
		||||
 | 
			
		||||
 Create as many sidebars as you want.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// @ts-check
 | 
			
		||||
 | 
			
		||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
 | 
			
		||||
const sidebars = {
 | 
			
		||||
  // By default, Docusaurus generates a sidebar from the docs folder structure
 | 
			
		||||
  tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
 | 
			
		||||
 | 
			
		||||
  // But you can create a sidebar manually
 | 
			
		||||
  /*
 | 
			
		||||
  tutorialSidebar: [
 | 
			
		||||
    {
 | 
			
		||||
      type: 'category',
 | 
			
		||||
      label: 'Tutorial',
 | 
			
		||||
      items: ['hello'],
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
   */
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = sidebars;
 | 
			
		||||
							
								
								
									
										62
									
								
								dev-docs/src/components/Homepage/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								dev-docs/src/components/Homepage/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import styles from "./styles.module.css";
 | 
			
		||||
 | 
			
		||||
const FeatureList = [
 | 
			
		||||
  {
 | 
			
		||||
    title: "Learn how Excalidraw works",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_innovative.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>Want to contribute to Excalidraw but got lost in the codebase?</>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Integrate Excalidraw",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Want to build your own app powered by Excalidraw by don't know where to
 | 
			
		||||
        start?
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Help us improve",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_add_files.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Are the docs missing something? Anything you had trouble understanding
 | 
			
		||||
        or needs an explanation? Come contribute to the docs to make them even
 | 
			
		||||
        better!
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
function Feature({ Svg, title, description }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx("col col--4")}>
 | 
			
		||||
      <div className="text--center">
 | 
			
		||||
        <Svg className={styles.featureSvg} role="img" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="text--center padding-horiz--md">
 | 
			
		||||
        <h3>{title}</h3>
 | 
			
		||||
        <p>{description}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function HomepageFeatures() {
 | 
			
		||||
  return (
 | 
			
		||||
    <section className={styles.features}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="row">
 | 
			
		||||
          {FeatureList.map((props, idx) => (
 | 
			
		||||
            <Feature key={idx} {...props} />
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										70
									
								
								dev-docs/src/components/Homepage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								dev-docs/src/components/Homepage/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import styles from "./styles.module.css";
 | 
			
		||||
 | 
			
		||||
type FeatureItem = {
 | 
			
		||||
  title: string;
 | 
			
		||||
  Svg: React.ComponentType<React.ComponentProps<"svg">>;
 | 
			
		||||
  description: JSX.Element;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const FeatureList: FeatureItem[] = [
 | 
			
		||||
  {
 | 
			
		||||
    title: "Easy to Use",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Docusaurus was designed from the ground up to be easily installed and
 | 
			
		||||
        used to get your website up and running quickly.
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Focus on What Matters",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Docusaurus lets you focus on your docs, and we'll do the chores. Go
 | 
			
		||||
        ahead and move your docs into the <code>docs</code> directory.
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Powered by React",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Extend or customize your website layout by reusing React. Docusaurus can
 | 
			
		||||
        be extended while reusing the same header and footer.
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
function Feature({ title, Svg, description }: FeatureItem) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx("col col--4")}>
 | 
			
		||||
      <div className="text--center">
 | 
			
		||||
        <Svg className={styles.featureSvg} role="img" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="text--center padding-horiz--md">
 | 
			
		||||
        <h3>{title}</h3>
 | 
			
		||||
        <p>{description}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function HomepageFeatures(): JSX.Element {
 | 
			
		||||
  return (
 | 
			
		||||
    <section className={styles.features}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="row">
 | 
			
		||||
          {FeatureList.map((props, idx) => (
 | 
			
		||||
            <Feature key={idx} {...props} />
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								dev-docs/src/components/Homepage/styles.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								dev-docs/src/components/Homepage/styles.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
.features {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  padding: 2rem 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.featureSvg {
 | 
			
		||||
  height: 200px;
 | 
			
		||||
  width: 200px;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								dev-docs/src/css/custom.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								dev-docs/src/css/custom.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Any CSS included here will be global. The classic template
 | 
			
		||||
 * bundles Infima by default. Infima is a CSS framework designed to
 | 
			
		||||
 * work well for content-centric websites.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* You can override the default Infima variables here. */
 | 
			
		||||
:root {
 | 
			
		||||
  --ifm-color-primary: #6965db;
 | 
			
		||||
  --ifm-color-primary-dark: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-darker: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-darkest: #4a47b1;
 | 
			
		||||
  --ifm-color-primary-light: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-lighter: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-lightest: #5b57d1;
 | 
			
		||||
  --ifm-code-font-size: 95%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
 | 
			
		||||
[data-theme="dark"] {
 | 
			
		||||
  --ifm-color-primary: #5650f0;
 | 
			
		||||
  --ifm-color-primary-dark: #4b46d8;
 | 
			
		||||
  --ifm-color-primary-darker: #4b46d8;
 | 
			
		||||
  --ifm-color-primary-darkest: #3e39be;
 | 
			
		||||
  --ifm-color-primary-light: #3f3d64;
 | 
			
		||||
  --ifm-color-primary-lighter: #3f3d64;
 | 
			
		||||
  --ifm-color-primary-lightest: #3f3d64;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.docusaurus-highlight-code-line {
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.1);
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin: 0 calc(-1 * var(--ifm-pre-padding));
 | 
			
		||||
  padding: 0 var(--ifm-pre-padding);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .docusaurus-highlight-code-line {
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.3);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .navbar__logo {
 | 
			
		||||
  filter: invert(93%) hue-rotate(180deg);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								dev-docs/src/pages/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								dev-docs/src/pages/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import Layout from "@theme/Layout";
 | 
			
		||||
import Link from "@docusaurus/Link";
 | 
			
		||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
 | 
			
		||||
import styles from "./index.module.css";
 | 
			
		||||
import HomepageFeatures from "@site/src/components/Homepage";
 | 
			
		||||
 | 
			
		||||
function HomepageHeader() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <header className={clsx("hero hero--primary", styles.heroBanner)}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <h1 className="hero__title">{siteConfig.title}</h1>
 | 
			
		||||
        <p className="hero__subtitle">{siteConfig.tagline}</p>
 | 
			
		||||
        <div className={styles.buttons}>
 | 
			
		||||
          <Link
 | 
			
		||||
            className="button button--secondary button--lg"
 | 
			
		||||
            to="/docs/get-started"
 | 
			
		||||
          >
 | 
			
		||||
            Get started
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </header>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <Layout
 | 
			
		||||
      title={`Hello from ${siteConfig.title}`}
 | 
			
		||||
      description="Description will go into a meta tag in <head />"
 | 
			
		||||
    >
 | 
			
		||||
      <HomepageHeader />
 | 
			
		||||
      <main>
 | 
			
		||||
        <HomepageFeatures />
 | 
			
		||||
      </main>
 | 
			
		||||
    </Layout>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								dev-docs/src/pages/index.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								dev-docs/src/pages/index.module.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
/**
 | 
			
		||||
 * CSS files with the .module.css suffix will be treated as CSS modules
 | 
			
		||||
 * and scoped locally.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
.heroBanner {
 | 
			
		||||
  padding: 4rem 0;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .heroBanner {
 | 
			
		||||
  color: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: 996px) {
 | 
			
		||||
  .heroBanner {
 | 
			
		||||
    padding: 2rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.buttons {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								dev-docs/src/pages/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								dev-docs/src/pages/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import Layout from "@theme/Layout";
 | 
			
		||||
import Link from "@docusaurus/Link";
 | 
			
		||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
 | 
			
		||||
import styles from "./index.module.css";
 | 
			
		||||
import HomepageFeatures from "@site/src/components/Homepage";
 | 
			
		||||
 | 
			
		||||
function HomepageHeader() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <header className={clsx("hero hero--primary", styles.heroBanner)}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <h1 className="hero__title">{siteConfig.title}</h1>
 | 
			
		||||
        <p className="hero__subtitle">{siteConfig.tagline}</p>
 | 
			
		||||
        <div className={styles.buttons}>
 | 
			
		||||
          <Link
 | 
			
		||||
            className="button button--secondary button--lg"
 | 
			
		||||
            to="/docs/get-started"
 | 
			
		||||
          >
 | 
			
		||||
            Get started
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </header>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <Layout
 | 
			
		||||
      title={`Hello from ${siteConfig.title}`}
 | 
			
		||||
      description="Description will go into a meta tag in <head />"
 | 
			
		||||
    >
 | 
			
		||||
      <HomepageHeader />
 | 
			
		||||
      <main>
 | 
			
		||||
        <HomepageFeatures />
 | 
			
		||||
      </main>
 | 
			
		||||
    </Layout>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								dev-docs/src/pages/markdown-page.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								dev-docs/src/pages/markdown-page.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
---
 | 
			
		||||
title: Markdown page example
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
# Markdown page example
 | 
			
		||||
 | 
			
		||||
You don't need React to write simple standalone pages.
 | 
			
		||||
							
								
								
									
										0
									
								
								dev-docs/static/.nojekyll
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								dev-docs/static/.nojekyll
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										
											BIN
										
									
								
								dev-docs/static/img/docusaurus.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								dev-docs/static/img/docusaurus.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 5.0 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								dev-docs/static/img/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								dev-docs/static/img/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 26 KiB  | 
							
								
								
									
										4
									
								
								dev-docs/static/img/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								dev-docs/static/img/logo.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
 | 
			
		||||
	<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
 | 
			
		||||
	<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.4 KiB  | 
							
								
								
									
										1
									
								
								dev-docs/static/img/undraw_add_files.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								dev-docs/static/img/undraw_add_files.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 5.7 KiB  | 
							
								
								
									
										1
									
								
								dev-docs/static/img/undraw_blank_canvas.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								dev-docs/static/img/undraw_blank_canvas.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 12 KiB  | 
							
								
								
									
										1
									
								
								dev-docs/static/img/undraw_innovative.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								dev-docs/static/img/undraw_innovative.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 5.4 KiB  | 
							
								
								
									
										7
									
								
								dev-docs/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								dev-docs/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  // This file is not used in compilation. It is here just for a nice editor experience.
 | 
			
		||||
  "extends": "@tsconfig/docusaurus/tsconfig.json",
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "baseUrl": "."
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7489
									
								
								dev-docs/yarn.lock
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7489
									
								
								dev-docs/yarn.lock
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,12 +1,11 @@
 | 
			
		||||
rules_version = '2';
 | 
			
		||||
service firebase.storage {
 | 
			
		||||
  match /b/{bucket}/o {
 | 
			
		||||
    match /{migrations} {
 | 
			
		||||
      match /{scenes}/{scene} {
 | 
			
		||||
      	allow get, write: if true;
 | 
			
		||||
        // redundant, but let's be explicit'
 | 
			
		||||
        allow list: if false;
 | 
			
		||||
      }
 | 
			
		||||
    match /{files}/rooms/{room}/{file} {
 | 
			
		||||
    	allow get, write: if true;
 | 
			
		||||
    }
 | 
			
		||||
    match /{files}/shareLinks/{shareLink}/{file} {
 | 
			
		||||
    	allow get, write: if true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										52
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										52
									
								
								package.json
									
									
									
									
									
								
							@@ -22,22 +22,25 @@
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "@testing-library/jest-dom": "5.16.2",
 | 
			
		||||
    "@testing-library/react": "12.1.2",
 | 
			
		||||
    "@tldraw/vec": "1.4.3",
 | 
			
		||||
    "@testing-library/react": "12.1.5",
 | 
			
		||||
    "@tldraw/vec": "1.7.1",
 | 
			
		||||
    "@types/jest": "27.4.0",
 | 
			
		||||
    "@types/pica": "5.1.3",
 | 
			
		||||
    "@types/react": "17.0.38",
 | 
			
		||||
    "@types/react-dom": "17.0.11",
 | 
			
		||||
    "@types/react": "18.0.15",
 | 
			
		||||
    "@types/react-dom": "18.0.6",
 | 
			
		||||
    "@types/socket.io-client": "1.4.36",
 | 
			
		||||
    "browser-fs-access": "0.23.0",
 | 
			
		||||
    "browser-fs-access": "0.29.1",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "cross-env": "7.0.3",
 | 
			
		||||
    "fake-indexeddb": "3.1.7",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.2",
 | 
			
		||||
    "http-server": "14.1.1",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.4",
 | 
			
		||||
    "idb-keyval": "6.0.3",
 | 
			
		||||
    "image-blob-reduce": "3.0.1",
 | 
			
		||||
    "jotai": "1.6.4",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "3.1.32",
 | 
			
		||||
    "nanoid": "3.3.3",
 | 
			
		||||
    "open-color": "1.9.1",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "perfect-freehand": "1.0.16",
 | 
			
		||||
@@ -46,11 +49,11 @@
 | 
			
		||||
    "png-chunks-extract": "1.0.0",
 | 
			
		||||
    "points-on-curve": "0.2.0",
 | 
			
		||||
    "pwacompat": "2.0.17",
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react": "18.2.0",
 | 
			
		||||
    "react-dom": "18.2.0",
 | 
			
		||||
    "react-scripts": "4.0.3",
 | 
			
		||||
    "roughjs": "4.5.2",
 | 
			
		||||
    "sass": "1.49.7",
 | 
			
		||||
    "sass": "1.51.0",
 | 
			
		||||
    "socket.io-client": "2.3.1",
 | 
			
		||||
    "typescript": "4.5.5"
 | 
			
		||||
  },
 | 
			
		||||
@@ -58,20 +61,19 @@
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
			
		||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
			
		||||
    "@types/chai": "4.3.0",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.7",
 | 
			
		||||
    "@types/pako": "1.0.3",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.6",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.7",
 | 
			
		||||
    "chai": "4.3.6",
 | 
			
		||||
    "dotenv": "10.0.0",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "dotenv": "16.0.1",
 | 
			
		||||
    "eslint-config-prettier": "8.5.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "firebase-tools": "9.23.0",
 | 
			
		||||
    "husky": "7.0.4",
 | 
			
		||||
    "jest-canvas-mock": "2.3.1",
 | 
			
		||||
    "lint-staged": "12.3.3",
 | 
			
		||||
    "jest-canvas-mock": "2.4.0",
 | 
			
		||||
    "lint-staged": "12.3.7",
 | 
			
		||||
    "pepjs": "0.5.3",
 | 
			
		||||
    "prettier": "2.5.1",
 | 
			
		||||
    "rewire": "5.0.0"
 | 
			
		||||
    "prettier": "2.6.2",
 | 
			
		||||
    "rewire": "6.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "resolutions": {
 | 
			
		||||
    "@typescript-eslint/typescript-estree": "5.10.2"
 | 
			
		||||
@@ -91,10 +93,11 @@
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build-node": "node ./scripts/build-node.js",
 | 
			
		||||
    "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
 | 
			
		||||
    "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
 | 
			
		||||
    "build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true react-scripts build",
 | 
			
		||||
    "build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
 | 
			
		||||
    "build:version": "node ./scripts/build-version.js",
 | 
			
		||||
    "build": "yarn build:app && yarn build:version",
 | 
			
		||||
    "build:prebuild": "node ./scripts/prebuild.js",
 | 
			
		||||
    "build": "yarn build:prebuild && yarn build:app && yarn build:version",
 | 
			
		||||
    "eject": "react-scripts eject",
 | 
			
		||||
    "fix:code": "yarn test:code --fix",
 | 
			
		||||
    "fix:other": "yarn prettier --write",
 | 
			
		||||
@@ -104,6 +107,7 @@
 | 
			
		||||
    "prepare": "husky install",
 | 
			
		||||
    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
 | 
			
		||||
    "start": "react-scripts start",
 | 
			
		||||
    "start:build": "npm run build && npx http-server build -a localhost -p 3001 -o",
 | 
			
		||||
    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
 | 
			
		||||
    "test:app": "react-scripts test --passWithNoTests",
 | 
			
		||||
    "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
 | 
			
		||||
@@ -112,6 +116,8 @@
 | 
			
		||||
    "test:typecheck": "tsc",
 | 
			
		||||
    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
 | 
			
		||||
    "test": "yarn test:app",
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js"
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js",
 | 
			
		||||
    "prerelease": "node scripts/prerelease.js",
 | 
			
		||||
    "release": "node scripts/release.js"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,25 @@
 | 
			
		||||
      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <script>
 | 
			
		||||
      // Redirect Excalidraw+ users which have auto-redirect enabled.
 | 
			
		||||
      //
 | 
			
		||||
      // Redirect only the bare root path, so link/room/library urls are not
 | 
			
		||||
      // redirected.
 | 
			
		||||
      //
 | 
			
		||||
      // Putting into index.html for best performance (can't redirect on server
 | 
			
		||||
      // due to location.hash checks).
 | 
			
		||||
      if (
 | 
			
		||||
        window.location.pathname === "/" &&
 | 
			
		||||
        !window.location.hash &&
 | 
			
		||||
        !window.location.search &&
 | 
			
		||||
        // if its present redirect
 | 
			
		||||
        document.cookie.includes("excplus-autoredirect=true")
 | 
			
		||||
      ) {
 | 
			
		||||
        window.location.href = "https://app.excalidraw.com";
 | 
			
		||||
      }
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
 | 
			
		||||
 | 
			
		||||
    <!-- Excalidraw version -->
 | 
			
		||||
@@ -72,12 +91,6 @@
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link
 | 
			
		||||
      href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
 | 
			
		||||
      rel="preconnect"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link
 | 
			
		||||
      rel="manifest"
 | 
			
		||||
      href="manifest.json"
 | 
			
		||||
@@ -85,6 +98,22 @@
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link rel="stylesheet" href="fonts.css" type="text/css" />
 | 
			
		||||
    <% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
 | 
			
		||||
    <script>
 | 
			
		||||
      {
 | 
			
		||||
        const _WebSocket = window.WebSocket;
 | 
			
		||||
        window.WebSocket = function (url) {
 | 
			
		||||
          if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
 | 
			
		||||
            console.info(
 | 
			
		||||
              "[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
 | 
			
		||||
            );
 | 
			
		||||
          } else {
 | 
			
		||||
            return new _WebSocket(url);
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    </script>
 | 
			
		||||
    <% } %>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.EXCALIDRAW_ASSET_PATH = "/";
 | 
			
		||||
      // setting this so that libraries installation reuses this window tab.
 | 
			
		||||
@@ -130,26 +159,6 @@
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        z-index: 999;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        pointer-events: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage span {
 | 
			
		||||
        background-color: var(--button-gray-1);
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        padding: 0.8em 1.2em;
 | 
			
		||||
        color: var(--popup-text-color);
 | 
			
		||||
        font-size: 1.3em;
 | 
			
		||||
      }
 | 
			
		||||
      #root {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        -webkit-touch-callout: none;
 | 
			
		||||
@@ -158,8 +167,10 @@
 | 
			
		||||
        -moz-user-select: none;
 | 
			
		||||
        -ms-user-select: none;
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
        @media screen and (min-width: 1200px) {
 | 
			
		||||
      @media screen and (min-width: 1200px) {
 | 
			
		||||
        #root {
 | 
			
		||||
          -webkit-touch-callout: default;
 | 
			
		||||
          -webkit-user-select: auto;
 | 
			
		||||
          -khtml-user-select: auto;
 | 
			
		||||
@@ -176,10 +187,6 @@
 | 
			
		||||
    <header>
 | 
			
		||||
      <h1 class="visually-hidden">Excalidraw</h1>
 | 
			
		||||
    </header>
 | 
			
		||||
    <div id="root">
 | 
			
		||||
      <div class="LoadingMessage">
 | 
			
		||||
        <span>Loading scene...</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -17,11 +17,23 @@
 | 
			
		||||
 * See https://goo.gl/2aRDsh
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
importScripts("/workbox/workbox-sw.js");
 | 
			
		||||
// in dev, `process` is undefined because this file is not compiled until build
 | 
			
		||||
const IS_DEVELOPMENT =
 | 
			
		||||
  typeof process === "undefined" || process.env.NODE_ENV !== "production";
 | 
			
		||||
 | 
			
		||||
workbox.setConfig({
 | 
			
		||||
  modulePathPrefix: "/workbox/",
 | 
			
		||||
});
 | 
			
		||||
if (IS_DEVELOPMENT) {
 | 
			
		||||
  importScripts(
 | 
			
		||||
    "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
 | 
			
		||||
  );
 | 
			
		||||
  workbox.setConfig({
 | 
			
		||||
    debug: true,
 | 
			
		||||
  });
 | 
			
		||||
} else {
 | 
			
		||||
  importScripts("/workbox/workbox-sw.js");
 | 
			
		||||
  workbox.setConfig({
 | 
			
		||||
    modulePathPrefix: "/workbox/",
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
self.addEventListener("message", (event) => {
 | 
			
		||||
  if (event.data && event.data.type === "SKIP_WAITING") {
 | 
			
		||||
@@ -30,14 +42,17 @@ self.addEventListener("message", (event) => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
workbox.core.clientsClaim();
 | 
			
		||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
 | 
			
		||||
 | 
			
		||||
workbox.routing.registerNavigationRoute(
 | 
			
		||||
  workbox.precaching.getCacheKeyForURL("./index.html"),
 | 
			
		||||
  {
 | 
			
		||||
    blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
if (!IS_DEVELOPMENT) {
 | 
			
		||||
  workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
 | 
			
		||||
 | 
			
		||||
  workbox.routing.registerNavigationRoute(
 | 
			
		||||
    workbox.precaching.getCacheKeyForURL("./index.html"),
 | 
			
		||||
    {
 | 
			
		||||
      blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Cache relevant font files
 | 
			
		||||
workbox.routing.registerRoute(
 | 
			
		||||
@@ -5,22 +5,25 @@ const core = require("@actions/core");
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
const pkg = require(excalidrawPackage);
 | 
			
		||||
const isPreview = process.argv.slice(2)[0] === "preview";
 | 
			
		||||
 | 
			
		||||
const getShortCommitHash = () => {
 | 
			
		||||
  return execSync("git rev-parse --short HEAD").toString().trim();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const publish = () => {
 | 
			
		||||
  const tag = isPreview ? "preview" : "next";
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    execSync(`yarn  --frozen-lockfile`);
 | 
			
		||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
			
		||||
    console.info("Published 🎉");
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
 | 
			
		||||
    console.info(`Published ${pkg.name}@${tag}🎉`);
 | 
			
		||||
    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!`,
 | 
			
		||||
    You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
 | 
			
		||||
    );
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    core.setOutput("result", "package couldn't be published :warning:!");
 | 
			
		||||
@@ -51,27 +54,19 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // update package.json
 | 
			
		||||
  pkg.name = "@excalidraw/excalidraw-next";
 | 
			
		||||
  let version = `${pkg.version}-${getShortCommitHash()}`;
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
  const isPreview = process.argv.slice(2)[0] === "preview";
 | 
			
		||||
  if (isPreview) {
 | 
			
		||||
    // use pullNumber-commithash as the version for preview
 | 
			
		||||
    const pullRequestNumber = process.argv.slice(3)[0];
 | 
			
		||||
    version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
 | 
			
		||||
    // replace "excalidraw-next" with "excalidraw-preview"
 | 
			
		||||
    pkg.name = "@excalidraw/excalidraw-preview";
 | 
			
		||||
    data = data.replace(/excalidraw-next/g, "excalidraw-preview");
 | 
			
		||||
    data = data.trim();
 | 
			
		||||
  }
 | 
			
		||||
  pkg.version = version;
 | 
			
		||||
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
			
		||||
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
  console.info("Publish in progress...");
 | 
			
		||||
  publish();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								scripts/buildDocs.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								scripts/buildDocs.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
const { exec } = require("child_process");
 | 
			
		||||
 | 
			
		||||
// get files changed between prev and head commit
 | 
			
		||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
			
		||||
  if (error || stderr) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
  const changedFiles = stdout.trim().split("\n");
 | 
			
		||||
 | 
			
		||||
  const docFiles = changedFiles.filter((file) => {
 | 
			
		||||
    return file.indexOf("docs") >= 0;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!docFiles.length) {
 | 
			
		||||
    console.info("Skipping building docs as no valid diff found");
 | 
			
		||||
    process.exit(0);
 | 
			
		||||
  }
 | 
			
		||||
  // Exit code 1 to build the docs in ignoredBuildStep
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
});
 | 
			
		||||
@@ -36,6 +36,7 @@ const crowdinMap = {
 | 
			
		||||
  "ru-RU": "en-ru",
 | 
			
		||||
  "si-LK": "en-silk",
 | 
			
		||||
  "sk-SK": "en-sk",
 | 
			
		||||
  "sl-SI": "en-sl",
 | 
			
		||||
  "sv-SE": "en-sv",
 | 
			
		||||
  "ta-IN": "en-ta",
 | 
			
		||||
  "tr-TR": "en-tr",
 | 
			
		||||
@@ -47,6 +48,8 @@ const crowdinMap = {
 | 
			
		||||
  "lv-LV": "en-lv",
 | 
			
		||||
  "cs-CZ": "en-cs",
 | 
			
		||||
  "kk-KZ": "en-kk",
 | 
			
		||||
  "vi-vn": "en-vi",
 | 
			
		||||
  "mr-in": "en-mr",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flags = {
 | 
			
		||||
@@ -86,6 +89,7 @@ const flags = {
 | 
			
		||||
  "ru-RU": "🇷🇺",
 | 
			
		||||
  "si-LK": "🇱🇰",
 | 
			
		||||
  "sk-SK": "🇸🇰",
 | 
			
		||||
  "sl-SI": "🇸🇮",
 | 
			
		||||
  "sv-SE": "🇸🇪",
 | 
			
		||||
  "ta-IN": "🇮🇳",
 | 
			
		||||
  "tr-TR": "🇹🇷",
 | 
			
		||||
@@ -93,6 +97,9 @@ const flags = {
 | 
			
		||||
  "zh-CN": "🇨🇳",
 | 
			
		||||
  "zh-HK": "🇭🇰",
 | 
			
		||||
  "zh-TW": "🇹🇼",
 | 
			
		||||
  "eu-ES": "🇪🇦",
 | 
			
		||||
  "vi-VN": "🇻🇳",
 | 
			
		||||
  "mr-IN": "🇮🇳",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const languages = {
 | 
			
		||||
@@ -133,6 +140,7 @@ const languages = {
 | 
			
		||||
  "ru-RU": "Русский",
 | 
			
		||||
  "si-LK": "සිංහල",
 | 
			
		||||
  "sk-SK": "Slovenčina",
 | 
			
		||||
  "sl-SI": "Slovenščina",
 | 
			
		||||
  "sv-SE": "Svenska",
 | 
			
		||||
  "ta-IN": "Tamil",
 | 
			
		||||
  "tr-TR": "Türkçe",
 | 
			
		||||
@@ -140,6 +148,8 @@ const languages = {
 | 
			
		||||
  "zh-CN": "简体中文",
 | 
			
		||||
  "zh-HK": "繁體中文 (香港)",
 | 
			
		||||
  "zh-TW": "繁體中文",
 | 
			
		||||
  "vi-VN": "Tiếng Việt",
 | 
			
		||||
  "mr-IN": "मराठी",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const percentages = fs.readFileSync(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								scripts/prebuild.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								scripts/prebuild.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const path = require("path");
 | 
			
		||||
 | 
			
		||||
// for development purposes we want to have the service-worker.js file
 | 
			
		||||
// accessible from the public folder. On build though, we need to compile it
 | 
			
		||||
// and CRA expects that file to be in src/ folder.
 | 
			
		||||
const moveServiceWorkerScript = () => {
 | 
			
		||||
  const oldPath = path.resolve(__dirname, "../public/service-worker.js");
 | 
			
		||||
  const newPath = path.resolve(__dirname, "../src/service-worker.js");
 | 
			
		||||
 | 
			
		||||
  fs.rename(oldPath, newPath, (error) => {
 | 
			
		||||
    if (error) {
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
    console.info("public/service-worker.js moved to src/");
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
if (process.env.CI) {
 | 
			
		||||
  moveServiceWorkerScript();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								scripts/prerelease.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								scripts/prerelease.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const util = require("util");
 | 
			
		||||
const exec = util.promisify(require("child_process").exec);
 | 
			
		||||
const updateChangelog = require("./updateChangelog");
 | 
			
		||||
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
 | 
			
		||||
const updatePackageVersion = (nextVersion) => {
 | 
			
		||||
  const pkg = require(excalidrawPackage);
 | 
			
		||||
  pkg.version = nextVersion;
 | 
			
		||||
  const content = `${JSON.stringify(pkg, null, 2)}\n`;
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, content, "utf-8");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const prerelease = async (nextVersion) => {
 | 
			
		||||
  try {
 | 
			
		||||
    await updateChangelog(nextVersion);
 | 
			
		||||
    updatePackageVersion(nextVersion);
 | 
			
		||||
    await exec(`git add -u`);
 | 
			
		||||
    await exec(
 | 
			
		||||
      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    console.info("Done!");
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const nextVersion = process.argv.slice(2)[0];
 | 
			
		||||
if (!nextVersion) {
 | 
			
		||||
  console.error("Pass the next version to release!");
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
}
 | 
			
		||||
prerelease(nextVersion);
 | 
			
		||||
@@ -1,39 +1,44 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const util = require("util");
 | 
			
		||||
const exec = util.promisify(require("child_process").exec);
 | 
			
		||||
const updateReadme = require("./updateReadme");
 | 
			
		||||
const updateChangelog = require("./updateChangelog");
 | 
			
		||||
const { execSync } = require("child_process");
 | 
			
		||||
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
const pkg = require(excalidrawPackage);
 | 
			
		||||
 | 
			
		||||
const updatePackageVersion = (nextVersion) => {
 | 
			
		||||
  const pkg = require(excalidrawPackage);
 | 
			
		||||
  pkg.version = nextVersion;
 | 
			
		||||
  const content = `${JSON.stringify(pkg, null, 2)}\n`;
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, content, "utf-8");
 | 
			
		||||
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
const updateReadme = () => {
 | 
			
		||||
  const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
 | 
			
		||||
 | 
			
		||||
  // remove note for stable readme
 | 
			
		||||
  const data = originalReadMe.slice(excalidrawIndex);
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const release = async (nextVersion) => {
 | 
			
		||||
const publish = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    updateReadme();
 | 
			
		||||
    await updateChangelog(nextVersion);
 | 
			
		||||
    updatePackageVersion(nextVersion);
 | 
			
		||||
    await exec(`git add -u`);
 | 
			
		||||
    await exec(
 | 
			
		||||
      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
 | 
			
		||||
    );
 | 
			
		||||
    /* eslint-disable no-console */
 | 
			
		||||
    console.log("Done!");
 | 
			
		||||
    execSync(`yarn  --frozen-lockfile`);
 | 
			
		||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const nextVersion = process.argv.slice(2)[0];
 | 
			
		||||
if (!nextVersion) {
 | 
			
		||||
  console.error("Pass the next version to release!");
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
}
 | 
			
		||||
release(nextVersion);
 | 
			
		||||
const release = () => {
 | 
			
		||||
  updateReadme();
 | 
			
		||||
  console.info("Note for stable readme removed");
 | 
			
		||||
 | 
			
		||||
  publish();
 | 
			
		||||
  console.info(`Published ${pkg.version}!`);
 | 
			
		||||
 | 
			
		||||
  // revert readme after release
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
 | 
			
		||||
  console.info("Readme reverted");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
release();
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ const headerForType = {
 | 
			
		||||
  perf: "Performance",
 | 
			
		||||
  build: "Build",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const badCommits = [];
 | 
			
		||||
const getCommitHashForLastVersion = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
 | 
			
		||||
@@ -53,19 +53,26 @@ const getLibraryCommitsSinceLastRelease = async () => {
 | 
			
		||||
    const messageWithoutType = commit.slice(indexOfColon + 1).trim();
 | 
			
		||||
    const messageWithCapitalizeFirst =
 | 
			
		||||
      messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
 | 
			
		||||
    const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
 | 
			
		||||
    const prMatch = commit.match(/\(#([0-9]*)\)/);
 | 
			
		||||
    if (prMatch) {
 | 
			
		||||
      const prNumber = prMatch[1];
 | 
			
		||||
 | 
			
		||||
    // return if the changelog already contains the pr number which would happen for package updates
 | 
			
		||||
    if (existingChangeLog.includes(prNumber)) {
 | 
			
		||||
      return;
 | 
			
		||||
      // return if the changelog already contains the pr number which would happen for package updates
 | 
			
		||||
      if (existingChangeLog.includes(prNumber)) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
 | 
			
		||||
      const messageWithPRLink = messageWithCapitalizeFirst.replace(
 | 
			
		||||
        /\(#[0-9]*\)/,
 | 
			
		||||
        prMarkdown,
 | 
			
		||||
      );
 | 
			
		||||
      commitList[type].push(messageWithPRLink);
 | 
			
		||||
    } else {
 | 
			
		||||
      badCommits.push(commit);
 | 
			
		||||
      commitList[type].push(messageWithCapitalizeFirst);
 | 
			
		||||
    }
 | 
			
		||||
    const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
 | 
			
		||||
    const messageWithPRLink = messageWithCapitalizeFirst.replace(
 | 
			
		||||
      /\(#[0-9]*\)/,
 | 
			
		||||
      prMarkdown,
 | 
			
		||||
    );
 | 
			
		||||
    commitList[type].push(messageWithPRLink);
 | 
			
		||||
  });
 | 
			
		||||
  console.info("Bad commits:", badCommits);
 | 
			
		||||
  return commitList;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +0,0 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
 | 
			
		||||
const updateReadme = () => {
 | 
			
		||||
  const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
  // remove note for unstable release
 | 
			
		||||
  data = data.replace(
 | 
			
		||||
    /<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
 | 
			
		||||
    "",
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // replace "excalidraw-next" with "excalidraw"
 | 
			
		||||
  data = data.replace(/excalidraw-next/g, "excalidraw");
 | 
			
		||||
  data = data.trim();
 | 
			
		||||
 | 
			
		||||
  const demoIndex = data.indexOf("### Demo");
 | 
			
		||||
  const excalidrawNextNote =
 | 
			
		||||
    "#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
 | 
			
		||||
  // Add excalidraw next note to try out for unreleased changes
 | 
			
		||||
  data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = updateReadme;
 | 
			
		||||
@@ -7,6 +7,7 @@ import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -24,9 +25,9 @@ export const actionAddToLibrary = register({
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return app.library
 | 
			
		||||
      .loadLibrary()
 | 
			
		||||
      .getLatestLibrary()
 | 
			
		||||
      .then((items) => {
 | 
			
		||||
        return app.library.saveLibrary([
 | 
			
		||||
        return app.library.setLibrary([
 | 
			
		||||
          {
 | 
			
		||||
            id: randomId(),
 | 
			
		||||
            status: "unpublished",
 | 
			
		||||
@@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
 | 
			
		||||
          commitToHistory: false,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            toastMessage: t("toast.addedToLibrary"),
 | 
			
		||||
            toast: { message: t("toast.addedToLibrary") },
 | 
			
		||||
          },
 | 
			
		||||
        };
 | 
			
		||||
      })
 | 
			
		||||
 
 | 
			
		||||
@@ -43,6 +43,7 @@ const alignSelectedElements = (
 | 
			
		||||
 | 
			
		||||
export const actionAlignTop = register({
 | 
			
		||||
  name: "alignTop",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -72,6 +73,7 @@ export const actionAlignTop = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignBottom = register({
 | 
			
		||||
  name: "alignBottom",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -101,6 +103,7 @@ export const actionAlignBottom = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignLeft = register({
 | 
			
		||||
  name: "alignLeft",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -130,6 +133,8 @@ export const actionAlignLeft = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignRight = register({
 | 
			
		||||
  name: "alignRight",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -159,6 +164,8 @@ export const actionAlignRight = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignVerticallyCentered = register({
 | 
			
		||||
  name: "alignVerticallyCentered",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignHorizontallyCentered = register({
 | 
			
		||||
  name: "alignHorizontallyCentered",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										136
									
								
								src/actions/actionBoundText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/actions/actionBoundText.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,136 @@
 | 
			
		||||
import { VERTICAL_ALIGN } from "../constants";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  measureText,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  hasBoundTextElement,
 | 
			
		||||
  isTextBindableContainer,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawTextContainer,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
    return selectedElements.some((element) => hasBoundTextElement(element));
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionBindText = register({
 | 
			
		||||
  name: "bindText",
 | 
			
		||||
  contextItemLabel: "labels.bindText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
 | 
			
		||||
    if (selectedElements.length === 2) {
 | 
			
		||||
      const textElement =
 | 
			
		||||
        isTextElement(selectedElements[0]) ||
 | 
			
		||||
        isTextElement(selectedElements[1]);
 | 
			
		||||
 | 
			
		||||
      let bindingContainer;
 | 
			
		||||
      if (isTextBindableContainer(selectedElements[0])) {
 | 
			
		||||
        bindingContainer = selectedElements[0];
 | 
			
		||||
      } else if (isTextBindableContainer(selectedElements[1])) {
 | 
			
		||||
        bindingContainer = selectedElements[1];
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        textElement &&
 | 
			
		||||
        bindingContainer &&
 | 
			
		||||
        getBoundTextElement(bindingContainer) === null
 | 
			
		||||
      ) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    let textElement: ExcalidrawTextElement;
 | 
			
		||||
    let container: ExcalidrawTextContainer;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      isTextElement(selectedElements[0]) &&
 | 
			
		||||
      isTextBindableContainer(selectedElements[1])
 | 
			
		||||
    ) {
 | 
			
		||||
      textElement = selectedElements[0];
 | 
			
		||||
      container = selectedElements[1];
 | 
			
		||||
    } else {
 | 
			
		||||
      textElement = selectedElements[1] as ExcalidrawTextElement;
 | 
			
		||||
      container = selectedElements[0] as ExcalidrawTextContainer;
 | 
			
		||||
    }
 | 
			
		||||
    mutateElement(textElement, {
 | 
			
		||||
      containerId: container.id,
 | 
			
		||||
      verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
    });
 | 
			
		||||
    mutateElement(container, {
 | 
			
		||||
      boundElements: (container.boundElements || []).concat({
 | 
			
		||||
        type: "text",
 | 
			
		||||
        id: textElement.id,
 | 
			
		||||
      }),
 | 
			
		||||
    });
 | 
			
		||||
    redrawTextBoundingBox(textElement, container);
 | 
			
		||||
    const updatedElements = elements.slice();
 | 
			
		||||
    const textElementIndex = updatedElements.findIndex(
 | 
			
		||||
      (ele) => ele.id === textElement.id,
 | 
			
		||||
    );
 | 
			
		||||
    updatedElements.splice(textElementIndex, 1);
 | 
			
		||||
    const containerIndex = updatedElements.findIndex(
 | 
			
		||||
      (ele) => ele.id === container.id,
 | 
			
		||||
    );
 | 
			
		||||
    updatedElements.splice(containerIndex + 1, 0, textElement);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: updatedElements,
 | 
			
		||||
      appState: { ...appState, selectedElementIds: { [container.id]: true } },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { THEME, ZOOM_STEP } from "../constants";
 | 
			
		||||
@@ -11,22 +11,24 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { getStateForZoom } from "../scene/zoom";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { getShortcutKey, updateActiveTool } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import { getDefaultAppState, isEraserActive } from "../appState";
 | 
			
		||||
import ClearCanvas from "../components/ClearCanvas";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
  name: "changeViewBackgroundColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, ...value },
 | 
			
		||||
      commitToHistory: !!value.viewBackgroundColor,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => {
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <div style={{ position: "relative" }}>
 | 
			
		||||
        <ColorPicker
 | 
			
		||||
@@ -39,6 +41,8 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
            updateData({ openPopup: active ? "canvasColorPicker" : null })
 | 
			
		||||
          }
 | 
			
		||||
          data-testid="canvas-background-picker"
 | 
			
		||||
          elements={elements}
 | 
			
		||||
          appState={appState}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
@@ -47,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionClearCanvas = register({
 | 
			
		||||
  name: "clearCanvas",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    app.imageCache.clear();
 | 
			
		||||
    return {
 | 
			
		||||
@@ -57,7 +62,6 @@ export const actionClearCanvas = register({
 | 
			
		||||
        ...getDefaultAppState(),
 | 
			
		||||
        files: {},
 | 
			
		||||
        theme: appState.theme,
 | 
			
		||||
        elementLocked: appState.elementLocked,
 | 
			
		||||
        penMode: appState.penMode,
 | 
			
		||||
        penDetected: appState.penDetected,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
@@ -65,8 +69,10 @@ export const actionClearCanvas = register({
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
        elementType:
 | 
			
		||||
          appState.elementType === "image" ? "selection" : appState.elementType,
 | 
			
		||||
        activeTool:
 | 
			
		||||
          appState.activeTool.type === "image"
 | 
			
		||||
            ? { ...appState.activeTool, type: "selection" }
 | 
			
		||||
            : appState.activeTool,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
@@ -77,6 +83,7 @@ export const actionClearCanvas = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomIn = register({
 | 
			
		||||
  name: "zoomIn",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -112,6 +119,7 @@ export const actionZoomIn = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomOut = register({
 | 
			
		||||
  name: "zoomOut",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -147,6 +155,7 @@ export const actionZoomOut = register({
 | 
			
		||||
 | 
			
		||||
export const actionResetZoom = register({
 | 
			
		||||
  name: "resetZoom",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -245,6 +254,7 @@ const zoomToFitElements = (
 | 
			
		||||
 | 
			
		||||
export const actionZoomToSelected = register({
 | 
			
		||||
  name: "zoomToSelection",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.code === CODES.TWO &&
 | 
			
		||||
@@ -255,6 +265,7 @@ export const actionZoomToSelected = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomToFit = register({
 | 
			
		||||
  name: "zoomToFit",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.code === CODES.ONE &&
 | 
			
		||||
@@ -265,6 +276,7 @@ export const actionZoomToFit = register({
 | 
			
		||||
 | 
			
		||||
export const actionToggleTheme = register({
 | 
			
		||||
  name: "toggleTheme",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -287,3 +299,49 @@ export const actionToggleTheme = register({
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionErase = register({
 | 
			
		||||
  name: "eraser",
 | 
			
		||||
  trackEvent: { category: "toolbar" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    let activeTool: AppState["activeTool"];
 | 
			
		||||
 | 
			
		||||
    if (isEraserActive(appState)) {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        ...(appState.activeTool.lastActiveToolBeforeEraser || {
 | 
			
		||||
          type: "selection",
 | 
			
		||||
        }),
 | 
			
		||||
        lastActiveToolBeforeEraser: null,
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        type: "eraser",
 | 
			
		||||
        lastActiveToolBeforeEraser: appState.activeTool,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedElementIds: {},
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        activeTool,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.key === KEYS.E,
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={eraser}
 | 
			
		||||
      className={clsx("eraser", { active: isEraserActive(appState) })}
 | 
			
		||||
      title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
 | 
			
		||||
      aria-label={t("toolBar.eraser")}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    ></ToolButton>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,23 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { copyToClipboard } from "../clipboard";
 | 
			
		||||
import {
 | 
			
		||||
  copyTextToSystemClipboard,
 | 
			
		||||
  copyToClipboard,
 | 
			
		||||
  probablySupportsClipboardWriteText,
 | 
			
		||||
} from "../clipboard";
 | 
			
		||||
import { actionDeleteSelected } from "./actionDeleteSelected";
 | 
			
		||||
import { getSelectedElements } from "../scene/selection";
 | 
			
		||||
import { exportCanvas } from "../data/index";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionCopy = register({
 | 
			
		||||
  name: "copy",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState, app.files);
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
 | 
			
		||||
    copyToClipboard(selectedElements, appState, app.files);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
@@ -23,6 +30,7 @@ export const actionCopy = register({
 | 
			
		||||
 | 
			
		||||
export const actionCut = register({
 | 
			
		||||
  name: "cut",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, data, app) => {
 | 
			
		||||
    actionCopy.perform(elements, appState, data, app);
 | 
			
		||||
    return actionDeleteSelected.perform(elements, appState);
 | 
			
		||||
@@ -33,6 +41,7 @@ export const actionCut = register({
 | 
			
		||||
 | 
			
		||||
export const actionCopyAsSvg = register({
 | 
			
		||||
  name: "copyAsSvg",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: async (elements, appState, _data, app) => {
 | 
			
		||||
    if (!app.canvas) {
 | 
			
		||||
      return {
 | 
			
		||||
@@ -73,6 +82,7 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
 | 
			
		||||
export const actionCopyAsPng = register({
 | 
			
		||||
  name: "copyAsPng",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: async (elements, appState, _data, app) => {
 | 
			
		||||
    if (!app.canvas) {
 | 
			
		||||
      return {
 | 
			
		||||
@@ -97,14 +107,16 @@ export const actionCopyAsPng = register({
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          toastMessage: t("toast.copyToClipboardAsPng", {
 | 
			
		||||
            exportSelection: selectedElements.length
 | 
			
		||||
              ? t("toast.selection")
 | 
			
		||||
              : t("toast.canvas"),
 | 
			
		||||
            exportColorScheme: appState.exportWithDarkMode
 | 
			
		||||
              ? t("buttons.darkMode")
 | 
			
		||||
              : t("buttons.lightMode"),
 | 
			
		||||
          }),
 | 
			
		||||
          toast: {
 | 
			
		||||
            message: t("toast.copyToClipboardAsPng", {
 | 
			
		||||
              exportSelection: selectedElements.length
 | 
			
		||||
                ? t("toast.selection")
 | 
			
		||||
                : t("toast.canvas"),
 | 
			
		||||
              exportColorScheme: appState.exportWithDarkMode
 | 
			
		||||
                ? t("buttons.darkMode")
 | 
			
		||||
                : t("buttons.lightMode"),
 | 
			
		||||
            }),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
@@ -122,3 +134,35 @@ export const actionCopyAsPng = register({
 | 
			
		||||
  contextItemLabel: "labels.copyAsPng",
 | 
			
		||||
  keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const copyText = register({
 | 
			
		||||
  name: "copyText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const text = selectedElements
 | 
			
		||||
      .reduce((acc: string[], element) => {
 | 
			
		||||
        if (isTextElement(element)) {
 | 
			
		||||
          acc.push(element.text);
 | 
			
		||||
        }
 | 
			
		||||
        return acc;
 | 
			
		||||
      }, [])
 | 
			
		||||
      .join("\n\n");
 | 
			
		||||
    copyTextToSystemClipboard(text);
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    return (
 | 
			
		||||
      probablySupportsClipboardWriteText &&
 | 
			
		||||
      getSelectedElements(elements, appState, true).some(isTextElement)
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.copyText",
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import { getElementsInGroup } from "../groups";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
import { updateActiveTool } from "../utils";
 | 
			
		||||
 | 
			
		||||
const deleteSelectedElements = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -58,6 +59,7 @@ const handleGroupEditingState = (
 | 
			
		||||
 | 
			
		||||
export const actionDeleteSelected = register({
 | 
			
		||||
  name: "deleteSelectedElements",
 | 
			
		||||
  trackEvent: { category: "element", action: "delete" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
@@ -133,7 +135,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...nextAppState,
 | 
			
		||||
        elementType: "selection",
 | 
			
		||||
        activeTool: updateActiveTool(appState, { type: "selection" }),
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: isSomeElementSelected(
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,11 @@ import {
 | 
			
		||||
  DistributeVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { distributeElements, Distribution } from "../disitrubte";
 | 
			
		||||
import { distributeElements, Distribution } from "../distribute";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES } from "../keys";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
@@ -39,6 +39,7 @@ const distributeSelectedElements = (
 | 
			
		||||
 | 
			
		||||
export const distributeHorizontally = register({
 | 
			
		||||
  name: "distributeHorizontally",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -49,7 +50,8 @@ export const distributeHorizontally = register({
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.altKey && event.code === CODES.H,
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
@@ -67,6 +69,7 @@ export const distributeHorizontally = register({
 | 
			
		||||
 | 
			
		||||
export const distributeVertically = register({
 | 
			
		||||
  name: "distributeVertically",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -77,7 +80,8 @@ export const distributeVertically = register({
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.altKey && event.code === CODES.V,
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionDuplicateSelection = register({
 | 
			
		||||
  name: "duplicateSelection",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    // duplicate selected point(s) if editing a line
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
@@ -127,12 +128,15 @@ const duplicateElements = (
 | 
			
		||||
      {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        selectedElementIds: newElements.reduce((acc, element) => {
 | 
			
		||||
          if (!isBoundToContainer(element)) {
 | 
			
		||||
            acc[element.id] = true;
 | 
			
		||||
          }
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {} as any),
 | 
			
		||||
        selectedElementIds: newElements.reduce(
 | 
			
		||||
          (acc: Record<ExcalidrawElement["id"], true>, element) => {
 | 
			
		||||
            if (!isBoundToContainer(element)) {
 | 
			
		||||
              acc[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
            return acc;
 | 
			
		||||
          },
 | 
			
		||||
          {},
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      getNonDeletedElements(finalElements),
 | 
			
		||||
    ),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { load, questionCircle, saveAs } from "../components/icons";
 | 
			
		||||
import { ProjectName } from "../components/ProjectName";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
@@ -8,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
			
		||||
import { resaveAsImageWithScene } from "../data/resave";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useDevice } from "../components/App";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { CheckboxItem } from "../components/CheckboxItem";
 | 
			
		||||
@@ -23,8 +22,8 @@ import { Theme } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export const actionChangeProjectName = register({
 | 
			
		||||
  name: "changeProjectName",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    trackEvent("change", "title");
 | 
			
		||||
    return { appState: { ...appState, name: value }, commitToHistory: false };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, appProps }) => (
 | 
			
		||||
@@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportScale = register({
 | 
			
		||||
  name: "changeExportScale",
 | 
			
		||||
  trackEvent: { category: "export", action: "scale" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportScale: value },
 | 
			
		||||
@@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportBackground = register({
 | 
			
		||||
  name: "changeExportBackground",
 | 
			
		||||
  trackEvent: { category: "export", action: "toggleBackground" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportBackground: value },
 | 
			
		||||
@@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportEmbedScene = register({
 | 
			
		||||
  name: "changeExportEmbedScene",
 | 
			
		||||
  trackEvent: { category: "export", action: "embedScene" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportEmbedScene: value },
 | 
			
		||||
@@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionSaveToActiveFile = register({
 | 
			
		||||
  name: "saveToActiveFile",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    const fileHandleExists = !!appState.fileHandle;
 | 
			
		||||
 | 
			
		||||
@@ -141,13 +144,15 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          fileHandle,
 | 
			
		||||
          toastMessage: fileHandleExists
 | 
			
		||||
            ? fileHandle?.name
 | 
			
		||||
              ? t("toast.fileSavedToFilename").replace(
 | 
			
		||||
                  "{filename}",
 | 
			
		||||
                  `"${fileHandle.name}"`,
 | 
			
		||||
                )
 | 
			
		||||
              : t("toast.fileSaved")
 | 
			
		||||
          toast: fileHandleExists
 | 
			
		||||
            ? {
 | 
			
		||||
                message: fileHandle?.name
 | 
			
		||||
                  ? t("toast.fileSavedToFilename").replace(
 | 
			
		||||
                      "{filename}",
 | 
			
		||||
                      `"${fileHandle.name}"`,
 | 
			
		||||
                    )
 | 
			
		||||
                  : t("toast.fileSaved"),
 | 
			
		||||
              }
 | 
			
		||||
            : null,
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
@@ -172,6 +177,7 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
 | 
			
		||||
export const actionSaveFileToDisk = register({
 | 
			
		||||
  name: "saveFileToDisk",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(
 | 
			
		||||
@@ -200,7 +206,7 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
      icon={saveAs}
 | 
			
		||||
      title={t("buttons.saveAs")}
 | 
			
		||||
      aria-label={t("buttons.saveAs")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      showAriaLabel={useDevice().isMobile}
 | 
			
		||||
      hidden={!nativeFileSystemSupported}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-as-button"
 | 
			
		||||
@@ -210,6 +216,7 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, _, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
@@ -243,7 +250,7 @@ export const actionLoadScene = register({
 | 
			
		||||
      icon={load}
 | 
			
		||||
      title={t("buttons.load")}
 | 
			
		||||
      aria-label={t("buttons.load")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      showAriaLabel={useDevice().isMobile}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      data-testid="load-button"
 | 
			
		||||
    />
 | 
			
		||||
@@ -252,6 +259,7 @@ export const actionLoadScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionExportWithDarkMode = register({
 | 
			
		||||
  name: "exportWithDarkMode",
 | 
			
		||||
  trackEvent: { category: "export", action: "toggleTheme" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportWithDarkMode: value },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { isInvisiblySmallElement } from "../element";
 | 
			
		||||
import { resetCursor } from "../utils";
 | 
			
		||||
import { updateActiveTool, resetCursor } from "../utils";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { done } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -13,11 +13,13 @@ import {
 | 
			
		||||
  maybeBindLinearElement,
 | 
			
		||||
  bindOrUnbindLinearElement,
 | 
			
		||||
} from "../element/binding";
 | 
			
		||||
import { isBindingElement } from "../element/typeChecks";
 | 
			
		||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const actionFinalize = register({
 | 
			
		||||
  name: "finalize",
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const { elementId, startBindingElement, endBindingElement } =
 | 
			
		||||
        appState.editingLinearElement;
 | 
			
		||||
@@ -38,6 +40,7 @@ export const actionFinalize = register({
 | 
			
		||||
              : undefined,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            cursorButton: "up",
 | 
			
		||||
            editingLinearElement: null,
 | 
			
		||||
          },
 | 
			
		||||
          commitToHistory: true,
 | 
			
		||||
@@ -47,8 +50,12 @@ export const actionFinalize = register({
 | 
			
		||||
 | 
			
		||||
    let newElements = elements;
 | 
			
		||||
 | 
			
		||||
    if (appState.pendingImageElement) {
 | 
			
		||||
      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    const pendingImageElement =
 | 
			
		||||
      appState.pendingImageElementId &&
 | 
			
		||||
      scene.getElement(appState.pendingImageElementId);
 | 
			
		||||
 | 
			
		||||
    if (pendingImageElement) {
 | 
			
		||||
      mutateElement(pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (window.document.activeElement instanceof HTMLElement) {
 | 
			
		||||
@@ -119,27 +126,47 @@ export const actionFinalize = register({
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "freedraw") {
 | 
			
		||||
      if (
 | 
			
		||||
        !appState.activeTool.locked &&
 | 
			
		||||
        appState.activeTool.type !== "freedraw"
 | 
			
		||||
      ) {
 | 
			
		||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "freedraw") ||
 | 
			
		||||
      (!appState.activeTool.locked &&
 | 
			
		||||
        appState.activeTool.type !== "freedraw") ||
 | 
			
		||||
      !multiPointElement
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor(canvas);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let activeTool: AppState["activeTool"];
 | 
			
		||||
    if (appState.activeTool.type === "eraser") {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        ...(appState.activeTool.lastActiveToolBeforeEraser || {
 | 
			
		||||
          type: "selection",
 | 
			
		||||
        }),
 | 
			
		||||
        lastActiveToolBeforeEraser: null,
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        type: "selection",
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      elements: newElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        elementType:
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "freedraw") &&
 | 
			
		||||
        cursorButton: "up",
 | 
			
		||||
        activeTool:
 | 
			
		||||
          (appState.activeTool.locked ||
 | 
			
		||||
            appState.activeTool.type === "freedraw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.elementType
 | 
			
		||||
            : "selection",
 | 
			
		||||
            ? appState.activeTool
 | 
			
		||||
            : activeTool,
 | 
			
		||||
        draggingElement: null,
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
        editingElement: null,
 | 
			
		||||
@@ -147,16 +174,21 @@ export const actionFinalize = register({
 | 
			
		||||
        suggestedBindings: [],
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "freedraw"
 | 
			
		||||
          !appState.activeTool.locked &&
 | 
			
		||||
          appState.activeTool.type !== "freedraw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
              }
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
        pendingImageElement: null,
 | 
			
		||||
        // To select the linear element when user has finished mutipoint editing
 | 
			
		||||
        selectedLinearElement:
 | 
			
		||||
          multiPointElement && isLinearElement(multiPointElement)
 | 
			
		||||
            ? new LinearElementEditor(multiPointElement, scene)
 | 
			
		||||
            : appState.selectedLinearElement,
 | 
			
		||||
        pendingImageElementId: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
      commitToHistory: appState.activeTool.type === "freedraw",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
@@ -165,7 +197,7 @@ export const actionFinalize = register({
 | 
			
		||||
        (!appState.draggingElement && appState.multiElement === null))) ||
 | 
			
		||||
    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
 | 
			
		||||
      appState.multiElement !== null),
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={done}
 | 
			
		||||
@@ -173,6 +205,7 @@ export const actionFinalize = register({
 | 
			
		||||
      aria-label={t("buttons.done")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      visible={appState.multiElement != null}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -35,6 +35,7 @@ const enableActionFlipVertical = (
 | 
			
		||||
 | 
			
		||||
export const actionFlipHorizontal = register({
 | 
			
		||||
  name: "flipHorizontal",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "horizontal"),
 | 
			
		||||
@@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
 | 
			
		||||
 | 
			
		||||
export const actionFlipVertical = register({
 | 
			
		||||
  name: "flipVertical",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "vertical"),
 | 
			
		||||
@@ -155,7 +157,7 @@ const flipElement = (
 | 
			
		||||
    // calculate new x-coord for transformation
 | 
			
		||||
    newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
 | 
			
		||||
    resizeSingleElement(
 | 
			
		||||
      element,
 | 
			
		||||
      new Map().set(element.id, element),
 | 
			
		||||
      true,
 | 
			
		||||
      element,
 | 
			
		||||
      usingNWHandle ? "nw" : "ne",
 | 
			
		||||
 
 | 
			
		||||
@@ -54,6 +54,7 @@ const enableActionGroup = (
 | 
			
		||||
 | 
			
		||||
export const actionGroup = register({
 | 
			
		||||
  name: "group",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -147,6 +148,7 @@ export const actionGroup = register({
 | 
			
		||||
 | 
			
		||||
export const actionUngroup = register({
 | 
			
		||||
  name: "ungroup",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const groupIds = getSelectedGroupIds(appState);
 | 
			
		||||
    if (groupIds.length === 0) {
 | 
			
		||||
 
 | 
			
		||||
@@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.undoOnce()),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
 | 
			
		||||
export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "redo",
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.redoOnce()),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
 | 
			
		||||
 | 
			
		||||
export const actionToggleCanvasMenu = register({
 | 
			
		||||
  name: "toggleCanvasMenu",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform: (_, appState) => ({
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
@@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
 | 
			
		||||
 | 
			
		||||
export const actionToggleEditMenu = register({
 | 
			
		||||
  name: "toggleEditMenu",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform: (_elements, appState) => ({
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
@@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
 | 
			
		||||
 | 
			
		||||
export const actionFullScreen = register({
 | 
			
		||||
  name: "toggleFullScreen",
 | 
			
		||||
  trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
 | 
			
		||||
  perform: () => {
 | 
			
		||||
    if (!isFullScreen()) {
 | 
			
		||||
      allowFullScreen();
 | 
			
		||||
@@ -69,6 +72,7 @@ export const actionFullScreen = register({
 | 
			
		||||
 | 
			
		||||
export const actionShortcuts = register({
 | 
			
		||||
  name: "toggleShortcuts",
 | 
			
		||||
  trackEvent: { category: "menu", action: "toggleHelpDialog" },
 | 
			
		||||
  perform: (_elements, appState, _, { focusContainer }) => {
 | 
			
		||||
    if (appState.showHelpDialog) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { getClientColors, getClientInitials } from "../clients";
 | 
			
		||||
import { getClientColors } from "../clients";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { Collaborator } from "../types";
 | 
			
		||||
@@ -6,6 +6,7 @@ import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionGoToCollaborator = register({
 | 
			
		||||
  name: "goToCollaborator",
 | 
			
		||||
  trackEvent: { category: "collab" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    const point = value as Collaborator["pointer"];
 | 
			
		||||
    if (!point) {
 | 
			
		||||
@@ -30,28 +31,18 @@ export const actionGoToCollaborator = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => {
 | 
			
		||||
    const clientId: string | undefined = data?.id;
 | 
			
		||||
    if (!clientId) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const collaborator = appState.collaborators.get(clientId);
 | 
			
		||||
 | 
			
		||||
    if (!collaborator) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    const [clientId, collaborator] = data as [string, Collaborator];
 | 
			
		||||
 | 
			
		||||
    const { background, stroke } = getClientColors(clientId, appState);
 | 
			
		||||
    const shortName = getClientInitials(collaborator.username);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Avatar
 | 
			
		||||
        color={background}
 | 
			
		||||
        border={stroke}
 | 
			
		||||
        onClick={() => updateData(collaborator.pointer)}
 | 
			
		||||
      >
 | 
			
		||||
        {shortName}
 | 
			
		||||
      </Avatar>
 | 
			
		||||
        name={collaborator.username || ""}
 | 
			
		||||
        src={collaborator.avatarUrl}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -30,11 +30,15 @@ import {
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  TextAlignRightIcon,
 | 
			
		||||
  TextAlignTopIcon,
 | 
			
		||||
  TextAlignBottomIcon,
 | 
			
		||||
  TextAlignMiddleIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  FONT_FAMILY,
 | 
			
		||||
  VERTICAL_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
@@ -58,6 +62,7 @@ import {
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  FontFamilyValues,
 | 
			
		||||
  TextAlign,
 | 
			
		||||
  VerticalAlign,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getLanguage, t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
@@ -161,11 +166,7 @@ const changeFontSize = (
 | 
			
		||||
          let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
 | 
			
		||||
            fontSize: newFontSize,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(
 | 
			
		||||
            newElement,
 | 
			
		||||
            getContainerElement(oldElement),
 | 
			
		||||
            appState,
 | 
			
		||||
          );
 | 
			
		||||
          redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
 | 
			
		||||
          newElement = offsetElementAfterFontResize(oldElement, newElement);
 | 
			
		||||
 | 
			
		||||
@@ -193,6 +194,7 @@ const changeFontSize = (
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
@@ -233,6 +235,8 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
        setActive={(active) =>
 | 
			
		||||
          updateData({ openPopup: active ? "strokeColorPicker" : null })
 | 
			
		||||
        }
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -240,6 +244,7 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeBackgroundColor = register({
 | 
			
		||||
  name: "changeBackgroundColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemBackgroundColor && {
 | 
			
		||||
@@ -273,6 +278,8 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
        setActive={(active) =>
 | 
			
		||||
          updateData({ openPopup: active ? "backgroundColorPicker" : null })
 | 
			
		||||
        }
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -280,6 +287,7 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFillStyle = register({
 | 
			
		||||
  name: "changeFillStyle",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -329,6 +337,7 @@ export const actionChangeFillStyle = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeWidth = register({
 | 
			
		||||
  name: "changeStrokeWidth",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -376,6 +385,7 @@ export const actionChangeStrokeWidth = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeSloppiness = register({
 | 
			
		||||
  name: "changeSloppiness",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -424,6 +434,7 @@ export const actionChangeSloppiness = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeStyle = register({
 | 
			
		||||
  name: "changeStrokeStyle",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -471,12 +482,17 @@ export const actionChangeStrokeStyle = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeOpacity = register({
 | 
			
		||||
  name: "changeOpacity",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
          opacity: value,
 | 
			
		||||
        }),
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (el) =>
 | 
			
		||||
          newElementWith(el, {
 | 
			
		||||
            opacity: value,
 | 
			
		||||
          }),
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
      appState: { ...appState, currentItemOpacity: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
@@ -491,20 +507,6 @@ export const actionChangeOpacity = register({
 | 
			
		||||
        max="100"
 | 
			
		||||
        step="10"
 | 
			
		||||
        onChange={(event) => updateData(+event.target.value)}
 | 
			
		||||
        onWheel={(event) => {
 | 
			
		||||
          event.stopPropagation();
 | 
			
		||||
          const target = event.target as HTMLInputElement;
 | 
			
		||||
          const STEP = 10;
 | 
			
		||||
          const MAX = 100;
 | 
			
		||||
          const MIN = 0;
 | 
			
		||||
          const value = +target.value;
 | 
			
		||||
 | 
			
		||||
          if (event.deltaY < 0 && value < MAX) {
 | 
			
		||||
            updateData(value + STEP);
 | 
			
		||||
          } else if (event.deltaY > 0 && value > MIN) {
 | 
			
		||||
            updateData(value - STEP);
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        value={
 | 
			
		||||
          getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
@@ -520,6 +522,7 @@ export const actionChangeOpacity = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontSize = register({
 | 
			
		||||
  name: "changeFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, () => value, value);
 | 
			
		||||
  },
 | 
			
		||||
@@ -577,6 +580,7 @@ export const actionChangeFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionDecreaseFontSize = register({
 | 
			
		||||
  name: "decreaseFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(
 | 
			
		||||
@@ -598,6 +602,7 @@ export const actionDecreaseFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionIncreaseFontSize = register({
 | 
			
		||||
  name: "increaseFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
 | 
			
		||||
@@ -615,6 +620,7 @@ export const actionIncreaseFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontFamily = register({
 | 
			
		||||
  name: "changeFontFamily",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -628,11 +634,7 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
                fontFamily: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -700,6 +702,7 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeTextAlign = register({
 | 
			
		||||
  name: "changeTextAlign",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -709,15 +712,9 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              {
 | 
			
		||||
                textAlign: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
              { textAlign: value },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -732,51 +729,121 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      <legend>{t("labels.textAlign")}</legend>
 | 
			
		||||
      <ButtonIconSelect<TextAlign | false>
 | 
			
		||||
        group="text-align"
 | 
			
		||||
        options={[
 | 
			
		||||
          {
 | 
			
		||||
            value: "left",
 | 
			
		||||
            text: t("labels.left"),
 | 
			
		||||
            icon: <TextAlignLeftIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "center",
 | 
			
		||||
            text: t("labels.center"),
 | 
			
		||||
            icon: <TextAlignCenterIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "right",
 | 
			
		||||
            text: t("labels.right"),
 | 
			
		||||
            icon: <TextAlignRightIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => {
 | 
			
		||||
            if (isTextElement(element)) {
 | 
			
		||||
              return element.textAlign;
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.textAlign")}</legend>
 | 
			
		||||
        <ButtonIconSelect<TextAlign | false>
 | 
			
		||||
          group="text-align"
 | 
			
		||||
          options={[
 | 
			
		||||
            {
 | 
			
		||||
              value: "left",
 | 
			
		||||
              text: t("labels.left"),
 | 
			
		||||
              icon: <TextAlignLeftIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: "center",
 | 
			
		||||
              text: t("labels.center"),
 | 
			
		||||
              icon: <TextAlignCenterIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: "right",
 | 
			
		||||
              text: t("labels.right"),
 | 
			
		||||
              icon: <TextAlignRightIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) => {
 | 
			
		||||
              if (isTextElement(element)) {
 | 
			
		||||
                return element.textAlign;
 | 
			
		||||
              }
 | 
			
		||||
              const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
              if (boundTextElement) {
 | 
			
		||||
                return boundTextElement.textAlign;
 | 
			
		||||
              }
 | 
			
		||||
              return null;
 | 
			
		||||
            },
 | 
			
		||||
            appState.currentItemTextAlign,
 | 
			
		||||
          )}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
        />
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const actionChangeVerticalAlign = register({
 | 
			
		||||
  name: "changeVerticalAlign",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (oldElement) => {
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              { verticalAlign: value },
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return oldElement;
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <ButtonIconSelect<VerticalAlign | false>
 | 
			
		||||
          group="text-align"
 | 
			
		||||
          options={[
 | 
			
		||||
            {
 | 
			
		||||
              value: VERTICAL_ALIGN.TOP,
 | 
			
		||||
              text: t("labels.alignTop"),
 | 
			
		||||
              icon: <TextAlignTopIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
              text: t("labels.centerVertically"),
 | 
			
		||||
              icon: <TextAlignMiddleIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: VERTICAL_ALIGN.BOTTOM,
 | 
			
		||||
              text: t("labels.alignBottom"),
 | 
			
		||||
              icon: <TextAlignBottomIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
          value={getFormValue(elements, appState, (element) => {
 | 
			
		||||
            if (isTextElement(element) && element.containerId) {
 | 
			
		||||
              return element.verticalAlign;
 | 
			
		||||
            }
 | 
			
		||||
            const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
            if (boundTextElement) {
 | 
			
		||||
              return boundTextElement.textAlign;
 | 
			
		||||
              return boundTextElement.verticalAlign;
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
          },
 | 
			
		||||
          appState.currentItemTextAlign,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(value) => updateData(value)}
 | 
			
		||||
      />
 | 
			
		||||
    </fieldset>
 | 
			
		||||
  ),
 | 
			
		||||
          })}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
        />
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeSharpness = register({
 | 
			
		||||
  name: "changeSharpness",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    const targetElements = getTargetElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -784,10 +851,10 @@ export const actionChangeSharpness = register({
 | 
			
		||||
    );
 | 
			
		||||
    const shouldUpdateForNonLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every((el) => !isLinearElement(el))
 | 
			
		||||
      : !isLinearElementType(appState.elementType);
 | 
			
		||||
      : !isLinearElementType(appState.activeTool.type);
 | 
			
		||||
    const shouldUpdateForLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every(isLinearElement)
 | 
			
		||||
      : isLinearElementType(appState.elementType);
 | 
			
		||||
      : isLinearElementType(appState.activeTool.type);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -827,8 +894,8 @@ export const actionChangeSharpness = register({
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => element.strokeSharpness,
 | 
			
		||||
          (canChangeSharpness(appState.elementType) &&
 | 
			
		||||
            (isLinearElementType(appState.elementType)
 | 
			
		||||
          (canChangeSharpness(appState.activeTool.type) &&
 | 
			
		||||
            (isLinearElementType(appState.activeTool.type)
 | 
			
		||||
              ? appState.currentItemLinearStrokeSharpness
 | 
			
		||||
              : appState.currentItemStrokeSharpness)) ||
 | 
			
		||||
            null,
 | 
			
		||||
@@ -841,6 +908,7 @@ export const actionChangeSharpness = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeArrowhead = register({
 | 
			
		||||
  name: "changeArrowhead",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (
 | 
			
		||||
    elements,
 | 
			
		||||
    appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -2,27 +2,43 @@ import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { selectGroupsForSelectedElements } from "../groups";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
 | 
			
		||||
export const actionSelectAll = register({
 | 
			
		||||
  name: "selectAll",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState, value, app) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    const selectedElementIds = elements.reduce(
 | 
			
		||||
      (map: Record<ExcalidrawElement["id"], true>, element) => {
 | 
			
		||||
        if (
 | 
			
		||||
          !element.isDeleted &&
 | 
			
		||||
          !(isTextElement(element) && element.containerId) &&
 | 
			
		||||
          !element.locked
 | 
			
		||||
        ) {
 | 
			
		||||
          map[element.id] = true;
 | 
			
		||||
        }
 | 
			
		||||
        return map;
 | 
			
		||||
      },
 | 
			
		||||
      {},
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: selectGroupsForSelectedElements(
 | 
			
		||||
        {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          selectedLinearElement:
 | 
			
		||||
            // single linear element selected
 | 
			
		||||
            Object.keys(selectedElementIds).length === 1 &&
 | 
			
		||||
            isLinearElement(elements[0])
 | 
			
		||||
              ? new LinearElementEditor(elements[0], app.scene)
 | 
			
		||||
              : null,
 | 
			
		||||
          editingGroupId: null,
 | 
			
		||||
          selectedElementIds: elements.reduce((map, element) => {
 | 
			
		||||
            if (
 | 
			
		||||
              !element.isDeleted &&
 | 
			
		||||
              !(isTextElement(element) && element.containerId)
 | 
			
		||||
            ) {
 | 
			
		||||
              map[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
            return map;
 | 
			
		||||
          }, {} as any),
 | 
			
		||||
          selectedElementIds,
 | 
			
		||||
        },
 | 
			
		||||
        getNonDeletedElements(elements),
 | 
			
		||||
      ),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										71
									
								
								src/actions/actionStyles.test.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/actions/actionStyles.test.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
import ExcalidrawApp from "../excalidraw-app";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES } from "../keys";
 | 
			
		||||
import { API } from "../tests/helpers/api";
 | 
			
		||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
 | 
			
		||||
import { fireEvent, render, screen } from "../tests/test-utils";
 | 
			
		||||
import { copiedStyles } from "./actionStyles";
 | 
			
		||||
 | 
			
		||||
const { h } = window;
 | 
			
		||||
 | 
			
		||||
const mouse = new Pointer("mouse");
 | 
			
		||||
 | 
			
		||||
describe("actionStyles", () => {
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await render(<ExcalidrawApp />);
 | 
			
		||||
  });
 | 
			
		||||
  it("should copy & paste styles via keyboard", () => {
 | 
			
		||||
    UI.clickTool("rectangle");
 | 
			
		||||
    mouse.down(10, 10);
 | 
			
		||||
    mouse.up(20, 20);
 | 
			
		||||
 | 
			
		||||
    UI.clickTool("rectangle");
 | 
			
		||||
    mouse.down(10, 10);
 | 
			
		||||
    mouse.up(20, 20);
 | 
			
		||||
 | 
			
		||||
    // Change some styles of second rectangle
 | 
			
		||||
    UI.clickLabeledElement("Stroke");
 | 
			
		||||
    UI.clickLabeledElement(t("colors.c92a2a"));
 | 
			
		||||
    UI.clickLabeledElement("Background");
 | 
			
		||||
    UI.clickLabeledElement(t("colors.e64980"));
 | 
			
		||||
    // Fill style
 | 
			
		||||
    fireEvent.click(screen.getByTitle("Cross-hatch"));
 | 
			
		||||
    // Stroke width
 | 
			
		||||
    fireEvent.click(screen.getByTitle("Bold"));
 | 
			
		||||
    // Stroke style
 | 
			
		||||
    fireEvent.click(screen.getByTitle("Dotted"));
 | 
			
		||||
    // Roughness
 | 
			
		||||
    fireEvent.click(screen.getByTitle("Cartoonist"));
 | 
			
		||||
    // Opacity
 | 
			
		||||
    fireEvent.change(screen.getByLabelText("Opacity"), {
 | 
			
		||||
      target: { value: "60" },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    mouse.reset();
 | 
			
		||||
 | 
			
		||||
    API.setSelectedElements([h.elements[1]]);
 | 
			
		||||
 | 
			
		||||
    Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
 | 
			
		||||
      Keyboard.codeDown(CODES.C);
 | 
			
		||||
    });
 | 
			
		||||
    const secondRect = JSON.parse(copiedStyles)[0];
 | 
			
		||||
    expect(secondRect.id).toBe(h.elements[1].id);
 | 
			
		||||
 | 
			
		||||
    mouse.reset();
 | 
			
		||||
    // Paste styles to first rectangle
 | 
			
		||||
    API.setSelectedElements([h.elements[0]]);
 | 
			
		||||
    Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
 | 
			
		||||
      Keyboard.codeDown(CODES.V);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const firstRect = API.getSelectedElement();
 | 
			
		||||
    expect(firstRect.id).toBe(h.elements[0].id);
 | 
			
		||||
    expect(firstRect.strokeColor).toBe("#c92a2a");
 | 
			
		||||
    expect(firstRect.backgroundColor).toBe("#e64980");
 | 
			
		||||
    expect(firstRect.fillStyle).toBe("cross-hatch");
 | 
			
		||||
    expect(firstRect.strokeWidth).toBe(2); // Bold: 2
 | 
			
		||||
    expect(firstRect.strokeStyle).toBe("dotted");
 | 
			
		||||
    expect(firstRect.roughness).toBe(2); // Cartoonist: 2
 | 
			
		||||
    expect(firstRect.opacity).toBe(60);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -6,28 +6,37 @@ import {
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { getContainerElement } from "../element/textElement";
 | 
			
		||||
import { getBoundTextElement } from "../element/textElement";
 | 
			
		||||
import { hasBoundTextElement } from "../element/typeChecks";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
 | 
			
		||||
// `copiedStyles` is exported only for tests.
 | 
			
		||||
export let copiedStyles: string = "{}";
 | 
			
		||||
 | 
			
		||||
export const actionCopyStyles = register({
 | 
			
		||||
  name: "copyStyles",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const elementsCopied = [];
 | 
			
		||||
    const element = elements.find((el) => appState.selectedElementIds[el.id]);
 | 
			
		||||
    elementsCopied.push(element);
 | 
			
		||||
    if (element && hasBoundTextElement(element)) {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      elementsCopied.push(boundTextElement);
 | 
			
		||||
    }
 | 
			
		||||
    if (element) {
 | 
			
		||||
      copiedStyles = JSON.stringify(element);
 | 
			
		||||
      copiedStyles = JSON.stringify(elementsCopied);
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        toastMessage: t("toast.copyStyles"),
 | 
			
		||||
        toast: { message: t("toast.copyStyles") },
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
@@ -39,36 +48,64 @@ export const actionCopyStyles = register({
 | 
			
		||||
 | 
			
		||||
export const actionPasteStyles = register({
 | 
			
		||||
  name: "pasteStyles",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const pastedElement = JSON.parse(copiedStyles);
 | 
			
		||||
    const elementsCopied = JSON.parse(copiedStyles);
 | 
			
		||||
    const pastedElement = elementsCopied[0];
 | 
			
		||||
    const boundTextElement = elementsCopied[1];
 | 
			
		||||
    if (!isExcalidrawElement(pastedElement)) {
 | 
			
		||||
      return { elements, commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
    const selectedElementIds = selectedElements.map((element) => element.id);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) => {
 | 
			
		||||
        if (appState.selectedElementIds[element.id]) {
 | 
			
		||||
          const newElement = newElementWith(element, {
 | 
			
		||||
            backgroundColor: pastedElement?.backgroundColor,
 | 
			
		||||
            strokeWidth: pastedElement?.strokeWidth,
 | 
			
		||||
            strokeColor: pastedElement?.strokeColor,
 | 
			
		||||
            strokeStyle: pastedElement?.strokeStyle,
 | 
			
		||||
            fillStyle: pastedElement?.fillStyle,
 | 
			
		||||
            opacity: pastedElement?.opacity,
 | 
			
		||||
            roughness: pastedElement?.roughness,
 | 
			
		||||
          });
 | 
			
		||||
          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(
 | 
			
		||||
              element,
 | 
			
		||||
              getContainerElement(element),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
        if (selectedElementIds.includes(element.id)) {
 | 
			
		||||
          let elementStylesToCopyFrom = pastedElement;
 | 
			
		||||
          if (isTextElement(element) && element.containerId) {
 | 
			
		||||
            elementStylesToCopyFrom = boundTextElement;
 | 
			
		||||
          }
 | 
			
		||||
          if (!elementStylesToCopyFrom) {
 | 
			
		||||
            return element;
 | 
			
		||||
          }
 | 
			
		||||
          let newElement = newElementWith(element, {
 | 
			
		||||
            backgroundColor: elementStylesToCopyFrom?.backgroundColor,
 | 
			
		||||
            strokeWidth: elementStylesToCopyFrom?.strokeWidth,
 | 
			
		||||
            strokeColor: elementStylesToCopyFrom?.strokeColor,
 | 
			
		||||
            strokeStyle: elementStylesToCopyFrom?.strokeStyle,
 | 
			
		||||
            fillStyle: elementStylesToCopyFrom?.fillStyle,
 | 
			
		||||
            opacity: elementStylesToCopyFrom?.opacity,
 | 
			
		||||
            roughness: elementStylesToCopyFrom?.roughness,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          if (isTextElement(newElement)) {
 | 
			
		||||
            newElement = newElementWith(newElement, {
 | 
			
		||||
              fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
              fontFamily:
 | 
			
		||||
                elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
              textAlign:
 | 
			
		||||
                elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
			
		||||
            });
 | 
			
		||||
            let container = null;
 | 
			
		||||
            if (newElement.containerId) {
 | 
			
		||||
              container =
 | 
			
		||||
                selectedElements.find(
 | 
			
		||||
                  (element) =>
 | 
			
		||||
                    isTextElement(newElement) &&
 | 
			
		||||
                    element.id === newElement.containerId,
 | 
			
		||||
                ) || null;
 | 
			
		||||
            }
 | 
			
		||||
            redrawTextBoundingBox(newElement, container);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (newElement.type === "arrow") {
 | 
			
		||||
            newElement = newElementWith(newElement, {
 | 
			
		||||
              startArrowhead: elementStylesToCopyFrom.startArrowhead,
 | 
			
		||||
              endArrowhead: elementStylesToCopyFrom.endArrowhead,
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return newElement;
 | 
			
		||||
        }
 | 
			
		||||
        return element;
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleGridMode = register({
 | 
			
		||||
  name: "gridMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.gridSize,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "grid");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										66
									
								
								src/actions/actionToggleLock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/actions/actionToggleLock.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionToggleLock = register({
 | 
			
		||||
  name: "toggleLock",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
 | 
			
		||||
    if (!selectedElements.length) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const operation = getOperation(selectedElements);
 | 
			
		||||
    const selectedElementsMap = arrayToMap(selectedElements);
 | 
			
		||||
    const lock = operation === "lock";
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) => {
 | 
			
		||||
        if (!selectedElementsMap.has(element.id)) {
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return newElementWith(element, { locked: lock });
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedLinearElement: lock ? null : appState.selectedLinearElement,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: (elements, appState) => {
 | 
			
		||||
    const selected = getSelectedElements(elements, appState, false);
 | 
			
		||||
    if (selected.length === 1) {
 | 
			
		||||
      return selected[0].locked
 | 
			
		||||
        ? "labels.elementLock.unlock"
 | 
			
		||||
        : "labels.elementLock.lock";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (selected.length > 1) {
 | 
			
		||||
      return getOperation(selected) === "lock"
 | 
			
		||||
        ? "labels.elementLock.lockAll"
 | 
			
		||||
        : "labels.elementLock.unlockAll";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      "Unexpected zero elements to lock/unlock. This should never happen.",
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState, elements) => {
 | 
			
		||||
    return (
 | 
			
		||||
      event.key.toLocaleLowerCase() === KEYS.L &&
 | 
			
		||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      getSelectedElements(elements, appState, false).length > 0
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const getOperation = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
 | 
			
		||||
@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,13 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleViewMode = register({
 | 
			
		||||
  name: "viewMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.viewModeEnabled,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "view");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,13 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleZenMode = register({
 | 
			
		||||
  name: "zenMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.zenModeEnabled,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "zen");
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,44 +0,0 @@
 | 
			
		||||
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,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -18,6 +18,7 @@ import {
 | 
			
		||||
 | 
			
		||||
export const actionSendBackward = register({
 | 
			
		||||
  name: "sendBackward",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveOneLeft(elements, appState),
 | 
			
		||||
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
 | 
			
		||||
 | 
			
		||||
export const actionBringForward = register({
 | 
			
		||||
  name: "bringForward",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveOneRight(elements, appState),
 | 
			
		||||
@@ -72,6 +74,7 @@ export const actionBringForward = register({
 | 
			
		||||
 | 
			
		||||
export const actionSendToBack = register({
 | 
			
		||||
  name: "sendToBack",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveAllLeft(elements, appState),
 | 
			
		||||
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
 | 
			
		||||
 | 
			
		||||
export const actionBringToFront = register({
 | 
			
		||||
  name: "bringToFront",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveAllRight(elements, appState),
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ export {
 | 
			
		||||
  actionChangeFontSize,
 | 
			
		||||
  actionChangeFontFamily,
 | 
			
		||||
  actionChangeTextAlign,
 | 
			
		||||
  actionChangeVerticalAlign,
 | 
			
		||||
} from "./actionProperties";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
@@ -74,11 +75,13 @@ export {
 | 
			
		||||
  actionCut,
 | 
			
		||||
  actionCopyAsPng,
 | 
			
		||||
  actionCopyAsSvg,
 | 
			
		||||
  copyText,
 | 
			
		||||
} from "./actionClipboard";
 | 
			
		||||
 | 
			
		||||
export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
			
		||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
			
		||||
 | 
			
		||||
export { actionToggleStats } from "./actionToggleStats";
 | 
			
		||||
export { actionUnbindText } from "./actionUnbindText";
 | 
			
		||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
 | 
			
		||||
export { actionLink } from "../element/Hyperlink";
 | 
			
		||||
export { actionToggleLock } from "./actionToggleLock";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,47 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  Action,
 | 
			
		||||
  ActionsManagerInterface,
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
  ActionResult,
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
  ActionSource,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { MODES } from "../constants";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
const trackAction = (
 | 
			
		||||
  action: Action,
 | 
			
		||||
  source: ActionSource,
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
  value: any,
 | 
			
		||||
) => {
 | 
			
		||||
  if (action.trackEvent) {
 | 
			
		||||
    try {
 | 
			
		||||
      if (typeof action.trackEvent === "object") {
 | 
			
		||||
        const shouldTrack = action.trackEvent.predicate
 | 
			
		||||
          ? action.trackEvent.predicate(appState, elements, value)
 | 
			
		||||
          : true;
 | 
			
		||||
        if (shouldTrack) {
 | 
			
		||||
          trackEvent(
 | 
			
		||||
            action.trackEvent.category,
 | 
			
		||||
            action.trackEvent.action || action.name,
 | 
			
		||||
            `${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("error while logging action:", error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class ActionManager {
 | 
			
		||||
  actions = {} as Record<ActionName, Action>;
 | 
			
		||||
 | 
			
		||||
  updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 | 
			
		||||
 | 
			
		||||
@@ -65,9 +94,15 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
          ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    if (data.length === 0) {
 | 
			
		||||
    if (data.length !== 1) {
 | 
			
		||||
      if (data.length > 1) {
 | 
			
		||||
        console.warn("Canceling as multiple actions match this shortcut", data);
 | 
			
		||||
      }
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const action = data[0];
 | 
			
		||||
 | 
			
		||||
    const { viewModeEnabled } = this.getAppState();
 | 
			
		||||
    if (viewModeEnabled) {
 | 
			
		||||
      if (!Object.values(MODES).includes(data[0].name)) {
 | 
			
		||||
@@ -75,27 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
    const appState = this.getAppState();
 | 
			
		||||
    const value = null;
 | 
			
		||||
 | 
			
		||||
    trackAction(action, "keyboard", appState, elements, this.app, null);
 | 
			
		||||
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    this.updater(
 | 
			
		||||
      data[0].perform(
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
    this.updater(data[0].perform(elements, appState, value, this.app));
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  executeAction(action: Action) {
 | 
			
		||||
    this.updater(
 | 
			
		||||
      action.perform(
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  executeAction(action: Action, source: ActionSource = "api") {
 | 
			
		||||
    const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
    const appState = this.getAppState();
 | 
			
		||||
    const value = null;
 | 
			
		||||
 | 
			
		||||
    trackAction(action, source, appState, elements, this.app, value);
 | 
			
		||||
 | 
			
		||||
    this.updater(action.perform(elements, appState, value, this.app));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -113,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
    ) {
 | 
			
		||||
      const action = this.actions[name];
 | 
			
		||||
      const PanelComponent = action.PanelComponent!;
 | 
			
		||||
      const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
      const appState = this.getAppState();
 | 
			
		||||
      const updateData = (formState?: any) => {
 | 
			
		||||
        trackAction(action, "ui", appState, elements, this.app, formState);
 | 
			
		||||
 | 
			
		||||
        this.updater(
 | 
			
		||||
          action.perform(
 | 
			
		||||
            this.getElementsIncludingDeleted(),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,10 @@
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { isDarwin } from "../keys";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { ActionName } from "./types";
 | 
			
		||||
 | 
			
		||||
export type ShortcutName =
 | 
			
		||||
export type ShortcutName = SubtypeOf<
 | 
			
		||||
  ActionName,
 | 
			
		||||
  | "cut"
 | 
			
		||||
  | "copy"
 | 
			
		||||
  | "paste"
 | 
			
		||||
@@ -26,7 +28,9 @@ export type ShortcutName =
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "link";
 | 
			
		||||
  | "hyperlink"
 | 
			
		||||
  | "toggleLock"
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  cut: [getShortcutKey("CtrlOrCmd+X")],
 | 
			
		||||
@@ -63,11 +67,12 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  flipHorizontal: [getShortcutKey("Shift+H")],
 | 
			
		||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
			
		||||
  viewMode: [getShortcutKey("Alt+R")],
 | 
			
		||||
  link: [getShortcutKey("CtrlOrCmd+K")],
 | 
			
		||||
  hyperlink: [getShortcutKey("CtrlOrCmd+K")],
 | 
			
		||||
  toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
			
		||||
  const shortcuts = shortcutMap[name];
 | 
			
		||||
  // if multiple shortcuts availiable, take the first one
 | 
			
		||||
  // if multiple shortcuts available, take the first one
 | 
			
		||||
  return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,8 @@ import {
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
			
		||||
 | 
			
		||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
export type ActionResult =
 | 
			
		||||
@@ -39,6 +40,7 @@ export type ActionName =
 | 
			
		||||
  | "paste"
 | 
			
		||||
  | "copyAsPng"
 | 
			
		||||
  | "copyAsSvg"
 | 
			
		||||
  | "copyText"
 | 
			
		||||
  | "sendBackward"
 | 
			
		||||
  | "bringForward"
 | 
			
		||||
  | "sendToBack"
 | 
			
		||||
@@ -82,6 +84,7 @@ export type ActionName =
 | 
			
		||||
  | "zoomToSelection"
 | 
			
		||||
  | "changeFontFamily"
 | 
			
		||||
  | "changeTextAlign"
 | 
			
		||||
  | "changeVerticalAlign"
 | 
			
		||||
  | "toggleFullScreen"
 | 
			
		||||
  | "toggleShortcuts"
 | 
			
		||||
  | "group"
 | 
			
		||||
@@ -105,14 +108,17 @@ export type ActionName =
 | 
			
		||||
  | "increaseFontSize"
 | 
			
		||||
  | "decreaseFontSize"
 | 
			
		||||
  | "unbindText"
 | 
			
		||||
  | "link";
 | 
			
		||||
  | "hyperlink"
 | 
			
		||||
  | "eraser"
 | 
			
		||||
  | "bindText"
 | 
			
		||||
  | "toggleLock";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  updateData: (formData?: any) => void;
 | 
			
		||||
  appProps: ExcalidrawProps;
 | 
			
		||||
  data?: Partial<{ id: string; size: ToolButtonSize }>;
 | 
			
		||||
  data?: Record<string, any>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface Action {
 | 
			
		||||
@@ -136,12 +142,23 @@ export interface Action {
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  checked?: (appState: Readonly<AppState>) => boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ActionsManagerInterface {
 | 
			
		||||
  actions: Record<ActionName, Action>;
 | 
			
		||||
  registerAction: (action: Action) => void;
 | 
			
		||||
  handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
 | 
			
		||||
  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
			
		||||
  executeAction: (action: Action) => void;
 | 
			
		||||
  trackEvent:
 | 
			
		||||
    | false
 | 
			
		||||
    | {
 | 
			
		||||
        category:
 | 
			
		||||
          | "toolbar"
 | 
			
		||||
          | "element"
 | 
			
		||||
          | "canvas"
 | 
			
		||||
          | "export"
 | 
			
		||||
          | "history"
 | 
			
		||||
          | "menu"
 | 
			
		||||
          | "collab"
 | 
			
		||||
          | "hyperlink";
 | 
			
		||||
        action?: string;
 | 
			
		||||
        predicate?: (
 | 
			
		||||
          appState: Readonly<AppState>,
 | 
			
		||||
          elements: readonly ExcalidrawElement[],
 | 
			
		||||
          value: any,
 | 
			
		||||
        ) => boolean;
 | 
			
		||||
      };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,16 +3,20 @@ export const trackEvent =
 | 
			
		||||
  process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
 | 
			
		||||
  typeof window !== "undefined" &&
 | 
			
		||||
  window.gtag
 | 
			
		||||
    ? (category: string, name: string, label?: string, value?: number) => {
 | 
			
		||||
        window.gtag("event", name, {
 | 
			
		||||
          event_category: category,
 | 
			
		||||
          event_label: label,
 | 
			
		||||
          value,
 | 
			
		||||
        });
 | 
			
		||||
    ? (category: string, action: string, label?: string, value?: number) => {
 | 
			
		||||
        try {
 | 
			
		||||
          window.gtag("event", action, {
 | 
			
		||||
            event_category: category,
 | 
			
		||||
            event_label: label,
 | 
			
		||||
            value,
 | 
			
		||||
          });
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error("error logging to ga", error);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
 | 
			
		||||
    ? (category: string, name: string, label?: string, value?: number) => {}
 | 
			
		||||
    : (category: string, name: string, label?: string, value?: number) => {
 | 
			
		||||
    ? (category: string, action: string, label?: string, value?: number) => {}
 | 
			
		||||
    : (category: string, action: string, label?: string, value?: number) => {
 | 
			
		||||
        // Uncomment the next line to track locally
 | 
			
		||||
        // console.info("Track Event", category, name, label, value);
 | 
			
		||||
        // console.log("Track Event", { category, action, label, value });
 | 
			
		||||
      };
 | 
			
		||||
 
 | 
			
		||||
@@ -41,8 +41,12 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    editingElement: null,
 | 
			
		||||
    editingGroupId: null,
 | 
			
		||||
    editingLinearElement: null,
 | 
			
		||||
    elementLocked: false,
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    activeTool: {
 | 
			
		||||
      type: "selection",
 | 
			
		||||
      customType: null,
 | 
			
		||||
      locked: false,
 | 
			
		||||
      lastActiveToolBeforeEraser: null,
 | 
			
		||||
    },
 | 
			
		||||
    penMode: false,
 | 
			
		||||
    penDetected: false,
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
@@ -54,6 +58,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    gridSize: null,
 | 
			
		||||
    isBindingEnabled: true,
 | 
			
		||||
    isLibraryOpen: false,
 | 
			
		||||
    isLibraryMenuDocked: false,
 | 
			
		||||
    isLoading: false,
 | 
			
		||||
    isResizing: false,
 | 
			
		||||
    isRotating: false,
 | 
			
		||||
@@ -76,15 +81,16 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    showStats: false,
 | 
			
		||||
    startBoundElement: null,
 | 
			
		||||
    suggestedBindings: [],
 | 
			
		||||
    toastMessage: null,
 | 
			
		||||
    toast: null,
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: {
 | 
			
		||||
      value: 1 as NormalizedZoomValue,
 | 
			
		||||
    },
 | 
			
		||||
    viewModeEnabled: false,
 | 
			
		||||
    pendingImageElement: null,
 | 
			
		||||
    pendingImageElementId: null,
 | 
			
		||||
    showHyperlinkPopup: false,
 | 
			
		||||
    selectedLinearElement: null,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -130,10 +136,9 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  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 },
 | 
			
		||||
  activeTool: { browser: true, export: false, server: false },
 | 
			
		||||
  penMode: { browser: true, export: false, server: false },
 | 
			
		||||
  penDetected: { browser: true, export: false, server: false },
 | 
			
		||||
  errorMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false, server: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false, server: false },
 | 
			
		||||
@@ -143,7 +148,8 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  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 },
 | 
			
		||||
  isLibraryOpen: { browser: true, export: false, server: false },
 | 
			
		||||
  isLibraryMenuDocked: { browser: true, 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 },
 | 
			
		||||
@@ -168,14 +174,15 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  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 },
 | 
			
		||||
  toast: { 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 },
 | 
			
		||||
  pendingImageElementId: { browser: false, export: false, server: false },
 | 
			
		||||
  showHyperlinkPopup: { browser: false, export: false, server: false },
 | 
			
		||||
  selectedLinearElement: { browser: true, export: false, server: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const _clearAppStateForStorage = <
 | 
			
		||||
@@ -213,3 +220,9 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
 | 
			
		||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
 | 
			
		||||
  return _clearAppStateForStorage(appState, "server");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isEraserActive = ({
 | 
			
		||||
  activeTool,
 | 
			
		||||
}: {
 | 
			
		||||
  activeTool: AppState["activeTool"];
 | 
			
		||||
}) => activeTool.type === "eraser";
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										121
									
								
								src/charts.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/charts.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,121 @@
 | 
			
		||||
import {
 | 
			
		||||
  Spreadsheet,
 | 
			
		||||
  tryParseCells,
 | 
			
		||||
  tryParseNumber,
 | 
			
		||||
  VALID_SPREADSHEET,
 | 
			
		||||
} from "./charts";
 | 
			
		||||
 | 
			
		||||
describe("charts", () => {
 | 
			
		||||
  describe("tryParseNumber", () => {
 | 
			
		||||
    it.each<[string, number]>([
 | 
			
		||||
      ["1", 1],
 | 
			
		||||
      ["0", 0],
 | 
			
		||||
      ["-1", -1],
 | 
			
		||||
      ["0.1", 0.1],
 | 
			
		||||
      [".1", 0.1],
 | 
			
		||||
      ["1.", 1],
 | 
			
		||||
      ["424.", 424],
 | 
			
		||||
      ["$1", 1],
 | 
			
		||||
      ["-.1", -0.1],
 | 
			
		||||
      ["-$1", -1],
 | 
			
		||||
      ["$-1", -1],
 | 
			
		||||
    ])("should correctly identify %s as numbers", (given, expected) => {
 | 
			
		||||
      expect(tryParseNumber(given)).toEqual(expected);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
 | 
			
		||||
      "should correctly identify %s as not a number",
 | 
			
		||||
      (given) => {
 | 
			
		||||
        expect(tryParseNumber(given)).toBeNull();
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe("tryParseCells", () => {
 | 
			
		||||
    it("Successfully parses a spreadsheet", () => {
 | 
			
		||||
      const spreadsheet = [
 | 
			
		||||
        ["time", "value"],
 | 
			
		||||
        ["01:00", "61"],
 | 
			
		||||
        ["02:00", "-60"],
 | 
			
		||||
        ["03:00", "85"],
 | 
			
		||||
        ["04:00", "-67"],
 | 
			
		||||
        ["05:00", "54"],
 | 
			
		||||
        ["06:00", "95"],
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      const result = tryParseCells(spreadsheet);
 | 
			
		||||
 | 
			
		||||
      expect(result.type).toBe(VALID_SPREADSHEET);
 | 
			
		||||
 | 
			
		||||
      const { title, labels, values } = (
 | 
			
		||||
        result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
 | 
			
		||||
      ).spreadsheet;
 | 
			
		||||
 | 
			
		||||
      expect(title).toEqual("value");
 | 
			
		||||
      expect(labels).toEqual([
 | 
			
		||||
        "01:00",
 | 
			
		||||
        "02:00",
 | 
			
		||||
        "03:00",
 | 
			
		||||
        "04:00",
 | 
			
		||||
        "05:00",
 | 
			
		||||
        "06:00",
 | 
			
		||||
      ]);
 | 
			
		||||
      expect(values).toEqual([61, -60, 85, -67, 54, 95]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("Uses the second column as the label if it is not a number", () => {
 | 
			
		||||
      const spreadsheet = [
 | 
			
		||||
        ["time", "value"],
 | 
			
		||||
        ["01:00", "61"],
 | 
			
		||||
        ["02:00", "-60"],
 | 
			
		||||
        ["03:00", "85"],
 | 
			
		||||
        ["04:00", "-67"],
 | 
			
		||||
        ["05:00", "54"],
 | 
			
		||||
        ["06:00", "95"],
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      const result = tryParseCells(spreadsheet);
 | 
			
		||||
 | 
			
		||||
      expect(result.type).toBe(VALID_SPREADSHEET);
 | 
			
		||||
 | 
			
		||||
      const { title, labels, values } = (
 | 
			
		||||
        result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
 | 
			
		||||
      ).spreadsheet;
 | 
			
		||||
 | 
			
		||||
      expect(title).toEqual("value");
 | 
			
		||||
      expect(labels).toEqual([
 | 
			
		||||
        "01:00",
 | 
			
		||||
        "02:00",
 | 
			
		||||
        "03:00",
 | 
			
		||||
        "04:00",
 | 
			
		||||
        "05:00",
 | 
			
		||||
        "06:00",
 | 
			
		||||
      ]);
 | 
			
		||||
      expect(values).toEqual([61, -60, 85, -67, 54, 95]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("treats the first column as labels if both columns are numbers", () => {
 | 
			
		||||
      const spreadsheet = [
 | 
			
		||||
        ["time", "value"],
 | 
			
		||||
        ["01", "61"],
 | 
			
		||||
        ["02", "-60"],
 | 
			
		||||
        ["03", "85"],
 | 
			
		||||
        ["04", "-67"],
 | 
			
		||||
        ["05", "54"],
 | 
			
		||||
        ["06", "95"],
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      const result = tryParseCells(spreadsheet);
 | 
			
		||||
 | 
			
		||||
      expect(result.type).toBe(VALID_SPREADSHEET);
 | 
			
		||||
 | 
			
		||||
      const { title, labels, values } = (
 | 
			
		||||
        result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
 | 
			
		||||
      ).spreadsheet;
 | 
			
		||||
 | 
			
		||||
      expect(title).toEqual("value");
 | 
			
		||||
      expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
 | 
			
		||||
      expect(values).toEqual([61, -60, 85, -67, 54, 95]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -1,5 +1,10 @@
 | 
			
		||||
import colors from "./colors";
 | 
			
		||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  ENV,
 | 
			
		||||
  VERTICAL_ALIGN,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { newElement, newLinearElement, newTextElement } from "./element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "./element/types";
 | 
			
		||||
import { randomId } from "./random";
 | 
			
		||||
@@ -24,18 +29,24 @@ type ParseSpreadsheetResult =
 | 
			
		||||
  | { type: typeof NOT_SPREADSHEET; reason: string }
 | 
			
		||||
  | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
 | 
			
		||||
 | 
			
		||||
const tryParseNumber = (s: string): number | null => {
 | 
			
		||||
  const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
 | 
			
		||||
/**
 | 
			
		||||
 * @private exported for testing
 | 
			
		||||
 */
 | 
			
		||||
export const tryParseNumber = (s: string): number | null => {
 | 
			
		||||
  const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
 | 
			
		||||
  if (!match) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return parseFloat(match[1].replace(/,/g, ""));
 | 
			
		||||
  return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
 | 
			
		||||
  lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
 | 
			
		||||
 | 
			
		||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 | 
			
		||||
/**
 | 
			
		||||
 * @private exported for testing
 | 
			
		||||
 */
 | 
			
		||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 | 
			
		||||
  const numCols = cells[0].length;
 | 
			
		||||
 | 
			
		||||
  if (numCols > 2) {
 | 
			
		||||
@@ -66,13 +77,16 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
 | 
			
		||||
  const labelColumnNumeric = isNumericColumn(cells, 0);
 | 
			
		||||
  const valueColumnNumeric = isNumericColumn(cells, 1);
 | 
			
		||||
 | 
			
		||||
  if (!isNumericColumn(cells, valueColumnIndex)) {
 | 
			
		||||
  if (!labelColumnNumeric && !valueColumnNumeric) {
 | 
			
		||||
    return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const labelColumnIndex = (valueColumnIndex + 1) % 2;
 | 
			
		||||
  const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
 | 
			
		||||
    ? [0, 1]
 | 
			
		||||
    : [1, 0];
 | 
			
		||||
  const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
 | 
			
		||||
  const rows = hasHeader ? cells.slice(1) : cells;
 | 
			
		||||
 | 
			
		||||
@@ -103,7 +117,7 @@ const transposeCells = (cells: string[][]) => {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
 | 
			
		||||
  // Copy/paste from excel, spreadhseets, tsv, csv.
 | 
			
		||||
  // Copy/paste from excel, spreadsheets, tsv, csv.
 | 
			
		||||
  // For now we only accept 2 columns with an optional header
 | 
			
		||||
 | 
			
		||||
  // Check for tab separated values
 | 
			
		||||
@@ -161,7 +175,8 @@ const commonProps = {
 | 
			
		||||
  strokeSharpness: "sharp",
 | 
			
		||||
  strokeStyle: "solid",
 | 
			
		||||
  strokeWidth: 1,
 | 
			
		||||
  verticalAlign: "middle",
 | 
			
		||||
  verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
  locked: false,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,16 +2,16 @@ import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "./element/types";
 | 
			
		||||
import { getSelectedElements } from "./scene";
 | 
			
		||||
import { AppState, BinaryFiles } from "./types";
 | 
			
		||||
import { SVG_EXPORT_TAG } from "./scene/export";
 | 
			
		||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 | 
			
		||||
import { isInitializedImageElement } from "./element/typeChecks";
 | 
			
		||||
import { isPromiseLike } from "./utils";
 | 
			
		||||
 | 
			
		||||
type ElementsClipboard = {
 | 
			
		||||
  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
			
		||||
  elements: ExcalidrawElement[];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -56,19 +56,20 @@ const clipboardContainsElements = (
 | 
			
		||||
export const copyToClipboard = async (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  files: BinaryFiles,
 | 
			
		||||
  files: BinaryFiles | null,
 | 
			
		||||
) => {
 | 
			
		||||
  // select binded text elements when copying
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
  const contents: ElementsClipboard = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
			
		||||
    elements: selectedElements,
 | 
			
		||||
    files: selectedElements.reduce((acc, element) => {
 | 
			
		||||
      if (isInitializedImageElement(element) && files[element.fileId]) {
 | 
			
		||||
        acc[element.fileId] = files[element.fileId];
 | 
			
		||||
      }
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {} as BinaryFiles),
 | 
			
		||||
    elements,
 | 
			
		||||
    files: files
 | 
			
		||||
      ? elements.reduce((acc, element) => {
 | 
			
		||||
          if (isInitializedImageElement(element) && files[element.fileId]) {
 | 
			
		||||
            acc[element.fileId] = files[element.fileId];
 | 
			
		||||
          }
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {} as BinaryFiles)
 | 
			
		||||
      : undefined,
 | 
			
		||||
  };
 | 
			
		||||
  const json = JSON.stringify(contents);
 | 
			
		||||
  CLIPBOARD = json;
 | 
			
		||||
@@ -124,7 +125,7 @@ const getSystemClipboard = async (
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Attemps to parse clipboard. Prefers system clipboard.
 | 
			
		||||
 * Attempts to parse clipboard. Prefers system clipboard.
 | 
			
		||||
 */
 | 
			
		||||
export const parseClipboard = async (
 | 
			
		||||
  event: ClipboardEvent | null,
 | 
			
		||||
@@ -166,10 +167,35 @@ export const parseClipboard = async (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
 | 
			
		||||
  await navigator.clipboard.write([
 | 
			
		||||
    new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
 | 
			
		||||
  let promise;
 | 
			
		||||
  try {
 | 
			
		||||
    // in Safari so far we need to construct the ClipboardItem synchronously
 | 
			
		||||
    // (i.e. in the same tick) otherwise browser will complain for lack of
 | 
			
		||||
    // user intent. Using a Promise ClipboardItem constructor solves this.
 | 
			
		||||
    // https://bugs.webkit.org/show_bug.cgi?id=222262
 | 
			
		||||
    //
 | 
			
		||||
    // not await so that we can detect whether the thrown error likely relates
 | 
			
		||||
    // to a lack of support for the Promise ClipboardItem constructor
 | 
			
		||||
    promise = navigator.clipboard.write([
 | 
			
		||||
      new window.ClipboardItem({
 | 
			
		||||
        [MIME_TYPES.png]: blob,
 | 
			
		||||
      }),
 | 
			
		||||
    ]);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // if we're using a Promise ClipboardItem, let's try constructing
 | 
			
		||||
    // with resolution value instead
 | 
			
		||||
    if (isPromiseLike(blob)) {
 | 
			
		||||
      await navigator.clipboard.write([
 | 
			
		||||
        new window.ClipboardItem({
 | 
			
		||||
          [MIME_TYPES.png]: await blob,
 | 
			
		||||
        }),
 | 
			
		||||
      ]);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  await promise;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement, PointerType } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useDevice } from "../components/App";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
@@ -15,40 +15,59 @@ import {
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
import { AppState, Zoom } from "../types";
 | 
			
		||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
			
		||||
import {
 | 
			
		||||
  capitalizeString,
 | 
			
		||||
  isTransparent,
 | 
			
		||||
  updateActiveTool,
 | 
			
		||||
  setCursorForShape,
 | 
			
		||||
} from "../utils";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const SelectedShapeActions = ({
 | 
			
		||||
  appState,
 | 
			
		||||
  elements,
 | 
			
		||||
  renderAction,
 | 
			
		||||
  elementType,
 | 
			
		||||
  activeTool,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  renderAction: ActionManager["renderAction"];
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  activeTool: AppState["activeTool"]["type"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const targetElements = getTargetElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
    appState,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  let isSingleElementBoundContainer = false;
 | 
			
		||||
  if (
 | 
			
		||||
    targetElements.length === 2 &&
 | 
			
		||||
    (hasBoundTextElement(targetElements[0]) ||
 | 
			
		||||
      hasBoundTextElement(targetElements[1]))
 | 
			
		||||
  ) {
 | 
			
		||||
    isSingleElementBoundContainer = true;
 | 
			
		||||
  }
 | 
			
		||||
  const isEditing = Boolean(appState.editingElement);
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const device = useDevice();
 | 
			
		||||
  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 | 
			
		||||
 | 
			
		||||
  const showFillIcons =
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    hasBackground(activeTool) ||
 | 
			
		||||
    targetElements.some(
 | 
			
		||||
      (element) =>
 | 
			
		||||
        hasBackground(element.type) && !isTransparent(element.backgroundColor),
 | 
			
		||||
    );
 | 
			
		||||
  const showChangeBackgroundIcons =
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    hasBackground(activeTool) ||
 | 
			
		||||
    targetElements.some((element) => hasBackground(element.type));
 | 
			
		||||
 | 
			
		||||
  const showLinkIcon =
 | 
			
		||||
    targetElements.length === 1 || isSingleElementBoundContainer;
 | 
			
		||||
 | 
			
		||||
  let commonSelectedType: string | null = targetElements[0]?.type || null;
 | 
			
		||||
 | 
			
		||||
  for (const element of targetElements) {
 | 
			
		||||
@@ -60,23 +79,23 @@ export const SelectedShapeActions = ({
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="panelColumn">
 | 
			
		||||
      {((hasStrokeColor(elementType) &&
 | 
			
		||||
        elementType !== "image" &&
 | 
			
		||||
      {((hasStrokeColor(activeTool) &&
 | 
			
		||||
        activeTool !== "image" &&
 | 
			
		||||
        commonSelectedType !== "image") ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeColor")}
 | 
			
		||||
      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
			
		||||
      {showFillIcons && renderAction("changeFillStyle")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeWidth(elementType) ||
 | 
			
		||||
      {(hasStrokeWidth(activeTool) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeWidth(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeWidth")}
 | 
			
		||||
 | 
			
		||||
      {(elementType === "freedraw" ||
 | 
			
		||||
      {(activeTool === "freedraw" ||
 | 
			
		||||
        targetElements.some((element) => element.type === "freedraw")) &&
 | 
			
		||||
        renderAction("changeStrokeShape")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeStyle(elementType) ||
 | 
			
		||||
      {(hasStrokeStyle(activeTool) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeStyle(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeStrokeStyle")}
 | 
			
		||||
@@ -84,12 +103,12 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(canChangeSharpness(elementType) ||
 | 
			
		||||
      {(canChangeSharpness(activeTool) ||
 | 
			
		||||
        targetElements.some((element) => canChangeSharpness(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeSharpness")}</>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(hasText(elementType) ||
 | 
			
		||||
      {(hasText(activeTool) ||
 | 
			
		||||
        targetElements.some((element) => hasText(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeFontSize")}
 | 
			
		||||
@@ -100,7 +119,11 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(canHaveArrowheads(elementType) ||
 | 
			
		||||
      {targetElements.some(
 | 
			
		||||
        (element) =>
 | 
			
		||||
          hasBoundTextElement(element) || isBoundToContainer(element),
 | 
			
		||||
      ) && renderAction("changeVerticalAlign")}
 | 
			
		||||
      {(canHaveArrowheads(activeTool) ||
 | 
			
		||||
        targetElements.some((element) => canHaveArrowheads(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeArrowhead")}</>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -117,7 +140,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
 | 
			
		||||
      {targetElements.length > 1 && (
 | 
			
		||||
      {targetElements.length > 1 && !isSingleElementBoundContainer && (
 | 
			
		||||
        <fieldset>
 | 
			
		||||
          <legend>{t("labels.align")}</legend>
 | 
			
		||||
          <div className="buttonList">
 | 
			
		||||
@@ -150,15 +173,15 @@ export const SelectedShapeActions = ({
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
      {!isMobile && !isEditing && targetElements.length > 0 && (
 | 
			
		||||
      {!isEditing && targetElements.length > 0 && (
 | 
			
		||||
        <fieldset>
 | 
			
		||||
          <legend>{t("labels.actions")}</legend>
 | 
			
		||||
          <div className="buttonList">
 | 
			
		||||
            {renderAction("duplicateSelection")}
 | 
			
		||||
            {renderAction("deleteSelectedElements")}
 | 
			
		||||
            {!device.isMobile && renderAction("duplicateSelection")}
 | 
			
		||||
            {!device.isMobile && renderAction("deleteSelectedElements")}
 | 
			
		||||
            {renderAction("group")}
 | 
			
		||||
            {renderAction("ungroup")}
 | 
			
		||||
            {targetElements.length === 1 && renderAction("link")}
 | 
			
		||||
            {showLinkIcon && renderAction("hyperlink")}
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -168,14 +191,16 @@ export const SelectedShapeActions = ({
 | 
			
		||||
 | 
			
		||||
export const ShapesSwitcher = ({
 | 
			
		||||
  canvas,
 | 
			
		||||
  elementType,
 | 
			
		||||
  activeTool,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  appState,
 | 
			
		||||
}: {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  activeTool: AppState["activeTool"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  onImageAction: (data: { pointerType: PointerType | null }) => void;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
}) => (
 | 
			
		||||
  <>
 | 
			
		||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
@@ -190,20 +215,37 @@ export const ShapesSwitcher = ({
 | 
			
		||||
          key={value}
 | 
			
		||||
          type="radio"
 | 
			
		||||
          icon={icon}
 | 
			
		||||
          checked={elementType === value}
 | 
			
		||||
          checked={activeTool.type === value}
 | 
			
		||||
          name="editor-current-shape"
 | 
			
		||||
          title={`${capitalizeString(label)} — ${shortcut}`}
 | 
			
		||||
          keyBindingLabel={`${index + 1}`}
 | 
			
		||||
          aria-label={capitalizeString(label)}
 | 
			
		||||
          aria-keyshortcuts={shortcut}
 | 
			
		||||
          data-testid={value}
 | 
			
		||||
          onPointerDown={({ pointerType }) => {
 | 
			
		||||
            if (!appState.penDetected && pointerType === "pen") {
 | 
			
		||||
              setAppState({
 | 
			
		||||
                penDetected: true,
 | 
			
		||||
                penMode: true,
 | 
			
		||||
              });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          onChange={({ pointerType }) => {
 | 
			
		||||
            if (appState.activeTool.type !== value) {
 | 
			
		||||
              trackEvent("toolbar", value, "ui");
 | 
			
		||||
            }
 | 
			
		||||
            const nextActiveTool = updateActiveTool(appState, {
 | 
			
		||||
              type: value,
 | 
			
		||||
            });
 | 
			
		||||
            setAppState({
 | 
			
		||||
              elementType: value,
 | 
			
		||||
              activeTool: nextActiveTool,
 | 
			
		||||
              multiElement: null,
 | 
			
		||||
              selectedElementIds: {},
 | 
			
		||||
            });
 | 
			
		||||
            setCursorForShape(canvas, value);
 | 
			
		||||
            setCursorForShape(canvas, {
 | 
			
		||||
              ...appState,
 | 
			
		||||
              activeTool: nextActiveTool,
 | 
			
		||||
            });
 | 
			
		||||
            if (value === "image") {
 | 
			
		||||
              onImageAction({ pointerType });
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -12,5 +12,11 @@
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
 | 
			
		||||
    &-img {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      border-radius: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,36 @@
 | 
			
		||||
import "./Avatar.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { getClientInitials } from "../clients";
 | 
			
		||||
 | 
			
		||||
type AvatarProps = {
 | 
			
		||||
  children: string;
 | 
			
		||||
  onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
 | 
			
		||||
  color: string;
 | 
			
		||||
  border: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  src?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className="Avatar"
 | 
			
		||||
    style={{ background: color, border: `1px solid ${border}` }}
 | 
			
		||||
    onClick={onClick}
 | 
			
		||||
  >
 | 
			
		||||
    {children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
 | 
			
		||||
  const shortName = getClientInitials(name);
 | 
			
		||||
  const [error, setError] = useState(false);
 | 
			
		||||
  const loadImg = !error && src;
 | 
			
		||||
  const style = loadImg
 | 
			
		||||
    ? undefined
 | 
			
		||||
    : { background: color, border: `1px solid ${border}` };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="Avatar" style={style} onClick={onClick}>
 | 
			
		||||
      {loadImg ? (
 | 
			
		||||
        <img
 | 
			
		||||
          className="Avatar-img"
 | 
			
		||||
          src={src}
 | 
			
		||||
          alt={shortName}
 | 
			
		||||
          referrerPolicy="no-referrer"
 | 
			
		||||
          onError={() => setError(true)}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        shortName
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import "./Card.scss";
 | 
			
		||||
 | 
			
		||||
export const Card: React.FC<{
 | 
			
		||||
  color: keyof OpenColor | "primary";
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
}> = ({ children, color }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ export const CheckboxItem: React.FC<{
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange: (checked: boolean, event: React.MouseEvent) => void;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
}> = ({ children, checked, onChange, className }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { useDevice } from "./App";
 | 
			
		||||
import { trash } from "./icons";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
 | 
			
		||||
        icon={trash}
 | 
			
		||||
        title={t("buttons.clearReset")}
 | 
			
		||||
        aria-label={t("buttons.clearReset")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        showAriaLabel={useDevice().isMobile}
 | 
			
		||||
        onClick={toggleDialog}
 | 
			
		||||
        data-testid="clear-canvas-button"
 | 
			
		||||
      />
 | 
			
		||||
 
 | 
			
		||||
@@ -18,13 +18,15 @@
 | 
			
		||||
      left: -5px;
 | 
			
		||||
    }
 | 
			
		||||
    min-width: 1em;
 | 
			
		||||
    min-height: 1em;
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    bottom: -5px;
 | 
			
		||||
    padding: 3px;
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    background-color: $oc-green-6;
 | 
			
		||||
    color: $oc-white;
 | 
			
		||||
    font-size: 0.7em;
 | 
			
		||||
    font-family: var(--ui-font);
 | 
			
		||||
    font-size: 0.6em;
 | 
			
		||||
    font-family: "Cascadia";
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useDevice } from "../components/App";
 | 
			
		||||
import { users } from "./icons";
 | 
			
		||||
 | 
			
		||||
import "./CollabButton.scss";
 | 
			
		||||
@@ -26,9 +26,9 @@ const CollabButton = ({
 | 
			
		||||
        type="button"
 | 
			
		||||
        title={t("labels.liveCollaboration")}
 | 
			
		||||
        aria-label={t("labels.liveCollaboration")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        showAriaLabel={useDevice().isMobile}
 | 
			
		||||
      >
 | 
			
		||||
        {collaboratorCount > 0 && (
 | 
			
		||||
        {isCollaborating && (
 | 
			
		||||
          <div className="CollabButton-collaborators">{collaboratorCount}</div>
 | 
			
		||||
        )}
 | 
			
		||||
      </ToolButton>
 | 
			
		||||
 
 | 
			
		||||
@@ -46,7 +46,7 @@
 | 
			
		||||
    top: -11px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-content {
 | 
			
		||||
  .color-picker-content--default {
 | 
			
		||||
    padding: 0.5rem;
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: repeat(5, auto);
 | 
			
		||||
@@ -59,6 +59,26 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-content--canvas {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    padding: 0.25rem;
 | 
			
		||||
 | 
			
		||||
    &-title {
 | 
			
		||||
      color: $oc-gray-6;
 | 
			
		||||
      font-size: 12px;
 | 
			
		||||
      padding: 0 0.25rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-colors {
 | 
			
		||||
      padding: 0.5rem 0;
 | 
			
		||||
 | 
			
		||||
      .color-picker-swatch {
 | 
			
		||||
        margin: 0 0.25rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-content .color-input-container {
 | 
			
		||||
    grid-column: 1 / span 5;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,53 @@ import { isArrowKey, KEYS } from "../keys";
 | 
			
		||||
import { t, getLanguage } from "../i18n";
 | 
			
		||||
import { isWritableElement } from "../utils";
 | 
			
		||||
import colors from "../colors";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
const MAX_CUSTOM_COLORS = 5;
 | 
			
		||||
const MAX_DEFAULT_COLORS = 15;
 | 
			
		||||
 | 
			
		||||
export const getCustomColors = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  type: "elementBackground" | "elementStroke",
 | 
			
		||||
) => {
 | 
			
		||||
  const customColors: string[] = [];
 | 
			
		||||
  const updatedElements = elements
 | 
			
		||||
    .filter((element) => !element.isDeleted)
 | 
			
		||||
    .sort((ele1, ele2) => ele2.updated - ele1.updated);
 | 
			
		||||
 | 
			
		||||
  let index = 0;
 | 
			
		||||
  const elementColorTypeMap = {
 | 
			
		||||
    elementBackground: "backgroundColor",
 | 
			
		||||
    elementStroke: "strokeColor",
 | 
			
		||||
  };
 | 
			
		||||
  const colorType = elementColorTypeMap[type] as
 | 
			
		||||
    | "backgroundColor"
 | 
			
		||||
    | "strokeColor";
 | 
			
		||||
  while (
 | 
			
		||||
    index < updatedElements.length &&
 | 
			
		||||
    customColors.length < MAX_CUSTOM_COLORS
 | 
			
		||||
  ) {
 | 
			
		||||
    const element = updatedElements[index];
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      customColors.length < MAX_CUSTOM_COLORS &&
 | 
			
		||||
      isCustomColor(element[colorType], type) &&
 | 
			
		||||
      !customColors.includes(element[colorType])
 | 
			
		||||
    ) {
 | 
			
		||||
      customColors.push(element[colorType]);
 | 
			
		||||
    }
 | 
			
		||||
    index++;
 | 
			
		||||
  }
 | 
			
		||||
  return customColors;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isCustomColor = (
 | 
			
		||||
  color: string,
 | 
			
		||||
  type: "elementBackground" | "elementStroke",
 | 
			
		||||
) => {
 | 
			
		||||
  return !colors[type].includes(color);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isValidColor = (color: string) => {
 | 
			
		||||
  const style = new Option().style;
 | 
			
		||||
@@ -35,6 +82,7 @@ const keyBindings = [
 | 
			
		||||
  ["1", "2", "3", "4", "5"],
 | 
			
		||||
  ["q", "w", "e", "r", "t"],
 | 
			
		||||
  ["a", "s", "d", "f", "g"],
 | 
			
		||||
  ["z", "x", "c", "v", "b"],
 | 
			
		||||
].flat();
 | 
			
		||||
 | 
			
		||||
const Picker = ({
 | 
			
		||||
@@ -45,6 +93,7 @@ const Picker = ({
 | 
			
		||||
  label,
 | 
			
		||||
  showInput = true,
 | 
			
		||||
  type,
 | 
			
		||||
  elements,
 | 
			
		||||
}: {
 | 
			
		||||
  colors: string[];
 | 
			
		||||
  color: string | null;
 | 
			
		||||
@@ -53,12 +102,20 @@ const Picker = ({
 | 
			
		||||
  label: string;
 | 
			
		||||
  showInput: boolean;
 | 
			
		||||
  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
}) => {
 | 
			
		||||
  const firstItem = React.useRef<HTMLButtonElement>();
 | 
			
		||||
  const activeItem = React.useRef<HTMLButtonElement>();
 | 
			
		||||
  const gallery = React.useRef<HTMLDivElement>();
 | 
			
		||||
  const colorInput = React.useRef<HTMLInputElement>();
 | 
			
		||||
 | 
			
		||||
  const [customColors] = React.useState(() => {
 | 
			
		||||
    if (type === "canvasBackground") {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
    return getCustomColors(elements, type);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    // After the component is first mounted focus on first input
 | 
			
		||||
    if (activeItem.current) {
 | 
			
		||||
@@ -71,52 +128,119 @@ const Picker = ({
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown = (event: React.KeyboardEvent) => {
 | 
			
		||||
    if (event.key === KEYS.TAB) {
 | 
			
		||||
      const { activeElement } = document;
 | 
			
		||||
      if (event.shiftKey) {
 | 
			
		||||
        if (activeElement === firstItem.current) {
 | 
			
		||||
          colorInput.current?.focus();
 | 
			
		||||
          event.preventDefault();
 | 
			
		||||
        }
 | 
			
		||||
      } else if (activeElement === colorInput.current) {
 | 
			
		||||
        firstItem.current?.focus();
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
      }
 | 
			
		||||
    } else if (isArrowKey(event.key)) {
 | 
			
		||||
    let handled = false;
 | 
			
		||||
    if (isArrowKey(event.key)) {
 | 
			
		||||
      handled = true;
 | 
			
		||||
      const { activeElement } = document;
 | 
			
		||||
      const isRTL = getLanguage().rtl;
 | 
			
		||||
      const index = Array.prototype.indexOf.call(
 | 
			
		||||
        gallery!.current!.children,
 | 
			
		||||
      let isCustom = false;
 | 
			
		||||
      let index = Array.prototype.indexOf.call(
 | 
			
		||||
        gallery.current!.querySelector(".color-picker-content--default")
 | 
			
		||||
          ?.children,
 | 
			
		||||
        activeElement,
 | 
			
		||||
      );
 | 
			
		||||
      if (index !== -1) {
 | 
			
		||||
        const length = gallery!.current!.children.length - (showInput ? 1 : 0);
 | 
			
		||||
      if (index === -1) {
 | 
			
		||||
        index = Array.prototype.indexOf.call(
 | 
			
		||||
          gallery.current!.querySelector(".color-picker-content--canvas-colors")
 | 
			
		||||
            ?.children,
 | 
			
		||||
          activeElement,
 | 
			
		||||
        );
 | 
			
		||||
        if (index !== -1) {
 | 
			
		||||
          isCustom = true;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      const parentElement = isCustom
 | 
			
		||||
        ? gallery.current?.querySelector(".color-picker-content--canvas-colors")
 | 
			
		||||
        : gallery.current?.querySelector(".color-picker-content--default");
 | 
			
		||||
 | 
			
		||||
      if (parentElement && index !== -1) {
 | 
			
		||||
        const length = parentElement.children.length - (showInput ? 1 : 0);
 | 
			
		||||
        const nextIndex =
 | 
			
		||||
          event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
 | 
			
		||||
            ? (index + 1) % length
 | 
			
		||||
            : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
 | 
			
		||||
            ? (length + index - 1) % length
 | 
			
		||||
            : event.key === KEYS.ARROW_DOWN
 | 
			
		||||
            : !isCustom && event.key === KEYS.ARROW_DOWN
 | 
			
		||||
            ? (index + 5) % length
 | 
			
		||||
            : event.key === KEYS.ARROW_UP
 | 
			
		||||
            : !isCustom && event.key === KEYS.ARROW_UP
 | 
			
		||||
            ? (length + index - 5) % length
 | 
			
		||||
            : index;
 | 
			
		||||
        (gallery!.current!.children![nextIndex] as any).focus();
 | 
			
		||||
        (parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
 | 
			
		||||
      }
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
    } else if (
 | 
			
		||||
      keyBindings.includes(event.key.toLowerCase()) &&
 | 
			
		||||
      !event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      !event.altKey &&
 | 
			
		||||
      !isWritableElement(event.target)
 | 
			
		||||
    ) {
 | 
			
		||||
      handled = true;
 | 
			
		||||
      const index = keyBindings.indexOf(event.key.toLowerCase());
 | 
			
		||||
      (gallery!.current!.children![index] as any).focus();
 | 
			
		||||
      const isCustom = index >= MAX_DEFAULT_COLORS;
 | 
			
		||||
      const parentElement = isCustom
 | 
			
		||||
        ? gallery?.current?.querySelector(
 | 
			
		||||
            ".color-picker-content--canvas-colors",
 | 
			
		||||
          )
 | 
			
		||||
        : gallery?.current?.querySelector(".color-picker-content--default");
 | 
			
		||||
      const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
 | 
			
		||||
      (
 | 
			
		||||
        parentElement?.children[actualIndex] as HTMLElement | undefined
 | 
			
		||||
      )?.focus();
 | 
			
		||||
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
    } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
 | 
			
		||||
      handled = true;
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
    if (handled) {
 | 
			
		||||
      event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
      event.stopPropagation();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderColors = (colors: Array<string>, custom: boolean = false) => {
 | 
			
		||||
    return colors.map((_color, i) => {
 | 
			
		||||
      const _colorWithoutHash = _color.replace("#", "");
 | 
			
		||||
      const keyBinding = custom
 | 
			
		||||
        ? keyBindings[i + MAX_DEFAULT_COLORS]
 | 
			
		||||
        : keyBindings[i];
 | 
			
		||||
      const label = custom
 | 
			
		||||
        ? _colorWithoutHash
 | 
			
		||||
        : t(`colors.${_colorWithoutHash}`);
 | 
			
		||||
      return (
 | 
			
		||||
        <button
 | 
			
		||||
          className="color-picker-swatch"
 | 
			
		||||
          onClick={(event) => {
 | 
			
		||||
            (event.currentTarget as HTMLButtonElement).focus();
 | 
			
		||||
            onChange(_color);
 | 
			
		||||
          }}
 | 
			
		||||
          title={`${label}${
 | 
			
		||||
            !isTransparent(_color) ? ` (${_color})` : ""
 | 
			
		||||
          } — ${keyBinding.toUpperCase()}`}
 | 
			
		||||
          aria-label={label}
 | 
			
		||||
          aria-keyshortcuts={keyBindings[i]}
 | 
			
		||||
          style={{ color: _color }}
 | 
			
		||||
          key={_color}
 | 
			
		||||
          ref={(el) => {
 | 
			
		||||
            if (!custom && el && i === 0) {
 | 
			
		||||
              firstItem.current = el;
 | 
			
		||||
            }
 | 
			
		||||
            if (el && _color === color) {
 | 
			
		||||
              activeItem.current = el;
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          onFocus={() => {
 | 
			
		||||
            onChange(_color);
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {isTransparent(_color) ? (
 | 
			
		||||
            <div className="color-picker-transparent"></div>
 | 
			
		||||
          ) : undefined}
 | 
			
		||||
          <span className="color-picker-keybinding">{keyBinding}</span>
 | 
			
		||||
        </button>
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -136,43 +260,23 @@ const Picker = ({
 | 
			
		||||
            gallery.current = el;
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
        // to allow focusing by clicking but not by tabbing
 | 
			
		||||
        tabIndex={-1}
 | 
			
		||||
      >
 | 
			
		||||
        {colors.map((_color, i) => {
 | 
			
		||||
          const _colorWithoutHash = _color.replace("#", "");
 | 
			
		||||
          return (
 | 
			
		||||
            <button
 | 
			
		||||
              className="color-picker-swatch"
 | 
			
		||||
              onClick={(event) => {
 | 
			
		||||
                (event.currentTarget as HTMLButtonElement).focus();
 | 
			
		||||
                onChange(_color);
 | 
			
		||||
              }}
 | 
			
		||||
              title={`${t(`colors.${_colorWithoutHash}`)}${
 | 
			
		||||
                !isTransparent(_color) ? ` (${_color})` : ""
 | 
			
		||||
              } — ${keyBindings[i].toUpperCase()}`}
 | 
			
		||||
              aria-label={t(`colors.${_colorWithoutHash}`)}
 | 
			
		||||
              aria-keyshortcuts={keyBindings[i]}
 | 
			
		||||
              style={{ color: _color }}
 | 
			
		||||
              key={_color}
 | 
			
		||||
              ref={(el) => {
 | 
			
		||||
                if (el && i === 0) {
 | 
			
		||||
                  firstItem.current = el;
 | 
			
		||||
                }
 | 
			
		||||
                if (el && _color === color) {
 | 
			
		||||
                  activeItem.current = el;
 | 
			
		||||
                }
 | 
			
		||||
              }}
 | 
			
		||||
              onFocus={() => {
 | 
			
		||||
                onChange(_color);
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              {isTransparent(_color) ? (
 | 
			
		||||
                <div className="color-picker-transparent"></div>
 | 
			
		||||
              ) : undefined}
 | 
			
		||||
              <span className="color-picker-keybinding">{keyBindings[i]}</span>
 | 
			
		||||
            </button>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
        <div className="color-picker-content--default">
 | 
			
		||||
          {renderColors(colors)}
 | 
			
		||||
        </div>
 | 
			
		||||
        {!!customColors.length && (
 | 
			
		||||
          <div className="color-picker-content--canvas">
 | 
			
		||||
            <span className="color-picker-content--canvas-title">
 | 
			
		||||
              {t("labels.canvasColors")}
 | 
			
		||||
            </span>
 | 
			
		||||
            <div className="color-picker-content--canvas-colors">
 | 
			
		||||
              {renderColors(customColors, true)}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {showInput && (
 | 
			
		||||
          <ColorInput
 | 
			
		||||
            color={color}
 | 
			
		||||
@@ -246,6 +350,8 @@ export const ColorPicker = ({
 | 
			
		||||
  label,
 | 
			
		||||
  isActive,
 | 
			
		||||
  setActive,
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
}: {
 | 
			
		||||
  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
			
		||||
  color: string | null;
 | 
			
		||||
@@ -253,6 +359,8 @@ export const ColorPicker = ({
 | 
			
		||||
  label: string;
 | 
			
		||||
  isActive: boolean;
 | 
			
		||||
  setActive: (active: boolean) => void;
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
}) => {
 | 
			
		||||
  const pickerButton = React.useRef<HTMLButtonElement>(null);
 | 
			
		||||
 | 
			
		||||
@@ -294,6 +402,7 @@ export const ColorPicker = ({
 | 
			
		||||
              label={label}
 | 
			
		||||
              showInput={false}
 | 
			
		||||
              type={type}
 | 
			
		||||
              elements={elements}
 | 
			
		||||
            />
 | 
			
		||||
          </Popover>
 | 
			
		||||
        ) : null}
 | 
			
		||||
 
 | 
			
		||||
@@ -70,7 +70,9 @@ const ContextMenu = ({
 | 
			
		||||
                  dangerous: actionName === "deleteSelectedElements",
 | 
			
		||||
                  checkmark: option.checked?.(appState),
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => actionManager.executeAction(option)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  actionManager.executeAction(option, "contextMenu")
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                <div className="context-menu-option__label">{label}</div>
 | 
			
		||||
                <kbd className="context-menu-option__shortcut">
 | 
			
		||||
 
 | 
			
		||||
@@ -2,13 +2,14 @@ import clsx from "clsx";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
 | 
			
		||||
import { useExcalidrawContainer, useDevice } from "../components/App";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import "./Dialog.scss";
 | 
			
		||||
import { back, close } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import { Modal } from "./Modal";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { queryFocusableElements } from "../utils";
 | 
			
		||||
 | 
			
		||||
export interface DialogProps {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
@@ -64,14 +65,6 @@ export const Dialog = (props: DialogProps) => {
 | 
			
		||||
    return () => islandNode.removeEventListener("keydown", handleKeyDown);
 | 
			
		||||
  }, [islandNode, props.autofocus]);
 | 
			
		||||
 | 
			
		||||
  const queryFocusableElements = (node: HTMLElement) => {
 | 
			
		||||
    const focusableElements = node.querySelectorAll<HTMLElement>(
 | 
			
		||||
      "button, a, input, select, textarea, div[tabindex]",
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return focusableElements ? Array.from(focusableElements) : [];
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onClose = () => {
 | 
			
		||||
    (lastActiveElement as HTMLElement).focus();
 | 
			
		||||
    props.onCloseRequest();
 | 
			
		||||
@@ -92,9 +85,10 @@ export const Dialog = (props: DialogProps) => {
 | 
			
		||||
          <button
 | 
			
		||||
            className="Modal__close"
 | 
			
		||||
            onClick={onClose}
 | 
			
		||||
            title={t("buttons.close")}
 | 
			
		||||
            aria-label={t("buttons.close")}
 | 
			
		||||
          >
 | 
			
		||||
            {useIsMobile() ? back : close}
 | 
			
		||||
            {useDevice().isMobile ? back : close}
 | 
			
		||||
          </button>
 | 
			
		||||
        </h2>
 | 
			
		||||
        <div className="Dialog__content">{props.children}</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
        <Section title={t("helpDialog.shortcuts")}>
 | 
			
		||||
          <Columns>
 | 
			
		||||
            <Column>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.shapes")}>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.tools")}>
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.selection")}
 | 
			
		||||
                  shortcuts={["V", "1"]}
 | 
			
		||||
@@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  shortcuts={["R", "2"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
@@ -159,6 +159,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                <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("toolBar.eraser")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("E")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.editSelectedShape")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -359,6 +363,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                    getShortcutKey(`Alt+${t("helpDialog.drag")}`),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.toggleElementLock")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.undo")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import {
 | 
			
		||||
  isTextElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { isEraserActive } from "../appState";
 | 
			
		||||
 | 
			
		||||
interface HintViewerProps {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
@@ -19,25 +20,32 @@ interface HintViewerProps {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
  const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
 | 
			
		||||
  const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
 | 
			
		||||
  const multiMode = appState.multiElement !== null;
 | 
			
		||||
 | 
			
		||||
  if (elementType === "arrow" || elementType === "line") {
 | 
			
		||||
  if (appState.isLibraryOpen) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isEraserActive(appState)) {
 | 
			
		||||
    return t("hints.eraserRevert");
 | 
			
		||||
  }
 | 
			
		||||
  if (activeTool.type === "arrow" || activeTool.type === "line") {
 | 
			
		||||
    if (!multiMode) {
 | 
			
		||||
      return t("hints.linearElement");
 | 
			
		||||
    }
 | 
			
		||||
    return t("hints.linearElementMulti");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "freedraw") {
 | 
			
		||||
  if (activeTool.type === "freedraw") {
 | 
			
		||||
    return t("hints.freeDraw");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "text") {
 | 
			
		||||
  if (activeTool.type === "text") {
 | 
			
		||||
    return t("hints.text");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (appState.elementType === "image" && appState.pendingImageElement) {
 | 
			
		||||
  if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
 | 
			
		||||
    return t("hints.placeImage");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -69,7 +77,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
    return t("hints.text_editing");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "selection") {
 | 
			
		||||
  if (activeTool.type === "selection") {
 | 
			
		||||
    if (
 | 
			
		||||
      appState.draggingElement?.type === "selection" &&
 | 
			
		||||
      !appState.editingElement &&
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,11 @@
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { probablySupportsClipboardBlob } from "../clipboard";
 | 
			
		||||
import { canvasToBlob } from "../data/blob";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { useDevice } from "./App";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { exportToCanvas } from "../scene/export";
 | 
			
		||||
import { AppState, BinaryFiles } from "../types";
 | 
			
		||||
@@ -19,6 +18,7 @@ import OpenColor from "open-color";
 | 
			
		||||
import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
 | 
			
		||||
const supportsContextFilters =
 | 
			
		||||
  "filter" in document.createElement("canvas").getContext("2d")!;
 | 
			
		||||
@@ -58,6 +58,7 @@ const ExportButton: React.FC<{
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  title: string;
 | 
			
		||||
  shade?: number;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
@@ -90,7 +91,7 @@ const ImageExportModal = ({
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
@@ -170,7 +171,9 @@ const ImageExportModal = ({
 | 
			
		||||
        <Stack.Row gap={2}>
 | 
			
		||||
          {actionManager.renderAction("changeExportScale")}
 | 
			
		||||
        </Stack.Row>
 | 
			
		||||
        <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
 | 
			
		||||
        <p style={{ marginLeft: "1em", userSelect: "none" }}>
 | 
			
		||||
          {t("buttons.scale")}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
@@ -229,7 +232,7 @@ export const ImageExportDialog = ({
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
@@ -250,7 +253,7 @@ export const ImageExportDialog = ({
 | 
			
		||||
        icon={exportImage}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.exportImage")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        showAriaLabel={useDevice().isMobile}
 | 
			
		||||
        title={t("buttons.exportImage")}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
 
 | 
			
		||||
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const updateLang = async () => {
 | 
			
		||||
      await setLanguage(currentLang);
 | 
			
		||||
      setLoading(false);
 | 
			
		||||
    };
 | 
			
		||||
    const currentLang =
 | 
			
		||||
      languages.find((lang) => lang.code === props.langCode) || defaultLang;
 | 
			
		||||
    updateLang();
 | 
			
		||||
    setLoading(false);
 | 
			
		||||
  }, [props.langCode]);
 | 
			
		||||
 | 
			
		||||
  return loading ? <LoadingMessage /> : props.children;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,7 @@
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { useDevice } from "./App";
 | 
			
		||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { exportFile, exportToFileIcon, link } from "./icons";
 | 
			
		||||
@@ -12,6 +11,9 @@ import { Card } from "./Card";
 | 
			
		||||
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getFrame } from "../utils";
 | 
			
		||||
 | 
			
		||||
export type ExportCB = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
@@ -29,7 +31,7 @@ const JSONExportModal = ({
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
@@ -54,7 +56,7 @@ const JSONExportModal = ({
 | 
			
		||||
              aria-label={t("exportDialog.disk_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                actionManager.executeAction(actionSaveFileToDisk);
 | 
			
		||||
                actionManager.executeAction(actionSaveFileToDisk, "ui");
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
@@ -70,9 +72,10 @@ const JSONExportModal = ({
 | 
			
		||||
              title={t("exportDialog.link_button")}
 | 
			
		||||
              aria-label={t("exportDialog.link_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                onExportToBackend(elements, appState, files, canvas)
 | 
			
		||||
              }
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                onExportToBackend(elements, appState, files, canvas);
 | 
			
		||||
                trackEvent("export", "link", `ui (${getFrame()})`);
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
}) => {
 | 
			
		||||
@@ -114,7 +117,7 @@ export const JSONExportDialog = ({
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.export")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        showAriaLabel={useDevice().isMobile}
 | 
			
		||||
        title={t("buttons.export")}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user