mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	Compare commits
	
		
			125 Commits
		
	
	
		
			aakansha-h
			...
			test-failu
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a07ac46f8d | ||
|   | 157bb7d6d6 | ||
|   | d0b33d35db | ||
|   | d6a5ef1936 | ||
|   | c7a11f5cd2 | ||
|   | 893c487add | ||
|   | 99fdffdab7 | ||
|   | faad8a65f1 | ||
|   | 9d04479f98 | ||
|   | 599a8f3c6f | ||
|   | 0982da38fe | ||
|   | 699897f71b | ||
|   | 328ff6c32d | ||
|   | 618442299f | ||
|   | 06b45e0cfc | ||
|   | 809d5ba17f | ||
|   | 40d53d9231 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9803a85381 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 72784f9d29 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e3249f930c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | cbe0d34f1a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bed8093e47 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1255ca2e84 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 14d02dcaea | ||
|   | 9747223705 | ||
|   | 0f11f7da15 | ||
|   | 8420aecb34 | ||
|   | 08afb857c3 | ||
|   | 9230c8f4d2 | ||
|   | dba8f812f1 | ||
|   | fdd8552637 | ||
|   | c8370b394c | ||
|   | 5fcf6a4845 | ||
|   | af3b93c410 | ||
|   | 2595e0de82 | ||
|   | 8ec5f7b982 | ||
|   | 9086674b27 | ||
|   | 6273d56524 | ||
|   | 7e135c4e22 | ||
|   | b704705ed8 | ||
|   | d2e371cdf0 | ||
|   | 6ab3f0eb74 | ||
|   | 539505affd | ||
|   | 95d669390f | ||
|   | 73a45e1988 | ||
|   | 88c2812949 | ||
|   | bdb14723b3 | ||
|   | cc9e764585 | ||
|   | 8466eb0eef | ||
|   | 0ebe6292a3 | ||
|   | 5854ac3eed | ||
|   | 65d84a5d5a | ||
|   | 808366d112 | ||
|   | 9311c99d3c | ||
|   | d131b31084 | ||
|   | 0111ca2050 | ||
|   | a1dcd6d984 | ||
|   | fffd4957db | ||
|   | 760fd7b3a6 | ||
|   | 1933116261 | ||
|   | 8b33ca3a1a | ||
|   | a86224c797 | ||
|   | 66bbfda460 | ||
|   | 88b2f4707d | ||
|   | 25c6056b03 | ||
|   | baf9651d34 | ||
|   | d2181847be | ||
|   | 1f117995d9 | ||
|   | 52c96a6870 | ||
|   | 81fd2350a9 | ||
|   | 8ed0fc2c87 | ||
|   | 96a5d6548b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4709b953e7 | ||
|   | bbe0c35f66 | ||
|   | d273acb7e4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3c0b29d85f | ||
|   | bfbaeae67f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 74b9885955 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2cbe869a13 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a48607eb25 | ||
|   | 7831b6e74b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 640affe7c0 | ||
|   | 335aff8838 | ||
|   | dc97dc30bf | ||
|   | a0ecfed4cd | ||
|   | e201e79cd0 | ||
|   | e1c5c706c6 | ||
|   | bdc56090d7 | ||
|   | 58accc9310 | ||
|   | b91158198e | ||
|   | 938ce241ff | ||
|   | 0228646507 | ||
|   | 25ea97d0f9 | ||
|   | 8d5d68e589 | ||
|   | 6c15d9948b | ||
|   | e8fba43cf6 | ||
|   | 2e5c798c71 | ||
|   | 8c298336fc | ||
|   | 7f91cdc0c9 | ||
|   | 6334bd832f | ||
|   | 4d26993c8f | ||
|   | 1e69609ce4 | ||
|   | f5379d1563 | ||
|   | c8f6e3faa8 | ||
|   | 36bf17cf59 | ||
|   | 75458c3192 | ||
|   | 4cd25253bf | ||
|   | 78e254fb30 | ||
|   | 79bd3b8cda | ||
|   | 55110bf1b8 | ||
|   | 941b2d7042 | ||
|   | e9067de173 | ||
|   | fdc462ec01 | ||
|   | d1441afec9 | ||
|   | 3298aaf0c7 | ||
|   | e9a224a0de | ||
|   | 76cf560914 | ||
|   | 6c1246ef77 | ||
|   | b477c2ad6b | ||
|   | 4cb6f09559 | ||
|   | 8636ef1017 | ||
|   | 3a776f8795 | ||
|   | 9929a2be6f | ||
|   | 9cccac1458 | ||
|   | 7eaf47c9d4 | 
| @@ -1,5 +1,6 @@ | ||||
| * | ||||
| !.env | ||||
| !.env.development | ||||
| !.env.production | ||||
| !.eslintrc.json | ||||
| !.npmrc | ||||
| !.prettierrc | ||||
|   | ||||
| @@ -20,3 +20,5 @@ 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= | ||||
|  | ||||
| FAST_REFRESH=false | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							| @@ -2,7 +2,7 @@ name: Auto release excalidraw next | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|       - release | ||||
|  | ||||
| jobs: | ||||
|   Auto-release-excalidraw-next: | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/build-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/build-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,7 @@ name: Build Docker image | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|       - release | ||||
|  | ||||
| jobs: | ||||
|   build-docker: | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/cancel.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/cancel.yml
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,7 @@ name: Cancel previous runs | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|       - release | ||||
|   pull_request: | ||||
|  | ||||
| jobs: | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,7 @@ name: Publish Docker | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|       - release | ||||
|  | ||||
| jobs: | ||||
|   publish-docker: | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/sentry-production.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/sentry-production.yml
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,7 @@ name: New Sentry production release | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|       - release | ||||
|  | ||||
| jobs: | ||||
|   sentry: | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -25,4 +25,3 @@ src/packages/excalidraw/types | ||||
| src/packages/excalidraw/example/public/bundle.js | ||||
| src/packages/excalidraw/example/public/excalidraw-assets-dev | ||||
| src/packages/excalidraw/example/public/excalidraw.development.js | ||||
|  | ||||
|   | ||||
| @@ -1,2 +1,2 @@ | ||||
| #!/bin/sh | ||||
| yarn lint-staged | ||||
| # yarn lint-staged | ||||
|   | ||||
| @@ -4692,9 +4692,9 @@ json-schema-traverse@^1.0.0: | ||||
|   integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== | ||||
|  | ||||
| json5@^2.1.2, json5@^2.2.1: | ||||
|   version "2.2.1" | ||||
|   resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" | ||||
|   integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" | ||||
|   integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== | ||||
|  | ||||
| jsonfile@^6.0.1: | ||||
|   version "6.1.0" | ||||
| @@ -4755,9 +4755,9 @@ loader-runner@^4.2.0: | ||||
|   integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== | ||||
|  | ||||
| loader-utils@^2.0.0: | ||||
|   version "2.0.2" | ||||
|   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129" | ||||
|   integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A== | ||||
|   version "2.0.4" | ||||
|   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" | ||||
|   integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== | ||||
|   dependencies: | ||||
|     big.js "^5.2.2" | ||||
|     emojis-list "^3.0.0" | ||||
|   | ||||
							
								
								
									
										31
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								package.json
									
									
									
									
									
								
							| @@ -31,6 +31,7 @@ | ||||
|     "@types/socket.io-client": "1.4.36", | ||||
|     "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.4", | ||||
| @@ -41,7 +42,7 @@ | ||||
|     "nanoid": "3.3.3", | ||||
|     "open-color": "1.9.1", | ||||
|     "pako": "1.0.11", | ||||
|     "perfect-freehand": "1.0.16", | ||||
|     "perfect-freehand": "1.2.0", | ||||
|     "pica": "7.1.1", | ||||
|     "png-chunk-text": "1.0.0", | ||||
|     "png-chunks-encode": "1.0.0", | ||||
| @@ -50,11 +51,23 @@ | ||||
|     "pwacompat": "2.0.17", | ||||
|     "react": "18.2.0", | ||||
|     "react-dom": "18.2.0", | ||||
|     "react-scripts": "4.0.3", | ||||
|     "react-scripts": "5.0.1", | ||||
|     "roughjs": "4.5.2", | ||||
|     "sass": "1.51.0", | ||||
|     "socket.io-client": "2.3.1", | ||||
|     "typescript": "4.5.5" | ||||
|     "typescript": "4.9.4", | ||||
|     "workbox-background-sync": "^6.5.4", | ||||
|     "workbox-broadcast-update": "^6.5.4", | ||||
|     "workbox-cacheable-response": "^6.5.4", | ||||
|     "workbox-core": "^6.5.4", | ||||
|     "workbox-expiration": "^6.5.4", | ||||
|     "workbox-google-analytics": "^6.5.4", | ||||
|     "workbox-navigation-preload": "^6.5.4", | ||||
|     "workbox-precaching": "^6.5.4", | ||||
|     "workbox-range-requests": "^6.5.4", | ||||
|     "workbox-routing": "^6.5.4", | ||||
|     "workbox-strategies": "^6.5.4", | ||||
|     "workbox-streams": "^6.5.4" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@excalidraw/eslint-config": "1.0.0", | ||||
| @@ -67,6 +80,7 @@ | ||||
|     "dotenv": "16.0.1", | ||||
|     "eslint-config-prettier": "8.5.0", | ||||
|     "eslint-plugin-prettier": "3.3.1", | ||||
|     "http-server": "14.1.1", | ||||
|     "husky": "7.0.4", | ||||
|     "jest-canvas-mock": "2.4.0", | ||||
|     "lint-staged": "12.3.7", | ||||
| @@ -74,9 +88,6 @@ | ||||
|     "prettier": "2.6.2", | ||||
|     "rewire": "6.0.0" | ||||
|   }, | ||||
|   "resolutions": { | ||||
|     "@typescript-eslint/typescript-estree": "5.10.2" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "node": ">=14.0.0" | ||||
|   }, | ||||
| @@ -92,11 +103,10 @@ | ||||
|   "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_APP_DISABLE_TRACKING=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:prebuild": "node ./scripts/prebuild.js", | ||||
|     "build": "yarn build:prebuild && yarn build:app && yarn build:version", | ||||
|     "build": "yarn build:app && yarn build:version", | ||||
|     "eject": "react-scripts eject", | ||||
|     "fix:code": "yarn test:code --fix", | ||||
|     "fix:other": "yarn prettier --write", | ||||
| @@ -106,6 +116,7 @@ | ||||
|     "prepare": "husky install", | ||||
|     "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", | ||||
|     "start": "react-scripts start", | ||||
|     "start:production": "npm run build && npx http-server build -a localhost -p 5001 -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 .", | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-Bold.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-Bold.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-Medium.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-Medium.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-SemiBold.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-SemiBold.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -11,3 +11,28 @@ | ||||
|   src: url("Cascadia.woff2"); | ||||
|   font-display: swap; | ||||
| } | ||||
|  | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-Regular.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 400; | ||||
| } | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-Medium.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 500; | ||||
| } | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-SemiBold.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 600; | ||||
| } | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-Bold.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 700; | ||||
| } | ||||
|   | ||||
| @@ -8,49 +8,76 @@ | ||||
|       content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" | ||||
|     /> | ||||
|     <meta name="referrer" content="origin" /> | ||||
|  | ||||
|     <meta name="mobile-web-app-capable" content="yes" /> | ||||
|     <meta name="theme-color" content="#121212" /> | ||||
|  | ||||
|     <meta name="theme-color" content="#000" /> | ||||
|     <!-- Primary Meta Tags --> | ||||
|     <meta | ||||
|       name="title" | ||||
|       content="Excalidraw — Collaborative whiteboarding made easy" | ||||
|     /> | ||||
|     <meta | ||||
|       name="description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta name="image" content="https://excalidraw.com/og-general-v1.png" /> | ||||
|  | ||||
|     <!-- Open Graph / Facebook --> | ||||
|     <meta property="og:site_name" content="Excalidraw" /> | ||||
|     <meta property="og:type" content="website" /> | ||||
|     <meta property="og:url" content="https://excalidraw.com" /> | ||||
|     <meta | ||||
|       property="og:title" | ||||
|       content="Excalidraw — Collaborative whiteboarding made easy" | ||||
|     /> | ||||
|     <meta property="og:image:alt" content="Excalidraw logo" /> | ||||
|     <meta | ||||
|       property="og:description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta property="og:image" content="https://excalidraw.com/og-fb-v1.png" /> | ||||
|  | ||||
|     <!-- Twitter --> | ||||
|     <meta property="twitter:card" content="summary_large_image" /> | ||||
|     <meta property="twitter:site" content="@excalidraw" /> | ||||
|     <meta property="twitter:url" content="https://excalidraw.com" /> | ||||
|     <meta | ||||
|       property="twitter:title" | ||||
|       content="Excalidraw — Collaborative whiteboarding made easy" | ||||
|     /> | ||||
|     <meta | ||||
|       property="twitter:description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta | ||||
|       property="twitter:image" | ||||
|       content="https://excalidraw.com/og-twitter-v1.png" | ||||
|     /> | ||||
|  | ||||
|     <!-- General tags --> | ||||
|     <meta | ||||
|       name="description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta name="image" content="og-image.png" /> | ||||
|  | ||||
|     <!-- OpenGraph tags --> | ||||
|     <meta property="og:url" content="https://excalidraw.com" /> | ||||
|     <meta property="og:site_name" content="Excalidraw" /> | ||||
|     <meta property="og:type" content="website" /> | ||||
|     <meta property="og:title" content="Excalidraw" /> | ||||
|     <meta | ||||
|       property="og:description" | ||||
|       content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <!-- OG tags require an absolute url for images --> | ||||
|     <meta | ||||
|       property="og:image" | ||||
|       name="twitter:image" | ||||
|       content="https://excalidraw.com/og-image.png" | ||||
|     /> | ||||
|     <meta | ||||
|       property="og:image:secure_url" | ||||
|       name="twitter:image" | ||||
|       content="https://excalidraw.com/og-image.png" | ||||
|     /> | ||||
|     <meta property="og:image:width" content="1280" /> | ||||
|     <meta property="og:image:height" content="669" /> | ||||
|     <meta property="og:image:alt" content="Excalidraw logo with byline." /> | ||||
|  | ||||
|     <!-- Twitter Card tags --> | ||||
|     <meta name="twitter:card" content="summary_large_image" /> | ||||
|     <meta name="twitter:title" content="Excalidraw" /> | ||||
|     <meta | ||||
|       name="twitter:description" | ||||
|       content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <!-------------------------------------------------------------------------> | ||||
|     <!--   to minimize white flash on load when user has dark mode enabled   --> | ||||
|     <script> | ||||
|       try { | ||||
|         // | ||||
|         const theme = window.localStorage.getItem("excalidraw-theme"); | ||||
|         if (theme === "dark") { | ||||
|           document.documentElement.classList.add("dark"); | ||||
|         } | ||||
|       } catch {} | ||||
|     </script> | ||||
|     <style> | ||||
|       html.dark { | ||||
|         background-color: #121212; | ||||
|         color: #fff; | ||||
|       } | ||||
|     </style> | ||||
|     <!-------------------------------------------------------------------------> | ||||
|  | ||||
|     <script> | ||||
|       // Redirect Excalidraw+ users which have auto-redirect enabled. | ||||
| @@ -119,7 +146,8 @@ | ||||
|       // setting this so that libraries installation reuses this window tab. | ||||
|       window.name = "_excalidraw"; | ||||
|     </script> | ||||
|     <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> | ||||
|     <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' && | ||||
|     process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> | ||||
|     <script | ||||
|       async | ||||
|       src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%" | ||||
| @@ -139,8 +167,8 @@ | ||||
|       body, | ||||
|       html { | ||||
|         margin: 0; | ||||
|         --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, | ||||
|           Roboto, Helvetica, Arial, sans-serif; | ||||
|         --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, | ||||
|           Segoe UI, Roboto, Helvetica, Arial, sans-serif; | ||||
|         font-family: var(--ui-font); | ||||
|         -webkit-text-size-adjust: 100%; | ||||
|  | ||||
| @@ -155,7 +183,7 @@ | ||||
|         width: 1px; | ||||
|         overflow: hidden; | ||||
|         clip: rect(1px, 1px, 1px, 1px); | ||||
|         white-space: nowrap; /* added line */ | ||||
|         white-space: nowrap; | ||||
|         user-select: none; | ||||
|       } | ||||
|  | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/og-fb-v1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/og-fb-v1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 26 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/og-general-v1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/og-general-v1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 26 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/og-twitter-v1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/og-twitter-v1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 27 KiB | 
| @@ -1,3 +1,9 @@ | ||||
| User-agent: Twitterbot | ||||
| Disallow: | ||||
|  | ||||
| User-agent: facebookexternalhit | ||||
| Disallow: | ||||
|  | ||||
| user-agent: * | ||||
| Allow: /$ | ||||
| Disallow: / | ||||
|   | ||||
| @@ -1,81 +0,0 @@ | ||||
| // eslint-disable-next-line no-restricted-globals | ||||
| // eslint-disable-next-line no-unused-expressions | ||||
|  | ||||
| /* eslint-disable no-restricted-globals */ | ||||
| /* global importScripts, workbox */ | ||||
|  | ||||
| /** | ||||
|  * Welcome to your Workbox-powered service worker! | ||||
|  * | ||||
|  * You'll need to register this file in your web app and you should | ||||
|  * disable HTTP caching for this file too. | ||||
|  * See https://goo.gl/nhQhGp | ||||
|  * | ||||
|  * The rest of the code is auto-generated. Please don't update this file | ||||
|  * directly; instead, make changes to your Workbox build configuration | ||||
|  * and re-run your build process. | ||||
|  * See https://goo.gl/2aRDsh | ||||
|  */ | ||||
|  | ||||
| // in dev, `process` is undefined because this file is not compiled until build | ||||
| const IS_DEVELOPMENT = | ||||
|   typeof process === "undefined" || process.env.NODE_ENV !== "production"; | ||||
|  | ||||
| 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") { | ||||
|     self.skipWaiting(); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| workbox.core.clientsClaim(); | ||||
|  | ||||
| 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( | ||||
|   new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"), | ||||
|   new workbox.strategies.StaleWhileRevalidate({ | ||||
|     cacheName: "fonts", | ||||
|     plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })], | ||||
|   }), | ||||
| ); | ||||
|  | ||||
| self.addEventListener("fetch", (event) => { | ||||
|   if ( | ||||
|     event.request.method === "POST" && | ||||
|     event.request.url.endsWith("/web-share-target") | ||||
|   ) { | ||||
|     return event.respondWith( | ||||
|       (async () => { | ||||
|         const formData = await event.request.formData(); | ||||
|         const file = formData.get("file"); | ||||
|         const webShareTargetCache = await caches.open("web-share-target"); | ||||
|         await webShareTargetCache.put("shared-file", new Response(file)); | ||||
|         return Response.redirect("/?web-share-target", 303); | ||||
|       })(), | ||||
|     ); | ||||
|   } | ||||
| }); | ||||
| @@ -15,6 +15,7 @@ const crowdinMap = { | ||||
|   "fa-IR": "en-fa", | ||||
|   "fi-FI": "en-fi", | ||||
|   "fr-FR": "en-fr", | ||||
|   "gl-ES": "en-gl", | ||||
|   "he-IL": "en-he", | ||||
|   "hi-IN": "en-hi", | ||||
|   "hu-HU": "en-hu", | ||||
| @@ -23,6 +24,7 @@ const crowdinMap = { | ||||
|   "ja-JP": "en-ja", | ||||
|   "kab-KAB": "en-kab", | ||||
|   "ko-KR": "en-ko", | ||||
|   "ku-TR": "en-ku", | ||||
|   "my-MM": "en-my", | ||||
|   "nb-NO": "en-nb", | ||||
|   "nl-NL": "en-nl", | ||||
| @@ -48,8 +50,8 @@ const crowdinMap = { | ||||
|   "lv-LV": "en-lv", | ||||
|   "cs-CZ": "en-cs", | ||||
|   "kk-KZ": "en-kk", | ||||
|   "vi-vn": "en-vi", | ||||
|   "mr-in": "en-mr", | ||||
|   "vi-VN": "en-vi", | ||||
|   "mr-IN": "en-mr", | ||||
| }; | ||||
|  | ||||
| const flags = { | ||||
| @@ -65,6 +67,7 @@ const flags = { | ||||
|   "fa-IR": "🇮🇷", | ||||
|   "fi-FI": "🇫🇮", | ||||
|   "fr-FR": "🇫🇷", | ||||
|   "gl-ES": "🇪🇸", | ||||
|   "he-IL": "🇮🇱", | ||||
|   "hi-IN": "🇮🇳", | ||||
|   "hu-HU": "🇭🇺", | ||||
| @@ -74,6 +77,7 @@ const flags = { | ||||
|   "kab-KAB": "🏳", | ||||
|   "kk-KZ": "🇰🇿", | ||||
|   "ko-KR": "🇰🇷", | ||||
|   "ku-TR": "🏳", | ||||
|   "lt-LT": "🇱🇹", | ||||
|   "lv-LV": "🇱🇻", | ||||
|   "my-MM": "🇲🇲", | ||||
| @@ -116,6 +120,7 @@ const languages = { | ||||
|   "fa-IR": "فارسی", | ||||
|   "fi-FI": "Suomi", | ||||
|   "fr-FR": "Français", | ||||
|   "gl-ES": "Galego", | ||||
|   "he-IL": "עברית", | ||||
|   "hi-IN": "हिन्दी", | ||||
|   "hu-HU": "Magyar", | ||||
| @@ -125,6 +130,7 @@ const languages = { | ||||
|   "kab-KAB": "Taqbaylit", | ||||
|   "kk-KZ": "Қазақ тілі", | ||||
|   "ko-KR": "한국어", | ||||
|   "ku-TR": "Kurdî", | ||||
|   "lt-LT": "Lietuvių", | ||||
|   "lv-LV": "Latviešu", | ||||
|   "my-MM": "Burmese", | ||||
|   | ||||
| @@ -1,21 +0,0 @@ | ||||
| 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/"); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| // ----------------------------------------------------------------------------- | ||||
|  | ||||
| moveServiceWorkerScript(); | ||||
| @@ -60,7 +60,7 @@ export const actionAlignTop = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignTopIcon theme={appState.theme} />} | ||||
|       icon={AlignTopIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignTop")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Up", | ||||
| @@ -90,7 +90,7 @@ export const actionAlignBottom = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignBottomIcon theme={appState.theme} />} | ||||
|       icon={AlignBottomIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignBottom")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Down", | ||||
| @@ -120,7 +120,7 @@ export const actionAlignLeft = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignLeftIcon theme={appState.theme} />} | ||||
|       icon={AlignLeftIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignLeft")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Left", | ||||
| @@ -151,7 +151,7 @@ export const actionAlignRight = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignRightIcon theme={appState.theme} />} | ||||
|       icon={AlignRightIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignRight")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Right", | ||||
| @@ -180,7 +180,7 @@ export const actionAlignVerticallyCentered = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<CenterVerticallyIcon theme={appState.theme} />} | ||||
|       icon={CenterVerticallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={t("labels.centerVertically")} | ||||
|       aria-label={t("labels.centerVertically")} | ||||
| @@ -206,7 +206,7 @@ export const actionAlignHorizontallyCentered = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<CenterHorizontallyIcon theme={appState.theme} />} | ||||
|       icon={CenterHorizontallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={t("labels.centerHorizontally")} | ||||
|       aria-label={t("labels.centerHorizontally")} | ||||
|   | ||||
| @@ -6,6 +6,10 @@ import { | ||||
|   measureText, | ||||
|   redrawTextBoundingBox, | ||||
| } from "../element/textElement"; | ||||
| import { | ||||
|   getOriginalContainerHeightFromCache, | ||||
|   resetOriginalContainerCache, | ||||
| } from "../element/textWysiwyg"; | ||||
| import { | ||||
|   hasBoundTextElement, | ||||
|   isTextBindableContainer, | ||||
| @@ -22,7 +26,7 @@ export const actionUnbindText = register({ | ||||
|   name: "unbindText", | ||||
|   contextItemLabel: "labels.unbindText", | ||||
|   trackEvent: { category: "element" }, | ||||
|   contextItemPredicate: (elements, appState) => { | ||||
|   predicate: (elements, appState) => { | ||||
|     const selectedElements = getSelectedElements(elements, appState); | ||||
|     return selectedElements.some((element) => hasBoundTextElement(element)); | ||||
|   }, | ||||
| @@ -38,6 +42,11 @@ export const actionUnbindText = register({ | ||||
|           boundTextElement.originalText, | ||||
|           getFontString(boundTextElement), | ||||
|         ); | ||||
|         const originalContainerHeight = getOriginalContainerHeightFromCache( | ||||
|           element.id, | ||||
|         ); | ||||
|         resetOriginalContainerCache(element.id); | ||||
|  | ||||
|         mutateElement(boundTextElement as ExcalidrawTextElement, { | ||||
|           containerId: null, | ||||
|           width, | ||||
| @@ -49,6 +58,9 @@ export const actionUnbindText = register({ | ||||
|           boundElements: element.boundElements?.filter( | ||||
|             (ele) => ele.id !== boundTextElement.id, | ||||
|           ), | ||||
|           height: originalContainerHeight | ||||
|             ? originalContainerHeight | ||||
|             : element.height, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| @@ -64,7 +76,7 @@ export const actionBindText = register({ | ||||
|   name: "bindText", | ||||
|   contextItemLabel: "labels.bindText", | ||||
|   trackEvent: { category: "element" }, | ||||
|   contextItemPredicate: (elements, appState) => { | ||||
|   predicate: (elements, appState) => { | ||||
|     const selectedElements = getSelectedElements(elements, appState); | ||||
|  | ||||
|     if (selectedElements.length === 2) { | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| import { ColorPicker } from "../components/ColorPicker"; | ||||
| import { eraser, zoomIn, zoomOut } from "../components/icons"; | ||||
| import { eraser, ZoomInIcon, ZoomOutIcon } from "../components/icons"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { DarkModeToggle } from "../components/DarkModeToggle"; | ||||
| import { THEME, ZOOM_STEP } from "../constants"; | ||||
| import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; | ||||
| import { getCommonBounds, getNonDeletedElements } from "../element"; | ||||
| import { ExcalidrawElement } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| @@ -16,12 +15,17 @@ import { register } from "./register"; | ||||
| import { Tooltip } from "../components/Tooltip"; | ||||
| import { newElementWith } from "../element/mutateElement"; | ||||
| import { getDefaultAppState, isEraserActive } from "../appState"; | ||||
| import ClearCanvas from "../components/ClearCanvas"; | ||||
| import clsx from "clsx"; | ||||
|  | ||||
| export const actionChangeViewBackgroundColor = register({ | ||||
|   name: "changeViewBackgroundColor", | ||||
|   trackEvent: false, | ||||
|   predicate: (elements, appState, props, app) => { | ||||
|     return ( | ||||
|       !!app.props.UIOptions.canvasActions.changeViewBackgroundColor && | ||||
|       !appState.viewModeEnabled | ||||
|     ); | ||||
|   }, | ||||
|   perform: (_, appState, value) => { | ||||
|     return { | ||||
|       appState: { ...appState, ...value }, | ||||
| @@ -29,6 +33,7 @@ export const actionChangeViewBackgroundColor = register({ | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData }) => { | ||||
|     // FIXME move me to src/components/mainMenu/DefaultItems.tsx | ||||
|     return ( | ||||
|       <div style={{ position: "relative" }}> | ||||
|         <ColorPicker | ||||
| @@ -52,6 +57,12 @@ export const actionChangeViewBackgroundColor = register({ | ||||
| export const actionClearCanvas = register({ | ||||
|   name: "clearCanvas", | ||||
|   trackEvent: { category: "canvas" }, | ||||
|   predicate: (elements, appState, props, app) => { | ||||
|     return ( | ||||
|       !!app.props.UIOptions.canvasActions.clearCanvas && | ||||
|       !appState.viewModeEnabled | ||||
|     ); | ||||
|   }, | ||||
|   perform: (elements, appState, _, app) => { | ||||
|     app.imageCache.clear(); | ||||
|     return { | ||||
| @@ -77,12 +88,11 @@ export const actionClearCanvas = register({ | ||||
|       commitToHistory: true, | ||||
|     }; | ||||
|   }, | ||||
|  | ||||
|   PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />, | ||||
| }); | ||||
|  | ||||
| export const actionZoomIn = register({ | ||||
|   name: "zoomIn", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "canvas" }, | ||||
|   perform: (_elements, appState, _, app) => { | ||||
|     return { | ||||
| @@ -103,13 +113,13 @@ export const actionZoomIn = register({ | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={zoomIn} | ||||
|       className="zoom-in-button zoom-button" | ||||
|       icon={ZoomInIcon} | ||||
|       title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`} | ||||
|       aria-label={t("buttons.zoomIn")} | ||||
|       onClick={() => { | ||||
|         updateData(null); | ||||
|       }} | ||||
|       size="small" | ||||
|     /> | ||||
|   ), | ||||
|   keyTest: (event) => | ||||
| @@ -119,6 +129,7 @@ export const actionZoomIn = register({ | ||||
|  | ||||
| export const actionZoomOut = register({ | ||||
|   name: "zoomOut", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "canvas" }, | ||||
|   perform: (_elements, appState, _, app) => { | ||||
|     return { | ||||
| @@ -139,13 +150,13 @@ export const actionZoomOut = register({ | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={zoomOut} | ||||
|       className="zoom-out-button zoom-button" | ||||
|       icon={ZoomOutIcon} | ||||
|       title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`} | ||||
|       aria-label={t("buttons.zoomOut")} | ||||
|       onClick={() => { | ||||
|         updateData(null); | ||||
|       }} | ||||
|       size="small" | ||||
|     /> | ||||
|   ), | ||||
|   keyTest: (event) => | ||||
| @@ -155,6 +166,7 @@ export const actionZoomOut = register({ | ||||
|  | ||||
| export const actionResetZoom = register({ | ||||
|   name: "resetZoom", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "canvas" }, | ||||
|   perform: (_elements, appState, _, app) => { | ||||
|     return { | ||||
| @@ -176,13 +188,12 @@ export const actionResetZoom = register({ | ||||
|     <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> | ||||
|       <ToolButton | ||||
|         type="button" | ||||
|         className="reset-zoom-button" | ||||
|         className="reset-zoom-button zoom-button" | ||||
|         title={t("buttons.resetZoom")} | ||||
|         aria-label={t("buttons.resetZoom")} | ||||
|         onClick={() => { | ||||
|           updateData(null); | ||||
|         }} | ||||
|         size="small" | ||||
|       > | ||||
|         {(appState.zoom.value * 100).toFixed(0)}% | ||||
|       </ToolButton> | ||||
| @@ -206,7 +217,7 @@ const zoomValueToFitBoundsOnViewport = ( | ||||
|   const zoomAdjustedToSteps = | ||||
|     Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; | ||||
|   const clampedZoomValueToFitElements = Math.min( | ||||
|     Math.max(zoomAdjustedToSteps, ZOOM_STEP), | ||||
|     Math.max(zoomAdjustedToSteps, MIN_ZOOM), | ||||
|     1, | ||||
|   ); | ||||
|   return clampedZoomValueToFitElements as NormalizedZoomValue; | ||||
| @@ -265,6 +276,7 @@ export const actionZoomToSelected = register({ | ||||
|  | ||||
| export const actionZoomToFit = register({ | ||||
|   name: "zoomToFit", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "canvas" }, | ||||
|   perform: (elements, appState) => zoomToFitElements(elements, appState, false), | ||||
|   keyTest: (event) => | ||||
| @@ -276,6 +288,7 @@ export const actionZoomToFit = register({ | ||||
|  | ||||
| export const actionToggleTheme = register({ | ||||
|   name: "toggleTheme", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "canvas" }, | ||||
|   perform: (_, appState, value) => { | ||||
|     return { | ||||
| @@ -287,17 +300,10 @@ export const actionToggleTheme = register({ | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ appState, updateData }) => ( | ||||
|     <div style={{ marginInlineStart: "0.25rem" }}> | ||||
|       <DarkModeToggle | ||||
|         value={appState.theme} | ||||
|         onChange={(theme) => { | ||||
|           updateData(theme); | ||||
|         }} | ||||
|       /> | ||||
|     </div> | ||||
|   ), | ||||
|   keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, | ||||
|   predicate: (elements, appState, props, app) => { | ||||
|     return !!app.props.UIOptions.canvasActions.toggleTheme; | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export const actionErase = register({ | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { register } from "./register"; | ||||
| import { | ||||
|   copyTextToSystemClipboard, | ||||
|   copyToClipboard, | ||||
|   probablySupportsClipboardBlob, | ||||
|   probablySupportsClipboardWriteText, | ||||
| } from "../clipboard"; | ||||
| import { actionDeleteSelected } from "./actionDeleteSelected"; | ||||
| @@ -23,11 +24,31 @@ export const actionCopy = register({ | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   predicate: (elements, appState, appProps, app) => { | ||||
|     return app.device.isMobile && !!navigator.clipboard; | ||||
|   }, | ||||
|   contextItemLabel: "labels.copy", | ||||
|   // don't supply a shortcut since we handle this conditionally via onCopy event | ||||
|   keyTest: undefined, | ||||
| }); | ||||
|  | ||||
| export const actionPaste = register({ | ||||
|   name: "paste", | ||||
|   trackEvent: { category: "element" }, | ||||
|   perform: (elements: any, appStates: any, data, app) => { | ||||
|     app.pasteFromClipboard(null); | ||||
|     return { | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   predicate: (elements, appState, appProps, app) => { | ||||
|     return app.device.isMobile && !!navigator.clipboard; | ||||
|   }, | ||||
|   contextItemLabel: "labels.paste", | ||||
|   // don't supply a shortcut since we handle this conditionally via onCopy event | ||||
|   keyTest: undefined, | ||||
| }); | ||||
|  | ||||
| export const actionCut = register({ | ||||
|   name: "cut", | ||||
|   trackEvent: { category: "element" }, | ||||
| @@ -35,8 +56,11 @@ export const actionCut = register({ | ||||
|     actionCopy.perform(elements, appState, data, app); | ||||
|     return actionDeleteSelected.perform(elements, appState); | ||||
|   }, | ||||
|   predicate: (elements, appState, appProps, app) => { | ||||
|     return app.device.isMobile && !!navigator.clipboard; | ||||
|   }, | ||||
|   contextItemLabel: "labels.cut", | ||||
|   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X, | ||||
|   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, | ||||
| }); | ||||
|  | ||||
| export const actionCopyAsSvg = register({ | ||||
| @@ -77,6 +101,9 @@ export const actionCopyAsSvg = register({ | ||||
|       }; | ||||
|     } | ||||
|   }, | ||||
|   predicate: (elements) => { | ||||
|     return probablySupportsClipboardWriteText && elements.length > 0; | ||||
|   }, | ||||
|   contextItemLabel: "labels.copyAsSvg", | ||||
| }); | ||||
|  | ||||
| @@ -131,6 +158,9 @@ export const actionCopyAsPng = register({ | ||||
|       }; | ||||
|     } | ||||
|   }, | ||||
|   predicate: (elements) => { | ||||
|     return probablySupportsClipboardBlob && elements.length > 0; | ||||
|   }, | ||||
|   contextItemLabel: "labels.copyAsPng", | ||||
|   keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, | ||||
| }); | ||||
| @@ -158,7 +188,7 @@ export const copyText = register({ | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   contextItemPredicate: (elements, appState) => { | ||||
|   predicate: (elements, appState) => { | ||||
|     return ( | ||||
|       probablySupportsClipboardWriteText && | ||||
|       getSelectedElements(elements, appState, true).some(isTextElement) | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { isSomeElementSelected } from "../scene"; | ||||
| import { KEYS } from "../keys"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { trash } from "../components/icons"; | ||||
| import { t } from "../i18n"; | ||||
| import { register } from "./register"; | ||||
| import { getNonDeletedElements } from "../element"; | ||||
| @@ -13,6 +12,7 @@ import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { fixBindingsAfterDeletion } from "../element/binding"; | ||||
| import { isBoundToContainer } from "../element/typeChecks"; | ||||
| import { updateActiveTool } from "../utils"; | ||||
| import { TrashIcon } from "../components/icons"; | ||||
|  | ||||
| const deleteSelectedElements = ( | ||||
|   elements: readonly ExcalidrawElement[], | ||||
| @@ -72,13 +72,22 @@ export const actionDeleteSelected = register({ | ||||
|       if (!element) { | ||||
|         return false; | ||||
|       } | ||||
|       if ( | ||||
|         // case: no point selected → delete whole element | ||||
|         selectedPointsIndices == null || | ||||
|       // case: no point selected → do nothing, as deleting the whole element | ||||
|       // is most likely a mistake, where you wanted to delete a specific point | ||||
|       // but failed to select it (or you thought it's selected, while it was | ||||
|       // only in a hover state) | ||||
|       if (selectedPointsIndices == null) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       // case: deleting last remaining point | ||||
|         element.points.length < 2 | ||||
|       ) { | ||||
|         const nextElements = elements.filter((el) => el.id !== element.id); | ||||
|       if (element.points.length < 2) { | ||||
|         const nextElements = elements.map((el) => { | ||||
|           if (el.id === element.id) { | ||||
|             return newElementWith(el, { isDeleted: true }); | ||||
|           } | ||||
|           return el; | ||||
|         }); | ||||
|         const nextAppState = handleGroupEditingState(appState, nextElements); | ||||
|  | ||||
|         return { | ||||
| @@ -149,7 +158,7 @@ export const actionDeleteSelected = register({ | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={trash} | ||||
|       icon={TrashIcon} | ||||
|       title={t("labels.delete")} | ||||
|       aria-label={t("labels.delete")} | ||||
|       onClick={() => updateData(null)} | ||||
|   | ||||
| @@ -56,7 +56,7 @@ export const distributeHorizontally = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<DistributeHorizontallyIcon theme={appState.theme} />} | ||||
|       icon={DistributeHorizontallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.distributeHorizontally")} — ${getShortcutKey( | ||||
|         "Alt+H", | ||||
| @@ -86,7 +86,7 @@ export const distributeVertically = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<DistributeVerticallyIcon theme={appState.theme} />} | ||||
|       icon={DistributeVerticallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`} | ||||
|       aria-label={t("labels.distributeVertically")} | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import { ExcalidrawElement } from "../element/types"; | ||||
| import { duplicateElement, getNonDeletedElements } from "../element"; | ||||
| import { getSelectedElements, isSomeElementSelected } from "../scene"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { clone } from "../components/icons"; | ||||
| import { t } from "../i18n"; | ||||
| import { arrayToMap, getShortcutKey } from "../utils"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| @@ -19,6 +18,7 @@ import { ActionResult } from "./types"; | ||||
| import { GRID_SIZE } from "../constants"; | ||||
| import { bindTextToShapeAfterDuplication } from "../element/textElement"; | ||||
| import { isBoundToContainer } from "../element/typeChecks"; | ||||
| import { DuplicateIcon } from "../components/icons"; | ||||
|  | ||||
| export const actionDuplicateSelection = register({ | ||||
|   name: "duplicateSelection", | ||||
| @@ -49,7 +49,7 @@ export const actionDuplicateSelection = register({ | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={clone} | ||||
|       icon={DuplicateIcon} | ||||
|       title={`${t("labels.duplicateSelection")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+D", | ||||
|       )}`} | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { load, questionCircle, saveAs } from "../components/icons"; | ||||
| import { questionCircle, saveAs } from "../components/icons"; | ||||
| import { ProjectName } from "../components/ProjectName"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import "../components/ToolIcon.scss"; | ||||
| import { Tooltip } from "../components/Tooltip"; | ||||
| import { DarkModeToggle } from "../components/DarkModeToggle"; | ||||
| import { loadFromJSON, saveAsJSON } from "../data"; | ||||
| @@ -15,11 +14,12 @@ import { getExportSize } from "../scene/export"; | ||||
| import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants"; | ||||
| import { getSelectedElements, isSomeElementSelected } from "../scene"; | ||||
| import { getNonDeletedElements } from "../element"; | ||||
| import { ActiveFile } from "../components/ActiveFile"; | ||||
| import { isImageFileHandle } from "../data/blob"; | ||||
| import { nativeFileSystemSupported } from "../data/filesystem"; | ||||
| import { Theme } from "../element/types"; | ||||
|  | ||||
| import "../components/ToolIcon.scss"; | ||||
|  | ||||
| export const actionChangeProjectName = register({ | ||||
|   name: "changeProjectName", | ||||
|   trackEvent: false, | ||||
| @@ -131,6 +131,13 @@ export const actionChangeExportEmbedScene = register({ | ||||
| export const actionSaveToActiveFile = register({ | ||||
|   name: "saveToActiveFile", | ||||
|   trackEvent: { category: "export" }, | ||||
|   predicate: (elements, appState, props, app) => { | ||||
|     return ( | ||||
|       !!app.props.UIOptions.canvasActions.saveToActiveFile && | ||||
|       !!appState.fileHandle && | ||||
|       !appState.viewModeEnabled | ||||
|     ); | ||||
|   }, | ||||
|   perform: async (elements, appState, value, app) => { | ||||
|     const fileHandleExists = !!appState.fileHandle; | ||||
|  | ||||
| @@ -167,16 +174,11 @@ export const actionSaveToActiveFile = register({ | ||||
|   }, | ||||
|   keyTest: (event) => | ||||
|     event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey, | ||||
|   PanelComponent: ({ updateData, appState }) => ( | ||||
|     <ActiveFile | ||||
|       onSave={() => updateData(null)} | ||||
|       fileName={appState.fileHandle?.name} | ||||
|     /> | ||||
|   ), | ||||
| }); | ||||
|  | ||||
| export const actionSaveFileToDisk = register({ | ||||
|   name: "saveFileToDisk", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "export" }, | ||||
|   perform: async (elements, appState, value, app) => { | ||||
|     try { | ||||
| @@ -217,6 +219,11 @@ export const actionSaveFileToDisk = register({ | ||||
| export const actionLoadScene = register({ | ||||
|   name: "loadScene", | ||||
|   trackEvent: { category: "export" }, | ||||
|   predicate: (elements, appState, props, app) => { | ||||
|     return ( | ||||
|       !!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled | ||||
|     ); | ||||
|   }, | ||||
|   perform: async (elements, appState, _, app) => { | ||||
|     try { | ||||
|       const { | ||||
| @@ -244,17 +251,6 @@ export const actionLoadScene = register({ | ||||
|     } | ||||
|   }, | ||||
|   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={load} | ||||
|       title={t("buttons.load")} | ||||
|       aria-label={t("buttons.load")} | ||||
|       showAriaLabel={useDevice().isMobile} | ||||
|       onClick={updateData} | ||||
|       data-testid="load-button" | ||||
|     /> | ||||
|   ), | ||||
| }); | ||||
|  | ||||
| export const actionExportWithDarkMode = register({ | ||||
|   | ||||
| @@ -33,9 +33,6 @@ export const actionFinalize = register({ | ||||
|             endBindingElement, | ||||
|           ); | ||||
|         } | ||||
|         const selectedLinearElement = appState.selectedLinearElement | ||||
|           ? new LinearElementEditor(element, scene, appState) | ||||
|           : null; | ||||
|         return { | ||||
|           elements: | ||||
|             element.points.length < 2 || isInvisiblySmallElement(element) | ||||
| @@ -45,7 +42,6 @@ export const actionFinalize = register({ | ||||
|             ...appState, | ||||
|             cursorButton: "up", | ||||
|             editingLinearElement: null, | ||||
|             selectedLinearElement, | ||||
|           }, | ||||
|           commitToHistory: true, | ||||
|         }; | ||||
| @@ -188,7 +184,7 @@ export const actionFinalize = register({ | ||||
|         // To select the linear element when user has finished mutipoint editing | ||||
|         selectedLinearElement: | ||||
|           multiPointElement && isLinearElement(multiPointElement) | ||||
|             ? new LinearElementEditor(multiPointElement, scene, appState) | ||||
|             ? new LinearElementEditor(multiPointElement, scene) | ||||
|             : appState.selectedLinearElement, | ||||
|         pendingImageElementId: null, | ||||
|       }, | ||||
|   | ||||
| @@ -6,10 +6,15 @@ import { ExcalidrawElement, NonDeleted } from "../element/types"; | ||||
| import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; | ||||
| import { AppState } from "../types"; | ||||
| import { getTransformHandles } from "../element/transformHandles"; | ||||
| import { isFreeDrawElement, isLinearElement } from "../element/typeChecks"; | ||||
| import { updateBoundElements } from "../element/binding"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { arrayToMap } from "../utils"; | ||||
| import { | ||||
|   getElementAbsoluteCoords, | ||||
|   getElementPointsCoords, | ||||
| } from "../element/bounds"; | ||||
| import { isLinearElement } from "../element/typeChecks"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { KEYS } from "../keys"; | ||||
|  | ||||
| const enableActionFlipHorizontal = ( | ||||
|   elements: readonly ExcalidrawElement[], | ||||
| @@ -45,7 +50,7 @@ export const actionFlipHorizontal = register({ | ||||
|   }, | ||||
|   keyTest: (event) => event.shiftKey && event.code === "KeyH", | ||||
|   contextItemLabel: "labels.flipHorizontal", | ||||
|   contextItemPredicate: (elements, appState) => | ||||
|   predicate: (elements, appState) => | ||||
|     enableActionFlipHorizontal(elements, appState), | ||||
| }); | ||||
|  | ||||
| @@ -59,9 +64,10 @@ export const actionFlipVertical = register({ | ||||
|       commitToHistory: true, | ||||
|     }; | ||||
|   }, | ||||
|   keyTest: (event) => event.shiftKey && event.code === "KeyV", | ||||
|   keyTest: (event) => | ||||
|     event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD], | ||||
|   contextItemLabel: "labels.flipVertical", | ||||
|   contextItemPredicate: (elements, appState) => | ||||
|   predicate: (elements, appState) => | ||||
|     enableActionFlipVertical(elements, appState), | ||||
| }); | ||||
|  | ||||
| @@ -118,13 +124,6 @@ const flipElement = ( | ||||
|   const height = element.height; | ||||
|   const originalAngle = normalizeAngle(element.angle); | ||||
|  | ||||
|   let finalOffsetX = 0; | ||||
|   if (isLinearElement(element) || isFreeDrawElement(element)) { | ||||
|     finalOffsetX = | ||||
|       element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - | ||||
|       element.width; | ||||
|   } | ||||
|  | ||||
|   // Rotate back to zero, if necessary | ||||
|   mutateElement(element, { | ||||
|     angle: normalizeAngle(0), | ||||
| @@ -132,7 +131,6 @@ const flipElement = ( | ||||
|   // Flip unrotated by pulling TransformHandle to opposite side | ||||
|   const transformHandles = getTransformHandles(element, appState.zoom); | ||||
|   let usingNWHandle = true; | ||||
|   let newNCoordsX = 0; | ||||
|   let nHandle = transformHandles.nw; | ||||
|   if (!nHandle) { | ||||
|     // Use ne handle instead | ||||
| @@ -146,30 +144,47 @@ const flipElement = ( | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   let finalOffsetX = 0; | ||||
|   if (isLinearElement(element) && element.points.length < 3) { | ||||
|     finalOffsetX = | ||||
|       element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - | ||||
|       element.width; | ||||
|   } | ||||
|  | ||||
|   let initialPointsCoords; | ||||
|   if (isLinearElement(element)) { | ||||
|     initialPointsCoords = getElementPointsCoords(element, element.points); | ||||
|   } | ||||
|   const initialElementAbsoluteCoords = getElementAbsoluteCoords(element); | ||||
|  | ||||
|   if (isLinearElement(element) && element.points.length < 3) { | ||||
|     for (let index = 1; index < element.points.length; index++) { | ||||
|       LinearElementEditor.movePoints(element, [ | ||||
|         { index, point: [-element.points[index][0], element.points[index][1]] }, | ||||
|         { | ||||
|           index, | ||||
|           point: [-element.points[index][0], element.points[index][1]], | ||||
|         }, | ||||
|       ]); | ||||
|     } | ||||
|     LinearElementEditor.normalizePoints(element); | ||||
|   } else { | ||||
|     // calculate new x-coord for transformation | ||||
|     newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width; | ||||
|     const elWidth = initialPointsCoords | ||||
|       ? initialPointsCoords[2] - initialPointsCoords[0] | ||||
|       : initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0]; | ||||
|  | ||||
|     const startPoint = initialPointsCoords | ||||
|       ? [initialPointsCoords[0], initialPointsCoords[1]] | ||||
|       : [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]]; | ||||
|  | ||||
|     resizeSingleElement( | ||||
|       new Map().set(element.id, element), | ||||
|       true, | ||||
|       false, | ||||
|       element, | ||||
|       usingNWHandle ? "nw" : "ne", | ||||
|       false, | ||||
|       newNCoordsX, | ||||
|       nHandle[1], | ||||
|       true, | ||||
|       usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth, | ||||
|       startPoint[1], | ||||
|     ); | ||||
|     // fix the size to account for handle sizes | ||||
|     mutateElement(element, { | ||||
|       width, | ||||
|       height, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Rotate by (360 degrees - original angle) | ||||
| @@ -186,9 +201,30 @@ const flipElement = ( | ||||
|   mutateElement(element, { | ||||
|     x: originalX + finalOffsetX, | ||||
|     y: originalY, | ||||
|     width, | ||||
|     height, | ||||
|   }); | ||||
|  | ||||
|   updateBoundElements(element); | ||||
|  | ||||
|   if (initialPointsCoords && isLinearElement(element)) { | ||||
|     // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin. | ||||
|     // There's still room for improvement since when the line roughness is > 1 | ||||
|     // we still have a small offset of the origin when fliipping the element. | ||||
|     const finalPointsCoords = getElementPointsCoords(element, element.points); | ||||
|  | ||||
|     const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0]; | ||||
|     const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2]; | ||||
|  | ||||
|     const coordsDiff = topLeftCoordsDiff + topRightCoordDiff; | ||||
|  | ||||
|     mutateElement(element, { | ||||
|       x: element.x + coordsDiff * 0.5, | ||||
|       y: element.y, | ||||
|       width, | ||||
|       height, | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { CODES, KEYS } from "../keys"; | ||||
| import { KEYS } from "../keys"; | ||||
| import { t } from "../i18n"; | ||||
| import { arrayToMap, getShortcutKey } from "../utils"; | ||||
| import { register } from "./register"; | ||||
| @@ -129,10 +129,9 @@ export const actionGroup = register({ | ||||
|     }; | ||||
|   }, | ||||
|   contextItemLabel: "labels.group", | ||||
|   contextItemPredicate: (elements, appState) => | ||||
|     enableActionGroup(elements, appState), | ||||
|   predicate: (elements, appState) => enableActionGroup(elements, appState), | ||||
|   keyTest: (event) => | ||||
|     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G, | ||||
|     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
| @@ -189,10 +188,11 @@ export const actionUngroup = register({ | ||||
|     }; | ||||
|   }, | ||||
|   keyTest: (event) => | ||||
|     event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G, | ||||
|     event.shiftKey && | ||||
|     event[KEYS.CTRL_OR_CMD] && | ||||
|     event.key === KEYS.G.toUpperCase(), | ||||
|   contextItemLabel: "labels.ungroup", | ||||
|   contextItemPredicate: (elements, appState) => | ||||
|     getSelectedGroupIds(appState).length > 0, | ||||
|   predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, | ||||
|  | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Action, ActionResult } from "./types"; | ||||
| import { undo, redo } from "../components/icons"; | ||||
| import { UndoIcon, RedoIcon } from "../components/icons"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { t } from "../i18n"; | ||||
| import History, { HistoryEntry } from "../history"; | ||||
| @@ -72,7 +72,7 @@ export const createUndoAction: ActionCreator = (history) => ({ | ||||
|   PanelComponent: ({ updateData, data }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={undo} | ||||
|       icon={UndoIcon} | ||||
|       aria-label={t("buttons.undo")} | ||||
|       onClick={updateData} | ||||
|       size={data?.size || "medium"} | ||||
| @@ -94,7 +94,7 @@ export const createRedoAction: ActionCreator = (history) => ({ | ||||
|   PanelComponent: ({ updateData, data }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={redo} | ||||
|       icon={RedoIcon} | ||||
|       aria-label={t("buttons.redo")} | ||||
|       onClick={updateData} | ||||
|       size={data?.size || "medium"} | ||||
|   | ||||
							
								
								
									
										49
									
								
								src/actions/actionLinearEditor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/actions/actionLinearEditor.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import { getNonDeletedElements } from "../element"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { isLinearElement } from "../element/typeChecks"; | ||||
| import { ExcalidrawLinearElement } from "../element/types"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
| import { register } from "./register"; | ||||
|  | ||||
| export const actionToggleLinearEditor = register({ | ||||
|   name: "toggleLinearEditor", | ||||
|   trackEvent: { | ||||
|     category: "element", | ||||
|   }, | ||||
|   predicate: (elements, appState) => { | ||||
|     const selectedElements = getSelectedElements(elements, appState); | ||||
|     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { | ||||
|       return true; | ||||
|     } | ||||
|     return false; | ||||
|   }, | ||||
|   perform(elements, appState, _, app) { | ||||
|     const selectedElement = getSelectedElements( | ||||
|       getNonDeletedElements(elements), | ||||
|       appState, | ||||
|       true, | ||||
|     )[0] as ExcalidrawLinearElement; | ||||
|  | ||||
|     const editingLinearElement = | ||||
|       appState.editingLinearElement?.elementId === selectedElement.id | ||||
|         ? null | ||||
|         : new LinearElementEditor(selectedElement, app.scene); | ||||
|     return { | ||||
|       appState: { | ||||
|         ...appState, | ||||
|         editingLinearElement, | ||||
|       }, | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   contextItemLabel: (elements, appState) => { | ||||
|     const selectedElement = getSelectedElements( | ||||
|       getNonDeletedElements(elements), | ||||
|       appState, | ||||
|       true, | ||||
|     )[0] as ExcalidrawLinearElement; | ||||
|     return appState.editingLinearElement?.elementId === selectedElement.id | ||||
|       ? "labels.lineEditor.exit" | ||||
|       : "labels.lineEditor.edit"; | ||||
|   }, | ||||
| }); | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { menu, palette } from "../components/icons"; | ||||
| import { HamburgerMenuIcon, palette } from "../components/icons"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { t } from "../i18n"; | ||||
| import { showSelectedShapeActions, getNonDeletedElements } from "../element"; | ||||
| import { register } from "./register"; | ||||
| import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; | ||||
| import { CODES, KEYS } from "../keys"; | ||||
| import { HelpIcon } from "../components/HelpIcon"; | ||||
| import { KEYS } from "../keys"; | ||||
|  | ||||
| export const actionToggleCanvasMenu = register({ | ||||
|   name: "toggleCanvasMenu", | ||||
| @@ -20,7 +19,7 @@ export const actionToggleCanvasMenu = register({ | ||||
|   PanelComponent: ({ appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={menu} | ||||
|       icon={HamburgerMenuIcon} | ||||
|       aria-label={t("buttons.menu")} | ||||
|       onClick={updateData} | ||||
|       selected={appState.openMenu === "canvas"} | ||||
| @@ -55,6 +54,7 @@ export const actionToggleEditMenu = register({ | ||||
|  | ||||
| export const actionFullScreen = register({ | ||||
|   name: "toggleFullScreen", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() }, | ||||
|   perform: () => { | ||||
|     if (!isFullScreen()) { | ||||
| @@ -67,26 +67,24 @@ export const actionFullScreen = register({ | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD], | ||||
|   keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD], | ||||
| }); | ||||
|  | ||||
| export const actionShortcuts = register({ | ||||
|   name: "toggleShortcuts", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "menu", action: "toggleHelpDialog" }, | ||||
|   perform: (_elements, appState, _, { focusContainer }) => { | ||||
|     if (appState.showHelpDialog) { | ||||
|     if (appState.openDialog === "help") { | ||||
|       focusContainer(); | ||||
|     } | ||||
|     return { | ||||
|       appState: { | ||||
|         ...appState, | ||||
|         showHelpDialog: !appState.showHelpDialog, | ||||
|         openDialog: appState.openDialog === "help" ? null : "help", | ||||
|       }, | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <HelpIcon title={t("helpDialog.title")} onClick={updateData} /> | ||||
|   ), | ||||
|   keyTest: (event) => event.key === KEYS.QUESTION_MARK, | ||||
| }); | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { register } from "./register"; | ||||
|  | ||||
| export const actionGoToCollaborator = register({ | ||||
|   name: "goToCollaborator", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "collab" }, | ||||
|   perform: (_elements, appState, value) => { | ||||
|     const point = value as Collaborator["pointer"]; | ||||
|   | ||||
| @@ -2,42 +2,47 @@ import { AppState } from "../../src/types"; | ||||
| import { ButtonIconSelect } from "../components/ButtonIconSelect"; | ||||
| import { ColorPicker } from "../components/ColorPicker"; | ||||
| import { IconPicker } from "../components/IconPicker"; | ||||
| // TODO barnabasmolnar/editor-redesign | ||||
| // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, | ||||
| // ArrowHead icons | ||||
| import { | ||||
|   ArrowheadArrowIcon, | ||||
|   ArrowheadBarIcon, | ||||
|   ArrowheadDotIcon, | ||||
|   ArrowheadTriangleIcon, | ||||
|   ArrowheadNoneIcon, | ||||
|   EdgeRoundIcon, | ||||
|   EdgeSharpIcon, | ||||
|   FillCrossHatchIcon, | ||||
|   FillHachureIcon, | ||||
|   FillSolidIcon, | ||||
|   FontFamilyCodeIcon, | ||||
|   FontFamilyHandDrawnIcon, | ||||
|   FontFamilyNormalIcon, | ||||
|   FontSizeExtraLargeIcon, | ||||
|   FontSizeLargeIcon, | ||||
|   FontSizeMediumIcon, | ||||
|   FontSizeSmallIcon, | ||||
|   SloppinessArchitectIcon, | ||||
|   SloppinessArtistIcon, | ||||
|   SloppinessCartoonistIcon, | ||||
|   StrokeStyleDashedIcon, | ||||
|   StrokeStyleDottedIcon, | ||||
|   StrokeStyleSolidIcon, | ||||
|   StrokeWidthIcon, | ||||
|   TextAlignCenterIcon, | ||||
|   TextAlignLeftIcon, | ||||
|   TextAlignRightIcon, | ||||
|   TextAlignTopIcon, | ||||
|   TextAlignBottomIcon, | ||||
|   TextAlignMiddleIcon, | ||||
|   FillHachureIcon, | ||||
|   FillCrossHatchIcon, | ||||
|   FillSolidIcon, | ||||
|   SloppinessArchitectIcon, | ||||
|   SloppinessArtistIcon, | ||||
|   SloppinessCartoonistIcon, | ||||
|   StrokeWidthBaseIcon, | ||||
|   StrokeWidthBoldIcon, | ||||
|   StrokeWidthExtraBoldIcon, | ||||
|   FontSizeSmallIcon, | ||||
|   FontSizeMediumIcon, | ||||
|   FontSizeLargeIcon, | ||||
|   FontSizeExtraLargeIcon, | ||||
|   EdgeSharpIcon, | ||||
|   EdgeRoundIcon, | ||||
|   FreedrawIcon, | ||||
|   FontFamilyNormalIcon, | ||||
|   FontFamilyCodeIcon, | ||||
|   TextAlignLeftIcon, | ||||
|   TextAlignCenterIcon, | ||||
|   TextAlignRightIcon, | ||||
| } from "../components/icons"; | ||||
| import { | ||||
|   DEFAULT_FONT_FAMILY, | ||||
|   DEFAULT_FONT_SIZE, | ||||
|   FONT_FAMILY, | ||||
|   ROUNDNESS, | ||||
|   VERTICAL_ALIGN, | ||||
| } from "../constants"; | ||||
| import { | ||||
| @@ -53,7 +58,7 @@ import { | ||||
| import { | ||||
|   isBoundToContainer, | ||||
|   isLinearElement, | ||||
|   isLinearElementType, | ||||
|   isUsingAdaptiveRadius, | ||||
| } from "../element/typeChecks"; | ||||
| import { | ||||
|   Arrowhead, | ||||
| @@ -68,7 +73,7 @@ import { getLanguage, t } from "../i18n"; | ||||
| import { KEYS } from "../keys"; | ||||
| import { randomInteger } from "../random"; | ||||
| import { | ||||
|   canChangeSharpness, | ||||
|   canChangeRoundness, | ||||
|   canHaveArrowheads, | ||||
|   getCommonAttributeOfSelectedElements, | ||||
|   getSelectedElements, | ||||
| @@ -307,17 +312,17 @@ export const actionChangeFillStyle = register({ | ||||
|           { | ||||
|             value: "hachure", | ||||
|             text: t("labels.hachure"), | ||||
|             icon: <FillHachureIcon theme={appState.theme} />, | ||||
|             icon: FillHachureIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "cross-hatch", | ||||
|             text: t("labels.crossHatch"), | ||||
|             icon: <FillCrossHatchIcon theme={appState.theme} />, | ||||
|             icon: FillCrossHatchIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "solid", | ||||
|             text: t("labels.solid"), | ||||
|             icon: <FillSolidIcon theme={appState.theme} />, | ||||
|             icon: FillSolidIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         group="fill" | ||||
| @@ -358,17 +363,17 @@ export const actionChangeStrokeWidth = register({ | ||||
|           { | ||||
|             value: 1, | ||||
|             text: t("labels.thin"), | ||||
|             icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />, | ||||
|             icon: StrokeWidthBaseIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 2, | ||||
|             text: t("labels.bold"), | ||||
|             icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />, | ||||
|             icon: StrokeWidthBoldIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 4, | ||||
|             text: t("labels.extraBold"), | ||||
|             icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />, | ||||
|             icon: StrokeWidthExtraBoldIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -407,17 +412,17 @@ export const actionChangeSloppiness = register({ | ||||
|           { | ||||
|             value: 0, | ||||
|             text: t("labels.architect"), | ||||
|             icon: <SloppinessArchitectIcon theme={appState.theme} />, | ||||
|             icon: SloppinessArchitectIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 1, | ||||
|             text: t("labels.artist"), | ||||
|             icon: <SloppinessArtistIcon theme={appState.theme} />, | ||||
|             icon: SloppinessArtistIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 2, | ||||
|             text: t("labels.cartoonist"), | ||||
|             icon: <SloppinessCartoonistIcon theme={appState.theme} />, | ||||
|             icon: SloppinessCartoonistIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -455,17 +460,17 @@ export const actionChangeStrokeStyle = register({ | ||||
|           { | ||||
|             value: "solid", | ||||
|             text: t("labels.strokeStyle_solid"), | ||||
|             icon: <StrokeStyleSolidIcon theme={appState.theme} />, | ||||
|             icon: StrokeWidthBaseIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "dashed", | ||||
|             text: t("labels.strokeStyle_dashed"), | ||||
|             icon: <StrokeStyleDashedIcon theme={appState.theme} />, | ||||
|             icon: StrokeStyleDashedIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "dotted", | ||||
|             text: t("labels.strokeStyle_dotted"), | ||||
|             icon: <StrokeStyleDottedIcon theme={appState.theme} />, | ||||
|             icon: StrokeStyleDottedIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -535,25 +540,25 @@ export const actionChangeFontSize = register({ | ||||
|           { | ||||
|             value: 16, | ||||
|             text: t("labels.small"), | ||||
|             icon: <FontSizeSmallIcon theme={appState.theme} />, | ||||
|             icon: FontSizeSmallIcon, | ||||
|             testId: "fontSize-small", | ||||
|           }, | ||||
|           { | ||||
|             value: 20, | ||||
|             text: t("labels.medium"), | ||||
|             icon: <FontSizeMediumIcon theme={appState.theme} />, | ||||
|             icon: FontSizeMediumIcon, | ||||
|             testId: "fontSize-medium", | ||||
|           }, | ||||
|           { | ||||
|             value: 28, | ||||
|             text: t("labels.large"), | ||||
|             icon: <FontSizeLargeIcon theme={appState.theme} />, | ||||
|             icon: FontSizeLargeIcon, | ||||
|             testId: "fontSize-large", | ||||
|           }, | ||||
|           { | ||||
|             value: 36, | ||||
|             text: t("labels.veryLarge"), | ||||
|             icon: <FontSizeExtraLargeIcon theme={appState.theme} />, | ||||
|             icon: FontSizeExtraLargeIcon, | ||||
|             testId: "fontSize-veryLarge", | ||||
|           }, | ||||
|         ]} | ||||
| @@ -658,17 +663,17 @@ export const actionChangeFontFamily = register({ | ||||
|       { | ||||
|         value: FONT_FAMILY.Virgil, | ||||
|         text: t("labels.handDrawn"), | ||||
|         icon: <FontFamilyHandDrawnIcon theme={appState.theme} />, | ||||
|         icon: FreedrawIcon, | ||||
|       }, | ||||
|       { | ||||
|         value: FONT_FAMILY.Helvetica, | ||||
|         text: t("labels.normal"), | ||||
|         icon: <FontFamilyNormalIcon theme={appState.theme} />, | ||||
|         icon: FontFamilyNormalIcon, | ||||
|       }, | ||||
|       { | ||||
|         value: FONT_FAMILY.Cascadia, | ||||
|         text: t("labels.code"), | ||||
|         icon: <FontFamilyCodeIcon theme={appState.theme} />, | ||||
|         icon: FontFamilyCodeIcon, | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
| @@ -739,17 +744,17 @@ export const actionChangeTextAlign = register({ | ||||
|             { | ||||
|               value: "left", | ||||
|               text: t("labels.left"), | ||||
|               icon: <TextAlignLeftIcon theme={appState.theme} />, | ||||
|               icon: TextAlignLeftIcon, | ||||
|             }, | ||||
|             { | ||||
|               value: "center", | ||||
|               text: t("labels.center"), | ||||
|               icon: <TextAlignCenterIcon theme={appState.theme} />, | ||||
|               icon: TextAlignCenterIcon, | ||||
|             }, | ||||
|             { | ||||
|               value: "right", | ||||
|               text: t("labels.right"), | ||||
|               icon: <TextAlignRightIcon theme={appState.theme} />, | ||||
|               icon: TextAlignRightIcon, | ||||
|             }, | ||||
|           ]} | ||||
|           value={getFormValue( | ||||
| @@ -812,16 +817,19 @@ export const actionChangeVerticalAlign = register({ | ||||
|               value: VERTICAL_ALIGN.TOP, | ||||
|               text: t("labels.alignTop"), | ||||
|               icon: <TextAlignTopIcon theme={appState.theme} />, | ||||
|               testId: "align-top", | ||||
|             }, | ||||
|             { | ||||
|               value: VERTICAL_ALIGN.MIDDLE, | ||||
|               text: t("labels.centerVertically"), | ||||
|               icon: <TextAlignMiddleIcon theme={appState.theme} />, | ||||
|               testId: "align-middle", | ||||
|             }, | ||||
|             { | ||||
|               value: VERTICAL_ALIGN.BOTTOM, | ||||
|               text: t("labels.alignBottom"), | ||||
|               icon: <TextAlignBottomIcon theme={appState.theme} />, | ||||
|               testId: "align-bottom", | ||||
|             }, | ||||
|           ]} | ||||
|           value={getFormValue(elements, appState, (element) => { | ||||
| @@ -841,39 +849,41 @@ export const actionChangeVerticalAlign = register({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export const actionChangeSharpness = register({ | ||||
|   name: "changeSharpness", | ||||
| export const actionChangeRoundness = register({ | ||||
|   name: "changeRoundness", | ||||
|   trackEvent: false, | ||||
|   perform: (elements, appState, value) => { | ||||
|     const targetElements = getTargetElements( | ||||
|       getNonDeletedElements(elements), | ||||
|       appState, | ||||
|     ); | ||||
|     const shouldUpdateForNonLinearElements = targetElements.length | ||||
|       ? targetElements.every((el) => !isLinearElement(el)) | ||||
|       : !isLinearElementType(appState.activeTool.type); | ||||
|     const shouldUpdateForLinearElements = targetElements.length | ||||
|       ? targetElements.every(isLinearElement) | ||||
|       : isLinearElementType(appState.activeTool.type); | ||||
|     return { | ||||
|       elements: changeProperty(elements, appState, (el) => | ||||
|         newElementWith(el, { | ||||
|           strokeSharpness: value, | ||||
|           roundness: | ||||
|             value === "round" | ||||
|               ? { | ||||
|                   type: isUsingAdaptiveRadius(el.type) | ||||
|                     ? ROUNDNESS.ADAPTIVE_RADIUS | ||||
|                     : ROUNDNESS.PROPORTIONAL_RADIUS, | ||||
|                 } | ||||
|               : null, | ||||
|         }), | ||||
|       ), | ||||
|       appState: { | ||||
|         ...appState, | ||||
|         currentItemStrokeSharpness: shouldUpdateForNonLinearElements | ||||
|           ? value | ||||
|           : appState.currentItemStrokeSharpness, | ||||
|         currentItemLinearStrokeSharpness: shouldUpdateForLinearElements | ||||
|           ? value | ||||
|           : appState.currentItemLinearStrokeSharpness, | ||||
|         currentItemRoundness: value, | ||||
|       }, | ||||
|       commitToHistory: true, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|   PanelComponent: ({ elements, appState, updateData }) => { | ||||
|     const targetElements = getTargetElements( | ||||
|       getNonDeletedElements(elements), | ||||
|       appState, | ||||
|     ); | ||||
|  | ||||
|     const hasLegacyRoundness = targetElements.some( | ||||
|       (el) => el.roundness?.type === ROUNDNESS.LEGACY, | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|       <fieldset> | ||||
|         <legend>{t("labels.edges")}</legend> | ||||
|         <ButtonIconSelect | ||||
| @@ -882,28 +892,28 @@ export const actionChangeSharpness = register({ | ||||
|             { | ||||
|               value: "sharp", | ||||
|               text: t("labels.sharp"), | ||||
|             icon: <EdgeSharpIcon theme={appState.theme} />, | ||||
|               icon: EdgeSharpIcon, | ||||
|             }, | ||||
|             { | ||||
|               value: "round", | ||||
|               text: t("labels.round"), | ||||
|             icon: <EdgeRoundIcon theme={appState.theme} />, | ||||
|               icon: EdgeRoundIcon, | ||||
|             }, | ||||
|           ]} | ||||
|           value={getFormValue( | ||||
|             elements, | ||||
|             appState, | ||||
|           (element) => element.strokeSharpness, | ||||
|           (canChangeSharpness(appState.activeTool.type) && | ||||
|             (isLinearElementType(appState.activeTool.type) | ||||
|               ? appState.currentItemLinearStrokeSharpness | ||||
|               : appState.currentItemStrokeSharpness)) || | ||||
|             (element) => | ||||
|               hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", | ||||
|             (canChangeRoundness(appState.activeTool.type) && | ||||
|               appState.currentItemRoundness) || | ||||
|               null, | ||||
|           )} | ||||
|           onChange={(value) => updateData(value)} | ||||
|         /> | ||||
|       </fieldset> | ||||
|   ), | ||||
|     ); | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export const actionChangeArrowhead = register({ | ||||
| @@ -949,42 +959,38 @@ export const actionChangeArrowhead = register({ | ||||
|     return ( | ||||
|       <fieldset> | ||||
|         <legend>{t("labels.arrowheads")}</legend> | ||||
|         <div className="iconSelectList"> | ||||
|         <div className="iconSelectList buttonList"> | ||||
|           <IconPicker | ||||
|             label="arrowhead_start" | ||||
|             options={[ | ||||
|               { | ||||
|                 value: null, | ||||
|                 text: t("labels.arrowhead_none"), | ||||
|                 icon: <ArrowheadNoneIcon theme={appState.theme} />, | ||||
|                 icon: ArrowheadNoneIcon, | ||||
|                 keyBinding: "q", | ||||
|               }, | ||||
|               { | ||||
|                 value: "arrow", | ||||
|                 text: t("labels.arrowhead_arrow"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadArrowIcon flip={!isRTL} />, | ||||
|                 keyBinding: "w", | ||||
|               }, | ||||
|               { | ||||
|                 value: "bar", | ||||
|                 text: t("labels.arrowhead_bar"), | ||||
|                 icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />, | ||||
|                 icon: <ArrowheadBarIcon flip={!isRTL} />, | ||||
|                 keyBinding: "e", | ||||
|               }, | ||||
|               { | ||||
|                 value: "dot", | ||||
|                 text: t("labels.arrowhead_dot"), | ||||
|                 icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />, | ||||
|                 icon: <ArrowheadDotIcon flip={!isRTL} />, | ||||
|                 keyBinding: "r", | ||||
|               }, | ||||
|               { | ||||
|                 value: "triangle", | ||||
|                 text: t("labels.arrowhead_triangle"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadTriangleIcon flip={!isRTL} />, | ||||
|                 keyBinding: "t", | ||||
|               }, | ||||
|             ]} | ||||
| @@ -1007,34 +1013,30 @@ export const actionChangeArrowhead = register({ | ||||
|                 value: null, | ||||
|                 text: t("labels.arrowhead_none"), | ||||
|                 keyBinding: "q", | ||||
|                 icon: <ArrowheadNoneIcon theme={appState.theme} />, | ||||
|                 icon: ArrowheadNoneIcon, | ||||
|               }, | ||||
|               { | ||||
|                 value: "arrow", | ||||
|                 text: t("labels.arrowhead_arrow"), | ||||
|                 keyBinding: "w", | ||||
|                 icon: ( | ||||
|                   <ArrowheadArrowIcon theme={appState.theme} flip={isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadArrowIcon flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "bar", | ||||
|                 text: t("labels.arrowhead_bar"), | ||||
|                 keyBinding: "e", | ||||
|                 icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />, | ||||
|                 icon: <ArrowheadBarIcon flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "dot", | ||||
|                 text: t("labels.arrowhead_dot"), | ||||
|                 keyBinding: "r", | ||||
|                 icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />, | ||||
|                 icon: <ArrowheadDotIcon flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "triangle", | ||||
|                 text: t("labels.arrowhead_triangle"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadTriangleIcon flip={isRTL} />, | ||||
|                 keyBinding: "t", | ||||
|               }, | ||||
|             ]} | ||||
|   | ||||
| @@ -35,7 +35,7 @@ export const actionSelectAll = register({ | ||||
|             // single linear element selected | ||||
|             Object.keys(selectedElementIds).length === 1 && | ||||
|             isLinearElement(elements[0]) | ||||
|               ? new LinearElementEditor(elements[0], app.scene, appState) | ||||
|               ? new LinearElementEditor(elements[0], app.scene) | ||||
|               : null, | ||||
|           editingGroupId: null, | ||||
|           selectedElementIds, | ||||
|   | ||||
| @@ -13,7 +13,11 @@ import { | ||||
|   DEFAULT_TEXT_ALIGN, | ||||
| } from "../constants"; | ||||
| import { getBoundTextElement } from "../element/textElement"; | ||||
| import { hasBoundTextElement } from "../element/typeChecks"; | ||||
| import { | ||||
|   hasBoundTextElement, | ||||
|   canApplyRoundnessTypeToElement, | ||||
|   getDefaultRoundnessTypeForElement, | ||||
| } from "../element/typeChecks"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
|  | ||||
| // `copiedStyles` is exported only for tests. | ||||
| @@ -77,6 +81,14 @@ export const actionPasteStyles = register({ | ||||
|             fillStyle: elementStylesToCopyFrom?.fillStyle, | ||||
|             opacity: elementStylesToCopyFrom?.opacity, | ||||
|             roughness: elementStylesToCopyFrom?.roughness, | ||||
|             roundness: elementStylesToCopyFrom.roundness | ||||
|               ? canApplyRoundnessTypeToElement( | ||||
|                   elementStylesToCopyFrom.roundness.type, | ||||
|                   element, | ||||
|                 ) | ||||
|                 ? elementStylesToCopyFrom.roundness | ||||
|                 : getDefaultRoundnessTypeForElement(element) | ||||
|               : null, | ||||
|           }); | ||||
|  | ||||
|           if (isTextElement(newElement)) { | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { AppState } from "../types"; | ||||
|  | ||||
| export const actionToggleGridMode = register({ | ||||
|   name: "gridMode", | ||||
|   viewMode: true, | ||||
|   trackEvent: { | ||||
|     category: "canvas", | ||||
|     predicate: (appState) => !appState.gridSize, | ||||
| @@ -19,6 +20,9 @@ export const actionToggleGridMode = register({ | ||||
|     }; | ||||
|   }, | ||||
|   checked: (appState: AppState) => appState.gridSize !== null, | ||||
|   predicate: (element, appState, props) => { | ||||
|     return typeof props.gridModeEnabled === "undefined"; | ||||
|   }, | ||||
|   contextItemLabel: "labels.showGrid", | ||||
|   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, | ||||
| }); | ||||
|   | ||||
| @@ -41,15 +41,9 @@ export const actionToggleLock = register({ | ||||
|         : "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 ( | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys"; | ||||
|  | ||||
| export const actionToggleStats = register({ | ||||
|   name: "stats", | ||||
|   viewMode: true, | ||||
|   trackEvent: { category: "menu" }, | ||||
|   perform(elements, appState) { | ||||
|     return { | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { register } from "./register"; | ||||
|  | ||||
| export const actionToggleViewMode = register({ | ||||
|   name: "viewMode", | ||||
|   viewMode: true, | ||||
|   trackEvent: { | ||||
|     category: "canvas", | ||||
|     predicate: (appState) => !appState.viewModeEnabled, | ||||
| @@ -17,6 +18,9 @@ export const actionToggleViewMode = register({ | ||||
|     }; | ||||
|   }, | ||||
|   checked: (appState) => appState.viewModeEnabled, | ||||
|   predicate: (elements, appState, appProps) => { | ||||
|     return typeof appProps.viewModeEnabled === "undefined"; | ||||
|   }, | ||||
|   contextItemLabel: "labels.viewMode", | ||||
|   keyTest: (event) => | ||||
|     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { register } from "./register"; | ||||
|  | ||||
| export const actionToggleZenMode = register({ | ||||
|   name: "zenMode", | ||||
|   viewMode: true, | ||||
|   trackEvent: { | ||||
|     category: "canvas", | ||||
|     predicate: (appState) => !appState.zenModeEnabled, | ||||
| @@ -17,6 +18,9 @@ export const actionToggleZenMode = register({ | ||||
|     }; | ||||
|   }, | ||||
|   checked: (appState) => appState.zenModeEnabled, | ||||
|   predicate: (elements, appState, appProps) => { | ||||
|     return typeof appProps.zenModeEnabled === "undefined"; | ||||
|   }, | ||||
|   contextItemLabel: "buttons.zenMode", | ||||
|   keyTest: (event) => | ||||
|     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, | ||||
|   | ||||
| @@ -10,10 +10,10 @@ import { t } from "../i18n"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
| import { register } from "./register"; | ||||
| import { | ||||
|   SendBackwardIcon, | ||||
|   BringToFrontIcon, | ||||
|   SendToBackIcon, | ||||
|   BringForwardIcon, | ||||
|   BringToFrontIcon, | ||||
|   SendBackwardIcon, | ||||
|   SendToBackIcon, | ||||
| } from "../components/icons"; | ||||
|  | ||||
| export const actionSendBackward = register({ | ||||
| @@ -39,7 +39,7 @@ export const actionSendBackward = register({ | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`} | ||||
|     > | ||||
|       <SendBackwardIcon theme={appState.theme} /> | ||||
|       {SendBackwardIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
| @@ -67,7 +67,7 @@ export const actionBringForward = register({ | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`} | ||||
|     > | ||||
|       <BringForwardIcon theme={appState.theme} /> | ||||
|       {BringForwardIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
| @@ -102,7 +102,7 @@ export const actionSendToBack = register({ | ||||
|           : getShortcutKey("CtrlOrCmd+Shift+[") | ||||
|       }`} | ||||
|     > | ||||
|       <SendToBackIcon theme={appState.theme} /> | ||||
|       {SendToBackIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
| @@ -138,7 +138,7 @@ export const actionBringToFront = register({ | ||||
|           : getShortcutKey("CtrlOrCmd+Shift+]") | ||||
|       }`} | ||||
|     > | ||||
|       <BringToFrontIcon theme={appState.theme} /> | ||||
|       {BringToFrontIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
|   | ||||
| @@ -85,3 +85,4 @@ export { actionToggleStats } from "./actionToggleStats"; | ||||
| export { actionUnbindText, actionBindText } from "./actionBoundText"; | ||||
| export { actionLink } from "../element/Hyperlink"; | ||||
| export { actionToggleLock } from "./actionToggleLock"; | ||||
| export { actionToggleLinearEditor } from "./actionLinearEditor"; | ||||
|   | ||||
| @@ -9,7 +9,6 @@ import { | ||||
| } from "./types"; | ||||
| import { ExcalidrawElement } from "../element/types"; | ||||
| import { AppClassProperties, AppState } from "../types"; | ||||
| import { MODES } from "../constants"; | ||||
| import { trackEvent } from "../analytics"; | ||||
|  | ||||
| const trackAction = ( | ||||
| @@ -103,12 +102,9 @@ export class ActionManager { | ||||
|  | ||||
|     const action = data[0]; | ||||
|  | ||||
|     const { viewModeEnabled } = this.getAppState(); | ||||
|     if (viewModeEnabled) { | ||||
|       if (!Object.values(MODES).includes(data[0].name)) { | ||||
|     if (this.getAppState().viewModeEnabled && action.viewMode !== true) { | ||||
|       return false; | ||||
|     } | ||||
|     } | ||||
|  | ||||
|     const elements = this.getElementsIncludingDeleted(); | ||||
|     const appState = this.getAppState(); | ||||
| @@ -176,4 +172,14 @@ export class ActionManager { | ||||
|  | ||||
|     return null; | ||||
|   }; | ||||
|  | ||||
|   isActionEnabled = (action: Action) => { | ||||
|     const elements = this.getElementsIncludingDeleted(); | ||||
|     const appState = this.getAppState(); | ||||
|  | ||||
|     return ( | ||||
|       !action.predicate || | ||||
|       action.predicate(elements, appState, this.app.props, this.app) | ||||
|     ); | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -3,8 +3,11 @@ import { isDarwin } from "../keys"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
| import { ActionName } from "./types"; | ||||
|  | ||||
| export type ShortcutName = SubtypeOf< | ||||
| export type ShortcutName = | ||||
|   | SubtypeOf< | ||||
|       ActionName, | ||||
|       | "toggleTheme" | ||||
|       | "loadScene" | ||||
|       | "cut" | ||||
|       | "copy" | ||||
|       | "paste" | ||||
| @@ -30,16 +33,22 @@ export type ShortcutName = SubtypeOf< | ||||
|       | "flipVertical" | ||||
|       | "hyperlink" | ||||
|       | "toggleLock" | ||||
| >; | ||||
|     > | ||||
|   | "saveScene" | ||||
|   | "imageExport"; | ||||
|  | ||||
| const shortcutMap: Record<ShortcutName, string[]> = { | ||||
|   toggleTheme: [getShortcutKey("Shift+Alt+D")], | ||||
|   saveScene: [getShortcutKey("CtrlOrCmd+S")], | ||||
|   loadScene: [getShortcutKey("CtrlOrCmd+O")], | ||||
|   imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")], | ||||
|   cut: [getShortcutKey("CtrlOrCmd+X")], | ||||
|   copy: [getShortcutKey("CtrlOrCmd+C")], | ||||
|   paste: [getShortcutKey("CtrlOrCmd+V")], | ||||
|   copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], | ||||
|   pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], | ||||
|   selectAll: [getShortcutKey("CtrlOrCmd+A")], | ||||
|   deleteSelectedElements: [getShortcutKey("Del")], | ||||
|   deleteSelectedElements: [getShortcutKey("Delete")], | ||||
|   duplicateSelection: [ | ||||
|     getShortcutKey("CtrlOrCmd+D"), | ||||
|     getShortcutKey(`Alt+${t("helpDialog.drag")}`), | ||||
|   | ||||
| @@ -91,7 +91,7 @@ export type ActionName = | ||||
|   | "ungroup" | ||||
|   | "goToCollaborator" | ||||
|   | "addToLibrary" | ||||
|   | "changeSharpness" | ||||
|   | "changeRoundness" | ||||
|   | "alignTop" | ||||
|   | "alignBottom" | ||||
|   | "alignLeft" | ||||
| @@ -111,7 +111,8 @@ export type ActionName = | ||||
|   | "hyperlink" | ||||
|   | "eraser" | ||||
|   | "bindText" | ||||
|   | "toggleLock"; | ||||
|   | "toggleLock" | ||||
|   | "toggleLinearEditor"; | ||||
|  | ||||
| export type PanelComponentProps = { | ||||
|   elements: readonly ExcalidrawElement[]; | ||||
| @@ -137,9 +138,11 @@ export interface Action { | ||||
|         elements: readonly ExcalidrawElement[], | ||||
|         appState: Readonly<AppState>, | ||||
|       ) => string); | ||||
|   contextItemPredicate?: ( | ||||
|   predicate?: ( | ||||
|     elements: readonly ExcalidrawElement[], | ||||
|     appState: AppState, | ||||
|     appProps: ExcalidrawProps, | ||||
|     app: AppClassProperties, | ||||
|   ) => boolean; | ||||
|   checked?: (appState: Readonly<AppState>) => boolean; | ||||
|   trackEvent: | ||||
| @@ -161,4 +164,7 @@ export interface Action { | ||||
|           value: any, | ||||
|         ) => boolean; | ||||
|       }; | ||||
|   /** if set to `true`, allow action to be performed in viewMode. | ||||
|    *  Defaults to `false` */ | ||||
|   viewMode?: boolean; | ||||
| } | ||||
|   | ||||
| @@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit< | ||||
|   "offsetTop" | "offsetLeft" | "width" | "height" | ||||
| > => { | ||||
|   return { | ||||
|     showWelcomeScreen: false, | ||||
|     theme: THEME.LIGHT, | ||||
|     collaborators: new Map(), | ||||
|     currentChartType: "bar", | ||||
| @@ -27,12 +28,11 @@ export const getDefaultAppState = (): Omit< | ||||
|     currentItemFillStyle: "hachure", | ||||
|     currentItemFontFamily: DEFAULT_FONT_FAMILY, | ||||
|     currentItemFontSize: DEFAULT_FONT_SIZE, | ||||
|     currentItemLinearStrokeSharpness: "round", | ||||
|     currentItemOpacity: 100, | ||||
|     currentItemRoughness: 1, | ||||
|     currentItemStartArrowhead: null, | ||||
|     currentItemStrokeColor: oc.black, | ||||
|     currentItemStrokeSharpness: "sharp", | ||||
|     currentItemRoundness: "round", | ||||
|     currentItemStrokeStyle: "solid", | ||||
|     currentItemStrokeWidth: 1, | ||||
|     currentItemTextAlign: DEFAULT_TEXT_ALIGN, | ||||
| @@ -57,16 +57,18 @@ export const getDefaultAppState = (): Omit< | ||||
|     fileHandle: null, | ||||
|     gridSize: null, | ||||
|     isBindingEnabled: true, | ||||
|     isLibraryOpen: false, | ||||
|     isLibraryMenuDocked: false, | ||||
|     isSidebarDocked: false, | ||||
|     isLoading: false, | ||||
|     isResizing: false, | ||||
|     isRotating: false, | ||||
|     lastPointerDownWith: "mouse", | ||||
|     multiElement: null, | ||||
|     name: `${t("labels.untitled")}-${getDateTime()}`, | ||||
|     contextMenu: null, | ||||
|     openMenu: null, | ||||
|     openPopup: null, | ||||
|     openSidebar: null, | ||||
|     openDialog: null, | ||||
|     pasteDialog: { shown: false, data: null }, | ||||
|     previousSelectedElementIds: {}, | ||||
|     resizingElement: null, | ||||
| @@ -77,7 +79,6 @@ export const getDefaultAppState = (): Omit< | ||||
|     selectedGroupIds: {}, | ||||
|     selectionElement: null, | ||||
|     shouldCacheIgnoreZoom: false, | ||||
|     showHelpDialog: false, | ||||
|     showStats: false, | ||||
|     startBoundElement: null, | ||||
|     suggestedBindings: [], | ||||
| @@ -110,6 +111,7 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   T extends Record<keyof AppState, Values>, | ||||
| >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => | ||||
|   config)({ | ||||
|   showWelcomeScreen: { browser: true, export: false, server: false }, | ||||
|   theme: { browser: true, export: false, server: false }, | ||||
|   collaborators: { browser: false, export: false, server: false }, | ||||
|   currentChartType: { browser: true, export: false, server: false }, | ||||
| @@ -118,7 +120,7 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   currentItemFillStyle: { browser: true, export: false, server: false }, | ||||
|   currentItemFontFamily: { browser: true, export: false, server: false }, | ||||
|   currentItemFontSize: { browser: true, export: false, server: false }, | ||||
|   currentItemLinearStrokeSharpness: { | ||||
|   currentItemRoundness: { | ||||
|     browser: true, | ||||
|     export: false, | ||||
|     server: false, | ||||
| @@ -127,7 +129,6 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   currentItemRoughness: { browser: true, export: false, server: false }, | ||||
|   currentItemStartArrowhead: { browser: true, export: false, server: false }, | ||||
|   currentItemStrokeColor: { browser: true, export: false, server: false }, | ||||
|   currentItemStrokeSharpness: { browser: true, export: false, server: false }, | ||||
|   currentItemStrokeStyle: { browser: true, export: false, server: false }, | ||||
|   currentItemStrokeWidth: { browser: true, export: false, server: false }, | ||||
|   currentItemTextAlign: { browser: true, export: false, server: false }, | ||||
| @@ -148,8 +149,7 @@ 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: true, export: false, server: false }, | ||||
|   isLibraryMenuDocked: { browser: true, export: false, server: false }, | ||||
|   isSidebarDocked: { 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 }, | ||||
| @@ -158,8 +158,11 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   name: { browser: true, export: false, server: false }, | ||||
|   offsetLeft: { browser: false, export: false, server: false }, | ||||
|   offsetTop: { browser: false, export: false, server: false }, | ||||
|   contextMenu: { browser: false, export: false, server: false }, | ||||
|   openMenu: { browser: true, export: false, server: false }, | ||||
|   openPopup: { browser: false, export: false, server: false }, | ||||
|   openSidebar: { browser: true, export: false, server: false }, | ||||
|   openDialog: { browser: false, export: false, server: false }, | ||||
|   pasteDialog: { browser: false, export: false, server: false }, | ||||
|   previousSelectedElementIds: { browser: true, export: false, server: false }, | ||||
|   resizingElement: { browser: false, export: false, server: false }, | ||||
| @@ -170,7 +173,6 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   selectedGroupIds: { browser: true, export: false, server: false }, | ||||
|   selectionElement: { browser: false, export: false, server: false }, | ||||
|   shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, | ||||
|   showHelpDialog: { browser: false, export: false, server: false }, | ||||
|   showStats: { browser: true, export: false, server: false }, | ||||
|   startBoundElement: { browser: false, export: false, server: false }, | ||||
|   suggestedBindings: { browser: false, export: false, server: false }, | ||||
|   | ||||
| @@ -172,7 +172,7 @@ const commonProps = { | ||||
|   opacity: 100, | ||||
|   roughness: 1, | ||||
|   strokeColor: colors.elementStroke[0], | ||||
|   strokeSharpness: "sharp", | ||||
|   roundness: null, | ||||
|   strokeStyle: "solid", | ||||
|   strokeWidth: 1, | ||||
|   verticalAlign: VERTICAL_ALIGN.MIDDLE, | ||||
| @@ -322,7 +322,7 @@ const chartBaseElements = ( | ||||
|         text: spreadsheet.title, | ||||
|         x: x + chartWidth / 2, | ||||
|         y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, | ||||
|         strokeSharpness: "sharp", | ||||
|         roundness: null, | ||||
|         strokeStyle: "solid", | ||||
|         textAlign: "center", | ||||
|       }) | ||||
|   | ||||
| @@ -11,27 +11,18 @@ export const getClientColors = (clientId: string, appState: AppState) => { | ||||
|   // Naive way of getting an integer out of the clientId | ||||
|   const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); | ||||
|  | ||||
|   // Skip transparent background. | ||||
|   const backgrounds = colors.elementBackground.slice(1); | ||||
|   const strokes = colors.elementStroke.slice(1); | ||||
|   // Skip transparent & gray colors | ||||
|   const backgrounds = colors.elementBackground.slice(3); | ||||
|   const strokes = colors.elementStroke.slice(3); | ||||
|   return { | ||||
|     background: backgrounds[sum % backgrounds.length], | ||||
|     stroke: strokes[sum % strokes.length], | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const getClientInitials = (username?: string | null) => { | ||||
|   if (!username) { | ||||
| export const getClientInitials = (userName?: string | null) => { | ||||
|   if (!userName) { | ||||
|     return "?"; | ||||
|   } | ||||
|   const names = username.trim().split(" "); | ||||
|  | ||||
|   if (names.length < 2) { | ||||
|     return names[0].substring(0, 2).toUpperCase(); | ||||
|   } | ||||
|  | ||||
|   const firstName = names[0]; | ||||
|   const lastName = names[names.length - 1]; | ||||
|  | ||||
|   return (firstName[0] + lastName[0]).toUpperCase(); | ||||
|   return userName.trim()[0].toUpperCase(); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										27
									
								
								src/clipboard.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/clipboard.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { parseClipboard } from "./clipboard"; | ||||
|  | ||||
| describe("Test parseClipboard", () => { | ||||
|   it("should parse valid json correctly", async () => { | ||||
|     let text = "123"; | ||||
|  | ||||
|     let clipboardData = await parseClipboard({ | ||||
|       //@ts-ignore | ||||
|       clipboardData: { | ||||
|         getData: () => text, | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     expect(clipboardData.text).toBe(text); | ||||
|  | ||||
|     text = "[123]"; | ||||
|  | ||||
|     clipboardData = await parseClipboard({ | ||||
|       //@ts-ignore | ||||
|       clipboardData: { | ||||
|         getData: () => text, | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     expect(clipboardData.text).toBe(text); | ||||
|   }); | ||||
| }); | ||||
| @@ -109,16 +109,16 @@ const parsePotentialSpreadsheet = ( | ||||
|  * Retrieves content from system clipboard (either from ClipboardEvent or | ||||
|  *  via async clipboard API if supported) | ||||
|  */ | ||||
| const getSystemClipboard = async ( | ||||
| export const getSystemClipboard = async ( | ||||
|   event: ClipboardEvent | null, | ||||
| ): Promise<string> => { | ||||
|   try { | ||||
|     const text = event | ||||
|       ? event.clipboardData?.getData("text/plain").trim() | ||||
|       ? event.clipboardData?.getData("text/plain") | ||||
|       : probablySupportsClipboardReadText && | ||||
|         (await navigator.clipboard.readText()); | ||||
|  | ||||
|     return text || ""; | ||||
|     return (text || "").trim(); | ||||
|   } catch { | ||||
|     return ""; | ||||
|   } | ||||
| @@ -129,19 +129,25 @@ const getSystemClipboard = async ( | ||||
|  */ | ||||
| export const parseClipboard = async ( | ||||
|   event: ClipboardEvent | null, | ||||
|   isPlainPaste = false, | ||||
| ): Promise<ClipboardData> => { | ||||
|   const systemClipboard = await getSystemClipboard(event); | ||||
|  | ||||
|   // if system clipboard empty, couldn't be resolved, or contains previously | ||||
|   // copied excalidraw scene as SVG, fall back to previously copied excalidraw | ||||
|   // elements | ||||
|   if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) { | ||||
|   if ( | ||||
|     !systemClipboard || | ||||
|     (!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG)) | ||||
|   ) { | ||||
|     return getAppClipboard(); | ||||
|   } | ||||
|  | ||||
|   // if system clipboard contains spreadsheet, use it even though it's | ||||
|   // technically possible it's staler than in-app clipboard | ||||
|   const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard); | ||||
|   const spreadsheetResult = | ||||
|     !isPlainPaste && parsePotentialSpreadsheet(systemClipboard); | ||||
|  | ||||
|   if (spreadsheetResult) { | ||||
|     return spreadsheetResult; | ||||
|   } | ||||
| @@ -154,17 +160,23 @@ export const parseClipboard = async ( | ||||
|       return { | ||||
|         elements: systemClipboardData.elements, | ||||
|         files: systemClipboardData.files, | ||||
|         text: isPlainPaste | ||||
|           ? JSON.stringify(systemClipboardData.elements, null, 2) | ||||
|           : undefined, | ||||
|       }; | ||||
|     } | ||||
|     return appClipboardData; | ||||
|   } catch { | ||||
|   } catch (e) {} | ||||
|   // system clipboard doesn't contain excalidraw elements → return plaintext | ||||
|   // unless we set a flag to prefer in-app clipboard because browser didn't | ||||
|   // support storing to system clipboard on copy | ||||
|   return PREFER_APP_CLIPBOARD && appClipboardData.elements | ||||
|       ? appClipboardData | ||||
|       : { text: systemClipboard }; | ||||
|     ? { | ||||
|         ...appClipboardData, | ||||
|         text: isPlainPaste | ||||
|           ? JSON.stringify(appClipboardData.elements, null, 2) | ||||
|           : undefined, | ||||
|       } | ||||
|     : { text: systemClipboard }; | ||||
| }; | ||||
|  | ||||
| export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { | ||||
|   | ||||
							
								
								
									
										92
									
								
								src/components/Actions.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/Actions.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| .zoom-actions, | ||||
| .undo-redo-buttons { | ||||
|   background-color: var(--island-bg-color); | ||||
|   border-radius: var(--border-radius-lg); | ||||
| } | ||||
|  | ||||
| .zoom-button, | ||||
| .undo-redo-buttons button { | ||||
|   border: 1px solid var(--default-border-color) !important; | ||||
|   border-radius: 0 !important; | ||||
|   background-color: transparent !important; | ||||
|   font-size: 0.875rem !important; | ||||
|   width: var(--lg-button-size); | ||||
|   height: var(--lg-button-size); | ||||
|   svg { | ||||
|     width: var(--lg-icon-size) !important; | ||||
|     height: var(--lg-icon-size) !important; | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .reset-zoom-button { | ||||
|   border-left: 0 !important; | ||||
|   border-right: 0 !important; | ||||
|   padding: 0 0.625rem !important; | ||||
|   width: 3.75rem !important; | ||||
|   justify-content: center; | ||||
|   color: var(--text-primary-color); | ||||
| } | ||||
|  | ||||
| .zoom-out-button { | ||||
|   border-top-left-radius: var(--border-radius-lg) !important; | ||||
|   border-bottom-left-radius: var(--border-radius-lg) !important; | ||||
|  | ||||
|   :root[dir="rtl"] & { | ||||
|     transform: scaleX(-1); | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     border-top-right-radius: 0 !important; | ||||
|     border-bottom-right-radius: 0 !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .zoom-in-button { | ||||
|   border-top-right-radius: var(--border-radius-lg) !important; | ||||
|   border-bottom-right-radius: var(--border-radius-lg) !important; | ||||
|  | ||||
|   :root[dir="rtl"] & { | ||||
|     transform: scaleX(-1); | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     border-top-left-radius: 0 !important; | ||||
|     border-bottom-left-radius: 0 !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .undo-redo-buttons { | ||||
|   .undo-button-container button { | ||||
|     border-top-left-radius: var(--border-radius-lg) !important; | ||||
|     border-bottom-left-radius: var(--border-radius-lg) !important; | ||||
|     border-right: 0 !important; | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       transform: scaleX(-1); | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__icon { | ||||
|       border-top-right-radius: 0 !important; | ||||
|       border-bottom-right-radius: 0 !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .redo-button-container button { | ||||
|     border-top-right-radius: var(--border-radius-lg) !important; | ||||
|     border-bottom-right-radius: var(--border-radius-lg) !important; | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       transform: scaleX(-1); | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__icon { | ||||
|       border-top-left-radius: 0 !important; | ||||
|       border-bottom-left-radius: 0 !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -5,7 +5,7 @@ import { ExcalidrawElement, PointerType } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "../components/App"; | ||||
| import { | ||||
|   canChangeSharpness, | ||||
|   canChangeRoundness, | ||||
|   canHaveArrowheads, | ||||
|   getTargetElements, | ||||
|   hasBackground, | ||||
| @@ -25,9 +25,12 @@ import Stack from "./Stack"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { hasStrokeColor } from "../scene/comparisons"; | ||||
| import { trackEvent } from "../analytics"; | ||||
| import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; | ||||
| import { hasBoundTextElement } from "../element/typeChecks"; | ||||
| import clsx from "clsx"; | ||||
| import { actionToggleZenMode } from "../actions"; | ||||
| import "./Actions.scss"; | ||||
| import { Tooltip } from "./Tooltip"; | ||||
| import { shouldAllowVerticalAlign } from "../element/textElement"; | ||||
|  | ||||
| export const SelectedShapeActions = ({ | ||||
|   appState, | ||||
| @@ -79,12 +82,16 @@ export const SelectedShapeActions = ({ | ||||
|  | ||||
|   return ( | ||||
|     <div className="panelColumn"> | ||||
|       <div> | ||||
|         {((hasStrokeColor(appState.activeTool.type) && | ||||
|           appState.activeTool.type !== "image" && | ||||
|           commonSelectedType !== "image") || | ||||
|           targetElements.some((element) => hasStrokeColor(element.type))) && | ||||
|           renderAction("changeStrokeColor")} | ||||
|       {showChangeBackgroundIcons && renderAction("changeBackgroundColor")} | ||||
|       </div> | ||||
|       {showChangeBackgroundIcons && ( | ||||
|         <div>{renderAction("changeBackgroundColor")}</div> | ||||
|       )} | ||||
|       {showFillIcons && renderAction("changeFillStyle")} | ||||
|  | ||||
|       {(hasStrokeWidth(appState.activeTool.type) || | ||||
| @@ -103,9 +110,9 @@ export const SelectedShapeActions = ({ | ||||
|         </> | ||||
|       )} | ||||
|  | ||||
|       {(canChangeSharpness(appState.activeTool.type) || | ||||
|         targetElements.some((element) => canChangeSharpness(element.type))) && ( | ||||
|         <>{renderAction("changeSharpness")}</> | ||||
|       {(canChangeRoundness(appState.activeTool.type) || | ||||
|         targetElements.some((element) => canChangeRoundness(element.type))) && ( | ||||
|         <>{renderAction("changeRoundness")}</> | ||||
|       )} | ||||
|  | ||||
|       {(hasText(appState.activeTool.type) || | ||||
| @@ -119,10 +126,8 @@ export const SelectedShapeActions = ({ | ||||
|         </> | ||||
|       )} | ||||
|  | ||||
|       {targetElements.some( | ||||
|         (element) => | ||||
|           hasBoundTextElement(element) || isBoundToContainer(element), | ||||
|       ) && renderAction("changeVerticalAlign")} | ||||
|       {shouldAllowVerticalAlign(targetElements) && | ||||
|         renderAction("changeVerticalAlign")} | ||||
|       {(canHaveArrowheads(appState.activeTool.type) || | ||||
|         targetElements.some((element) => canHaveArrowheads(element.type))) && ( | ||||
|         <>{renderAction("changeArrowhead")}</> | ||||
| @@ -163,7 +168,16 @@ export const SelectedShapeActions = ({ | ||||
|             )} | ||||
|             {targetElements.length > 2 && | ||||
|               renderAction("distributeHorizontally")} | ||||
|             <div className="iconRow"> | ||||
|             {/* breaks the row ˇˇ */} | ||||
|             <div style={{ flexBasis: "100%", height: 0 }} /> | ||||
|             <div | ||||
|               style={{ | ||||
|                 display: "flex", | ||||
|                 flexWrap: "wrap", | ||||
|                 gap: ".5rem", | ||||
|                 marginTop: "-0.5rem", | ||||
|               }} | ||||
|             > | ||||
|               {renderAction("alignTop")} | ||||
|               {renderAction("alignVerticallyCentered")} | ||||
|               {renderAction("alignBottom")} | ||||
| @@ -203,25 +217,25 @@ export const ShapesSwitcher = ({ | ||||
|   appState: AppState; | ||||
| }) => ( | ||||
|   <> | ||||
|     {SHAPES.map(({ value, icon, key }, index) => { | ||||
|     {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { | ||||
|       const label = t(`toolBar.${value}`); | ||||
|       const letter = key && (typeof key === "string" ? key : key[0]); | ||||
|       const shortcut = letter | ||||
|         ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}` | ||||
|         : `${index + 1}`; | ||||
|         ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}` | ||||
|         : `${numericKey}`; | ||||
|       return ( | ||||
|         <ToolButton | ||||
|           className="Shape" | ||||
|           className={clsx("Shape", { fillable })} | ||||
|           key={value} | ||||
|           type="radio" | ||||
|           icon={icon} | ||||
|           checked={activeTool.type === value} | ||||
|           name="editor-current-shape" | ||||
|           title={`${capitalizeString(label)} — ${shortcut}`} | ||||
|           keyBindingLabel={`${index + 1}`} | ||||
|           keyBindingLabel={numericKey} | ||||
|           aria-label={capitalizeString(label)} | ||||
|           aria-keyshortcuts={shortcut} | ||||
|           data-testid={value} | ||||
|           data-testid={`toolbar-${value}`} | ||||
|           onPointerDown={({ pointerType }) => { | ||||
|             if (!appState.penDetected && pointerType === "pen") { | ||||
|               setAppState({ | ||||
| @@ -263,11 +277,11 @@ export const ZoomActions = ({ | ||||
|   renderAction: ActionManager["renderAction"]; | ||||
|   zoom: Zoom; | ||||
| }) => ( | ||||
|   <Stack.Col gap={1}> | ||||
|     <Stack.Row gap={1} align="center"> | ||||
|   <Stack.Col gap={1} className="zoom-actions"> | ||||
|     <Stack.Row align="center"> | ||||
|       {renderAction("zoomOut")} | ||||
|       {renderAction("zoomIn")} | ||||
|       {renderAction("resetZoom")} | ||||
|       {renderAction("zoomIn")} | ||||
|     </Stack.Row> | ||||
|   </Stack.Col> | ||||
| ); | ||||
| @@ -280,8 +294,12 @@ export const UndoRedoActions = ({ | ||||
|   className?: string; | ||||
| }) => ( | ||||
|   <div className={`undo-redo-buttons ${className}`}> | ||||
|     {renderAction("undo", { size: "small" })} | ||||
|     {renderAction("redo", { size: "small" })} | ||||
|     <div className="undo-button-container"> | ||||
|       <Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip> | ||||
|     </div> | ||||
|     <div className="redo-button-container"> | ||||
|       <Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip> | ||||
|     </div> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
|   | ||||
| @@ -1,28 +0,0 @@ | ||||
| import Stack from "../components/Stack"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { save, file } from "../components/icons"; | ||||
| import { t } from "../i18n"; | ||||
|  | ||||
| import "./ActiveFile.scss"; | ||||
|  | ||||
| type ActiveFileProps = { | ||||
|   fileName?: string; | ||||
|   onSave: () => void; | ||||
| }; | ||||
|  | ||||
| export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => ( | ||||
|   <Stack.Row className="ActiveFile" gap={1} align="center"> | ||||
|     <span className="ActiveFile__fileName"> | ||||
|       {file} | ||||
|       <span>{fileName}</span> | ||||
|     </span> | ||||
|     <ToolButton | ||||
|       type="icon" | ||||
|       icon={save} | ||||
|       title={t("buttons.save")} | ||||
|       aria-label={t("buttons.save")} | ||||
|       onClick={onSave} | ||||
|       data-testid="save-button" | ||||
|     /> | ||||
|   </Stack.Row> | ||||
| ); | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -2,21 +2,35 @@ | ||||
|  | ||||
| .excalidraw { | ||||
|   .Avatar { | ||||
|     width: 2.5rem; | ||||
|     height: 2.5rem; | ||||
|     border-radius: 1.25rem; | ||||
|     width: 1.25rem; | ||||
|     height: 1.25rem; | ||||
|     position: relative; | ||||
|     border-radius: 100%; | ||||
|     outline-offset: 2px; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     color: $oc-white; | ||||
|     cursor: pointer; | ||||
|     font-size: 0.8rem; | ||||
|     font-size: 0.625rem; | ||||
|     font-weight: 500; | ||||
|     line-height: 1; | ||||
|  | ||||
|     &-img { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       border-radius: 100%; | ||||
|     } | ||||
|  | ||||
|     &::before { | ||||
|       content: ""; | ||||
|       position: absolute; | ||||
|       top: -3px; | ||||
|       right: -3px; | ||||
|       bottom: -3px; | ||||
|       left: -3px; | ||||
|       border: 1px solid var(--avatar-border-color); | ||||
|       border-radius: 100%; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,13 +11,11 @@ type AvatarProps = { | ||||
|   src?: string; | ||||
| }; | ||||
|  | ||||
| export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => { | ||||
| export const Avatar = ({ color, 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}` }; | ||||
|   const style = loadImg ? undefined : { background: color }; | ||||
|   return ( | ||||
|     <div className="Avatar" style={style} onClick={onClick}> | ||||
|       {loadImg ? ( | ||||
|   | ||||
| @@ -1,20 +0,0 @@ | ||||
| import React from "react"; | ||||
| import { ActionManager } from "../actions/manager"; | ||||
| import { AppState } from "../types"; | ||||
|  | ||||
| export const BackgroundPickerAndDarkModeToggle = ({ | ||||
|   appState, | ||||
|   setAppState, | ||||
|   actionManager, | ||||
|   showThemeBtn, | ||||
| }: { | ||||
|   actionManager: ActionManager; | ||||
|   appState: AppState; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   showThemeBtn: boolean; | ||||
| }) => ( | ||||
|   <div style={{ display: "flex" }}> | ||||
|     {actionManager.renderAction("changeViewBackgroundColor")} | ||||
|     {showThemeBtn && actionManager.renderAction("toggleTheme")} | ||||
|   </div> | ||||
| ); | ||||
							
								
								
									
										7
									
								
								src/components/Button.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/components/Button.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| @import "../css/theme"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .excalidraw-button { | ||||
|     @include outlineButtonStyles; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/components/Button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/components/Button.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import "./Button.scss"; | ||||
|  | ||||
| interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> { | ||||
|   type?: "button" | "submit" | "reset"; | ||||
|   onSelect: () => any; | ||||
|   children: React.ReactNode; | ||||
|   className?: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * A generic button component that follows Excalidraw's design system. | ||||
|  * Style can be customised using `className` or `style` prop. | ||||
|  * Accepts all props that a regular `button` element accepts. | ||||
|  */ | ||||
| export const Button = ({ | ||||
|   type = "button", | ||||
|   onSelect, | ||||
|   children, | ||||
|   className = "", | ||||
|   ...rest | ||||
| }: ButtonProps) => { | ||||
|   return ( | ||||
|     <button | ||||
|       onClick={(event) => { | ||||
|         onSelect(); | ||||
|         rest.onClick?.(event); | ||||
|       }} | ||||
|       type={type} | ||||
|       className={`excalidraw-button ${className}`} | ||||
|       {...rest} | ||||
|     > | ||||
|       {children} | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
| @@ -64,6 +64,8 @@ | ||||
|  | ||||
|       color: #{$oc-blue-7}; | ||||
|  | ||||
|       border: 0; | ||||
|  | ||||
|       &:focus { | ||||
|         box-shadow: 0 0 0 3px #{$oc-blue-7}; | ||||
|       } | ||||
|   | ||||
| @@ -1,43 +0,0 @@ | ||||
| import { useState } from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "./App"; | ||||
| import { trash } from "./icons"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import ConfirmDialog from "./ConfirmDialog"; | ||||
|  | ||||
| const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { | ||||
|   const [showDialog, setShowDialog] = useState(false); | ||||
|   const toggleDialog = () => { | ||||
|     setShowDialog(!showDialog); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         type="button" | ||||
|         icon={trash} | ||||
|         title={t("buttons.clearReset")} | ||||
|         aria-label={t("buttons.clearReset")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|         onClick={toggleDialog} | ||||
|         data-testid="clear-canvas-button" | ||||
|       /> | ||||
|  | ||||
|       {showDialog && ( | ||||
|         <ConfirmDialog | ||||
|           onConfirm={() => { | ||||
|             onConfirm(); | ||||
|             toggleDialog(); | ||||
|           }} | ||||
|           onCancel={toggleDialog} | ||||
|           title={t("clearCanvasDialog.title")} | ||||
|         > | ||||
|           <p className="clear-canvas__content"> {t("alerts.clearReset")}</p> | ||||
|         </ConfirmDialog> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default ClearCanvas; | ||||
| @@ -1,32 +0,0 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .CollabButton.is-collaborating { | ||||
|     background-color: var(--button-special-active-bg-color); | ||||
|  | ||||
|     .ToolIcon__icon svg, | ||||
|     .ToolIcon__label { | ||||
|       color: var(--icon-green-fill-color); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .CollabButton-collaborators { | ||||
|     :root[dir="ltr"] & { | ||||
|       right: -5px; | ||||
|     } | ||||
|     :root[dir="rtl"] & { | ||||
|       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.6em; | ||||
|     font-family: "Cascadia"; | ||||
|   } | ||||
| } | ||||
| @@ -1,39 +0,0 @@ | ||||
| import clsx from "clsx"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "../components/App"; | ||||
| import { users } from "./icons"; | ||||
|  | ||||
| import "./CollabButton.scss"; | ||||
|  | ||||
| const CollabButton = ({ | ||||
|   isCollaborating, | ||||
|   collaboratorCount, | ||||
|   onClick, | ||||
| }: { | ||||
|   isCollaborating: boolean; | ||||
|   collaboratorCount: number; | ||||
|   onClick: () => void; | ||||
| }) => { | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         className={clsx("CollabButton", { | ||||
|           "is-collaborating": isCollaborating, | ||||
|         })} | ||||
|         onClick={onClick} | ||||
|         icon={users} | ||||
|         type="button" | ||||
|         title={t("labels.liveCollaboration")} | ||||
|         aria-label={t("labels.liveCollaboration")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|       > | ||||
|         {isCollaborating && ( | ||||
|           <div className="CollabButton-collaborators">{collaboratorCount}</div> | ||||
|         )} | ||||
|       </ToolButton> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default CollabButton; | ||||
| @@ -21,6 +21,23 @@ | ||||
|     display: grid; | ||||
|     grid-template-columns: auto 1fr; | ||||
|     align-items: center; | ||||
|     column-gap: 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .color-picker-control-container + .popover { | ||||
|     position: static; | ||||
|   } | ||||
|  | ||||
|   .color-picker-popover-container { | ||||
|     margin-top: -0.25rem; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       margin-left: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       margin-left: -3rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-triangle { | ||||
| @@ -30,20 +47,29 @@ | ||||
|     border-width: 0 9px 10px; | ||||
|     border-color: transparent transparent var(--popup-bg-color); | ||||
|     position: absolute; | ||||
|     top: -10px; | ||||
|     top: 10px; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       left: 12px; | ||||
|       transform: rotate(270deg); | ||||
|       left: -14px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       right: 12px; | ||||
|       transform: rotate(90deg); | ||||
|       right: -14px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-triangle-shadow { | ||||
|     border-color: transparent transparent transparentize($oc-black, 0.9); | ||||
|     top: -11px; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       left: -14px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       right: -16px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-content--default { | ||||
| @@ -119,16 +145,21 @@ | ||||
|   } | ||||
|  | ||||
|   .color-picker-hash { | ||||
|     background: var(--input-border-color); | ||||
|     height: 1.875rem; | ||||
|     width: 1.875rem; | ||||
|     height: var(--default-button-size); | ||||
|     flex-shrink: 0; | ||||
|     padding: 0.5rem 0.5rem 0.5rem 0.75rem; | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     border-right: 0; | ||||
|     box-sizing: border-box; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       border-radius: 4px 0 0 4px; | ||||
|       border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       border-radius: 0 4px 4px 0; | ||||
|       border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; | ||||
|       border-right: 1px solid var(--default-border-color); | ||||
|       border-left: 0; | ||||
|     } | ||||
|  | ||||
|     color: var(--input-label-color); | ||||
| @@ -138,81 +169,64 @@ | ||||
|     position: relative; | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash { | ||||
|     box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash::before, | ||||
|   .color-input-container:focus-within .color-picker-hash::after { | ||||
|     content: ""; | ||||
|     width: 1px; | ||||
|     height: 100%; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash::before { | ||||
|     background: var(--input-border-color); | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       right: -1px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       left: -1px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash::after { | ||||
|     background: var(--input-bg-color); | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       right: -2px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       left: -2px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-input-container { | ||||
|     display: flex; | ||||
|  | ||||
|     &:focus-within { | ||||
|       box-shadow: 0 0 0 1px var(--color-primary-darkest); | ||||
|       border-radius: var(--border-radius-lg); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-input { | ||||
|     width: 11ch; /* length of `transparent` */ | ||||
|     box-sizing: border-box; | ||||
|     width: 100%; | ||||
|     margin: 0; | ||||
|     font-size: 1rem; | ||||
|     background-color: var(--input-bg-color); | ||||
|     font-size: 0.875rem; | ||||
|     background-color: transparent; | ||||
|     color: var(--text-primary-color); | ||||
|     border: 0; | ||||
|     outline: none; | ||||
|     height: 1.75em; | ||||
|     box-shadow: var(--input-border-color) 0 0 0 1px inset; | ||||
|     height: var(--default-button-size); | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     border-left: 0; | ||||
|     letter-spacing: 0.4px; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       border-radius: 0 4px 4px 0; | ||||
|       border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       border-radius: 4px 0 0 4px; | ||||
|       border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); | ||||
|       border-left: 1px solid var(--default-border-color); | ||||
|       border-right: 0; | ||||
|     } | ||||
|  | ||||
|     float: left; | ||||
|     padding: 1px; | ||||
|     padding-inline-start: 0.5em; | ||||
|     padding: 0.5rem; | ||||
|     padding-left: 0.25rem; | ||||
|     appearance: none; | ||||
|  | ||||
|     &:focus-visible { | ||||
|       box-shadow: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-label-swatch-container { | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     width: var(--default-button-size); | ||||
|     height: var(--default-button-size); | ||||
|     box-sizing: border-box; | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   .color-picker-label-swatch { | ||||
|     height: 1.875rem; | ||||
|     width: 1.875rem; | ||||
|     margin-inline-end: 0.25rem; | ||||
|     border: 1px solid $oc-gray-3; | ||||
|     position: relative; | ||||
|     @include outlineButtonStyles; | ||||
|     background-color: var(--swatch-color) !important; | ||||
|     overflow: hidden; | ||||
|     background-color: transparent !important; | ||||
|     position: relative; | ||||
|     filter: var(--theme-filter); | ||||
|     border: 0 !important; | ||||
|  | ||||
|     &:after { | ||||
|       content: ""; | ||||
|   | ||||
| @@ -66,10 +66,13 @@ const getColor = (color: string): string | null => { | ||||
|     return color; | ||||
|   } | ||||
|  | ||||
|   return isValidColor(color) | ||||
|     ? color | ||||
|     : isValidColor(`#${color}`) | ||||
|   // testing for `#` first fixes a bug on Electron (more specfically, an | ||||
|   // Obsidian popout window), where a hex color without `#` is (incorrectly) | ||||
|   // considered valid | ||||
|   return isValidColor(`#${color}`) | ||||
|     ? `#${color}` | ||||
|     : isValidColor(color) | ||||
|     ? color | ||||
|     : null; | ||||
| }; | ||||
|  | ||||
| @@ -365,10 +368,12 @@ export const ColorPicker = ({ | ||||
|   appState: AppState; | ||||
| }) => { | ||||
|   const pickerButton = React.useRef<HTMLButtonElement>(null); | ||||
|   const coords = pickerButton.current?.getBoundingClientRect(); | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <div className="color-picker-control-container"> | ||||
|         <div className="color-picker-label-swatch-container"> | ||||
|           <button | ||||
|             className="color-picker-label-swatch" | ||||
|             aria-label={label} | ||||
| @@ -376,6 +381,7 @@ export const ColorPicker = ({ | ||||
|             onClick={() => setActive(!isActive)} | ||||
|             ref={pickerButton} | ||||
|           /> | ||||
|         </div> | ||||
|         <ColorInput | ||||
|           color={color} | ||||
|           label={label} | ||||
| @@ -386,6 +392,15 @@ export const ColorPicker = ({ | ||||
|       </div> | ||||
|       <React.Suspense fallback=""> | ||||
|         {isActive ? ( | ||||
|           <div | ||||
|             className="color-picker-popover-container" | ||||
|             style={{ | ||||
|               position: "fixed", | ||||
|               top: coords?.top, | ||||
|               left: coords?.right, | ||||
|               zIndex: 1, | ||||
|             }} | ||||
|           > | ||||
|             <Popover | ||||
|               onCloseRequest={(event) => | ||||
|                 event.target !== pickerButton.current && setActive(false) | ||||
| @@ -407,6 +422,7 @@ export const ColorPicker = ({ | ||||
|                 elements={elements} | ||||
|               /> | ||||
|             </Popover> | ||||
|           </div> | ||||
|         ) : null} | ||||
|       </React.Suspense> | ||||
|     </div> | ||||
|   | ||||
| @@ -4,34 +4,8 @@ | ||||
|   .confirm-dialog { | ||||
|     &-buttons { | ||||
|       display: flex; | ||||
|       padding: 0.2rem 0; | ||||
|       column-gap: 0.5rem; | ||||
|       justify-content: flex-end; | ||||
|     } | ||||
|     .ToolIcon__icon { | ||||
|       min-width: 2.5rem; | ||||
|       width: auto; | ||||
|       font-size: 1rem; | ||||
|     } | ||||
|  | ||||
|     .ToolIcon_type_button { | ||||
|       margin-left: 0.8rem; | ||||
|       padding: 0 0.5rem; | ||||
|     } | ||||
|  | ||||
|     &__content { | ||||
|       font-size: 1rem; | ||||
|     } | ||||
|  | ||||
|     &--confirm.ToolIcon_type_button { | ||||
|       background-color: $oc-red-6; | ||||
|  | ||||
|       &:hover { | ||||
|         background-color: $oc-red-8; | ||||
|       } | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         color: $oc-white; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,11 @@ | ||||
| import { t } from "../i18n"; | ||||
| import { Dialog, DialogProps } from "./Dialog"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import "./ConfirmDialog.scss"; | ||||
| import DialogActionButton from "./DialogActionButton"; | ||||
| import { useSetAtom } from "jotai"; | ||||
| import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; | ||||
| import { useExcalidrawSetAppState } from "./App"; | ||||
|  | ||||
| interface Props extends Omit<DialogProps, "onCloseRequest"> { | ||||
|   onConfirm: () => void; | ||||
| @@ -20,6 +23,9 @@ const ConfirmDialog = (props: Props) => { | ||||
|     className = "", | ||||
|     ...rest | ||||
|   } = props; | ||||
|   const setAppState = useExcalidrawSetAppState(); | ||||
|   const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); | ||||
|  | ||||
|   return ( | ||||
|     <Dialog | ||||
|       onCloseRequest={onCancel} | ||||
| @@ -29,21 +35,22 @@ const ConfirmDialog = (props: Props) => { | ||||
|     > | ||||
|       {children} | ||||
|       <div className="confirm-dialog-buttons"> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={cancelText} | ||||
|           aria-label={cancelText} | ||||
|         <DialogActionButton | ||||
|           label={cancelText} | ||||
|           onClick={onCancel} | ||||
|           className="confirm-dialog--cancel" | ||||
|           onClick={() => { | ||||
|             setAppState({ openMenu: null }); | ||||
|             setIsLibraryMenuOpen(false); | ||||
|             onCancel(); | ||||
|           }} | ||||
|         /> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={confirmText} | ||||
|           aria-label={confirmText} | ||||
|         <DialogActionButton | ||||
|           label={confirmText} | ||||
|           onClick={onConfirm} | ||||
|           className="confirm-dialog--confirm" | ||||
|           onClick={() => { | ||||
|             setAppState({ openMenu: null }); | ||||
|             setIsLibraryMenuOpen(false); | ||||
|             onConfirm(); | ||||
|           }} | ||||
|           actionType="danger" | ||||
|         /> | ||||
|       </div> | ||||
|     </Dialog> | ||||
|   | ||||
| @@ -19,7 +19,7 @@ | ||||
|     color: var(--popup-text-color); | ||||
|   } | ||||
|  | ||||
|   .context-menu-option { | ||||
|   .context-menu-item { | ||||
|     position: relative; | ||||
|     width: 100%; | ||||
|     min-width: 9.5rem; | ||||
| @@ -43,16 +43,16 @@ | ||||
|     } | ||||
|  | ||||
|     &.dangerous { | ||||
|       .context-menu-option__label { | ||||
|       .context-menu-item__label { | ||||
|         color: $oc-red-7; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .context-menu-option__label { | ||||
|     .context-menu-item__label { | ||||
|       justify-self: start; | ||||
|       margin-inline-end: 20px; | ||||
|     } | ||||
|     .context-menu-option__shortcut { | ||||
|     .context-menu-item__shortcut { | ||||
|       justify-self: end; | ||||
|       opacity: 0.6; | ||||
|       font-family: inherit; | ||||
| @@ -60,37 +60,37 @@ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .context-menu-option:hover { | ||||
|   .context-menu-item:hover { | ||||
|     color: var(--popup-bg-color); | ||||
|     background-color: var(--select-highlight-color); | ||||
|  | ||||
|     &.dangerous { | ||||
|       .context-menu-option__label { | ||||
|       .context-menu-item__label { | ||||
|         color: var(--popup-bg-color); | ||||
|       } | ||||
|       background-color: $oc-red-6; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .context-menu-option:focus { | ||||
|   .context-menu-item:focus { | ||||
|     z-index: 1; | ||||
|   } | ||||
|  | ||||
|   @include isMobile { | ||||
|     .context-menu-option { | ||||
|     .context-menu-item { | ||||
|       display: block; | ||||
|  | ||||
|       .context-menu-option__label { | ||||
|       .context-menu-item__label { | ||||
|         margin-inline-end: 0; | ||||
|       } | ||||
|  | ||||
|       .context-menu-option__shortcut { | ||||
|       .context-menu-item__shortcut { | ||||
|         display: none; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .context-menu-option-separator { | ||||
|   .context-menu-item-separator { | ||||
|     border: none; | ||||
|     border-top: 1px solid $oc-gray-5; | ||||
|   } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import { render, unmountComponentAtNode } from "react-dom"; | ||||
| import clsx from "clsx"; | ||||
| import { Popover } from "./Popover"; | ||||
| import { t } from "../i18n"; | ||||
| @@ -10,33 +9,52 @@ import { | ||||
| } from "../actions/shortcuts"; | ||||
| import { Action } from "../actions/types"; | ||||
| import { ActionManager } from "../actions/manager"; | ||||
| import { AppState } from "../types"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { | ||||
|   useExcalidrawAppState, | ||||
|   useExcalidrawElements, | ||||
|   useExcalidrawSetAppState, | ||||
| } from "./App"; | ||||
| import React from "react"; | ||||
|  | ||||
| export type ContextMenuOption = "separator" | Action; | ||||
| export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; | ||||
|  | ||||
| export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[]; | ||||
|  | ||||
| type ContextMenuProps = { | ||||
|   options: ContextMenuOption[]; | ||||
|   onCloseRequest?(): void; | ||||
|   actionManager: ActionManager; | ||||
|   items: ContextMenuItems; | ||||
|   top: number; | ||||
|   left: number; | ||||
|   actionManager: ActionManager; | ||||
|   appState: Readonly<AppState>; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
| }; | ||||
|  | ||||
| const ContextMenu = ({ | ||||
|   options, | ||||
|   onCloseRequest, | ||||
|   top, | ||||
|   left, | ||||
|   actionManager, | ||||
|   appState, | ||||
| export const CONTEXT_MENU_SEPARATOR = "separator"; | ||||
|  | ||||
| export const ContextMenu = React.memo( | ||||
|   ({ actionManager, items, top, left }: ContextMenuProps) => { | ||||
|     const appState = useExcalidrawAppState(); | ||||
|     const setAppState = useExcalidrawSetAppState(); | ||||
|     const elements = useExcalidrawElements(); | ||||
|  | ||||
|     const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { | ||||
|       if ( | ||||
|         item && | ||||
|         (item === CONTEXT_MENU_SEPARATOR || | ||||
|           !item.predicate || | ||||
|           item.predicate( | ||||
|             elements, | ||||
| }: ContextMenuProps) => { | ||||
|             appState, | ||||
|             actionManager.app.props, | ||||
|             actionManager.app, | ||||
|           )) | ||||
|       ) { | ||||
|         acc.push(item); | ||||
|       } | ||||
|       return acc; | ||||
|     }, []); | ||||
|  | ||||
|     return ( | ||||
|       <Popover | ||||
|       onCloseRequest={onCloseRequest} | ||||
|         onCloseRequest={() => setAppState({ contextMenu: null })} | ||||
|         top={top} | ||||
|         left={left} | ||||
|         fitInViewport={true} | ||||
| @@ -49,33 +67,48 @@ const ContextMenu = ({ | ||||
|           className="context-menu" | ||||
|           onContextMenu={(event) => event.preventDefault()} | ||||
|         > | ||||
|         {options.map((option, idx) => { | ||||
|           if (option === "separator") { | ||||
|             return <hr key={idx} className="context-menu-option-separator" />; | ||||
|           {filteredItems.map((item, idx) => { | ||||
|             if (item === CONTEXT_MENU_SEPARATOR) { | ||||
|               if ( | ||||
|                 !filteredItems[idx - 1] || | ||||
|                 filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR | ||||
|               ) { | ||||
|                 return null; | ||||
|               } | ||||
|               return <hr key={idx} className="context-menu-item-separator" />; | ||||
|             } | ||||
|  | ||||
|           const actionName = option.name; | ||||
|             const actionName = item.name; | ||||
|             let label = ""; | ||||
|           if (option.contextItemLabel) { | ||||
|             if (typeof option.contextItemLabel === "function") { | ||||
|               label = t(option.contextItemLabel(elements, appState)); | ||||
|             if (item.contextItemLabel) { | ||||
|               if (typeof item.contextItemLabel === "function") { | ||||
|                 label = t(item.contextItemLabel(elements, appState)); | ||||
|               } else { | ||||
|               label = t(option.contextItemLabel); | ||||
|                 label = t(item.contextItemLabel); | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             return ( | ||||
|             <li key={idx} data-testid={actionName} onClick={onCloseRequest}> | ||||
|               <button | ||||
|                 className={clsx("context-menu-option", { | ||||
|                   dangerous: actionName === "deleteSelectedElements", | ||||
|                   checkmark: option.checked?.(appState), | ||||
|                 })} | ||||
|                 onClick={() => | ||||
|                   actionManager.executeAction(option, "contextMenu") | ||||
|                 } | ||||
|               <li | ||||
|                 key={idx} | ||||
|                 data-testid={actionName} | ||||
|                 onClick={() => { | ||||
|                   // we need update state before executing the action in case | ||||
|                   // the action uses the appState it's being passed (that still | ||||
|                   // contains a defined contextMenu) to return the next state. | ||||
|                   setAppState({ contextMenu: null }, () => { | ||||
|                     actionManager.executeAction(item, "contextMenu"); | ||||
|                   }); | ||||
|                 }} | ||||
|               > | ||||
|                 <div className="context-menu-option__label">{label}</div> | ||||
|                 <kbd className="context-menu-option__shortcut"> | ||||
|                 <button | ||||
|                   className={clsx("context-menu-item", { | ||||
|                     dangerous: actionName === "deleteSelectedElements", | ||||
|                     checkmark: item.checked?.(appState), | ||||
|                   })} | ||||
|                 > | ||||
|                   <div className="context-menu-item__label">{label}</div> | ||||
|                   <kbd className="context-menu-item__shortcut"> | ||||
|                     {actionName | ||||
|                       ? getShortcutFromShortcutName(actionName as ShortcutName) | ||||
|                       : ""} | ||||
| @@ -87,63 +120,5 @@ const ContextMenu = ({ | ||||
|         </ul> | ||||
|       </Popover> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>(); | ||||
|  | ||||
| const getContextMenuNode = (container: HTMLElement): HTMLDivElement => { | ||||
|   let contextMenuNode = contextMenuNodeByContainer.get(container); | ||||
|   if (contextMenuNode) { | ||||
|     return contextMenuNode; | ||||
|   } | ||||
|   contextMenuNode = document.createElement("div"); | ||||
|   container | ||||
|     .querySelector(".excalidraw-contextMenuContainer")! | ||||
|     .appendChild(contextMenuNode); | ||||
|   contextMenuNodeByContainer.set(container, contextMenuNode); | ||||
|   return contextMenuNode; | ||||
| }; | ||||
|  | ||||
| type ContextMenuParams = { | ||||
|   options: (ContextMenuOption | false | null | undefined)[]; | ||||
|   top: ContextMenuProps["top"]; | ||||
|   left: ContextMenuProps["left"]; | ||||
|   actionManager: ContextMenuProps["actionManager"]; | ||||
|   appState: Readonly<AppState>; | ||||
|   container: HTMLElement; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
| }; | ||||
|  | ||||
| const handleClose = (container: HTMLElement) => { | ||||
|   const contextMenuNode = contextMenuNodeByContainer.get(container); | ||||
|   if (contextMenuNode) { | ||||
|     unmountComponentAtNode(contextMenuNode); | ||||
|     contextMenuNode.remove(); | ||||
|     contextMenuNodeByContainer.delete(container); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   push(params: ContextMenuParams) { | ||||
|     const options = Array.of<ContextMenuOption>(); | ||||
|     params.options.forEach((option) => { | ||||
|       if (option) { | ||||
|         options.push(option); | ||||
|       } | ||||
|     }); | ||||
|     if (options.length) { | ||||
|       render( | ||||
|         <ContextMenu | ||||
|           top={params.top} | ||||
|           left={params.left} | ||||
|           options={options} | ||||
|           onCloseRequest={() => handleClose(params.container)} | ||||
|           actionManager={params.actionManager} | ||||
|           appState={params.appState} | ||||
|           elements={params.elements} | ||||
|         />, | ||||
|         getContextMenuNode(params.container), | ||||
|       ); | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
| ); | ||||
|   | ||||
| @@ -7,68 +7,11 @@ | ||||
|   } | ||||
|  | ||||
|   .Dialog__title { | ||||
|     display: grid; | ||||
|     align-items: center; | ||||
|     margin-top: 0; | ||||
|     grid-template-columns: 1fr calc(var(--space-factor) * 7); | ||||
|     grid-gap: var(--metric); | ||||
|     padding: calc(var(--space-factor) * 2); | ||||
|     text-align: center; | ||||
|     font-variant: small-caps; | ||||
|     font-size: 1.2em; | ||||
|   } | ||||
|  | ||||
|   .Dialog__titleContent { | ||||
|     flex: 1; | ||||
|   } | ||||
|  | ||||
|   .Dialog .Modal__close { | ||||
|     color: var(--icon-fill-color); | ||||
|     margin: 0; | ||||
|   } | ||||
|  | ||||
|   .Dialog__content { | ||||
|     padding: 0 16px 16px; | ||||
|   } | ||||
|  | ||||
|   @include isMobile { | ||||
|     .Dialog { | ||||
|       --metric: calc(var(--space-factor) * 4); | ||||
|       --inset-left: #{"max(var(--metric), var(--sal))"}; | ||||
|       --inset-right: #{"max(var(--metric), var(--sar))"}; | ||||
|     } | ||||
|  | ||||
|     .Dialog__title { | ||||
|       grid-template-columns: calc(var(--space-factor) * 7) 1fr calc( | ||||
|           var(--space-factor) * 7 | ||||
|         ); | ||||
|       position: sticky; | ||||
|       top: 0; | ||||
|       padding: calc(var(--space-factor) * 2); | ||||
|       background: var(--island-bg-color); | ||||
|       font-size: 1.25em; | ||||
|  | ||||
|       box-sizing: border-box; | ||||
|       border-bottom: 1px solid var(--button-gray-2); | ||||
|       z-index: 1; | ||||
|     } | ||||
|  | ||||
|     .Dialog__titleContent { | ||||
|       text-align: center; | ||||
|     } | ||||
|  | ||||
|     .Dialog .Island { | ||||
|       width: 100vw; | ||||
|       height: 100%; | ||||
|       box-sizing: border-box; | ||||
|       overflow-y: auto; | ||||
|       padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"}; | ||||
|       padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"}; | ||||
|       padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"}; | ||||
|     } | ||||
|  | ||||
|     .Dialog .Modal__close { | ||||
|       order: -1; | ||||
|     } | ||||
|     text-align: left; | ||||
|     font-size: 1.25rem; | ||||
|     border-bottom: 1px solid var(--dialog-border-color); | ||||
|     padding: 0 0 0.75rem; | ||||
|     margin-bottom: 1.5rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -2,14 +2,20 @@ import clsx from "clsx"; | ||||
| import React, { useEffect, useState } from "react"; | ||||
| import { useCallbackRefState } from "../hooks/useCallbackRefState"; | ||||
| import { t } from "../i18n"; | ||||
| import { useExcalidrawContainer, useDevice } from "../components/App"; | ||||
| import { | ||||
|   useExcalidrawContainer, | ||||
|   useDevice, | ||||
|   useExcalidrawSetAppState, | ||||
| } from "../components/App"; | ||||
| import { KEYS } from "../keys"; | ||||
| import "./Dialog.scss"; | ||||
| import { back, close } from "./icons"; | ||||
| import { back, CloseIcon } from "./icons"; | ||||
| import { Island } from "./Island"; | ||||
| import { Modal } from "./Modal"; | ||||
| import { AppState } from "../types"; | ||||
| import { queryFocusableElements } from "../utils"; | ||||
| import { useSetAtom } from "jotai"; | ||||
| import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; | ||||
|  | ||||
| export interface DialogProps { | ||||
|   children: React.ReactNode; | ||||
| @@ -65,7 +71,12 @@ export const Dialog = (props: DialogProps) => { | ||||
|     return () => islandNode.removeEventListener("keydown", handleKeyDown); | ||||
|   }, [islandNode, props.autofocus]); | ||||
|  | ||||
|   const setAppState = useExcalidrawSetAppState(); | ||||
|   const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); | ||||
|  | ||||
|   const onClose = () => { | ||||
|     setAppState({ openMenu: null }); | ||||
|     setIsLibraryMenuOpen(false); | ||||
|     (lastActiveElement as HTMLElement).focus(); | ||||
|     props.onCloseRequest(); | ||||
|   }; | ||||
| @@ -88,7 +99,7 @@ export const Dialog = (props: DialogProps) => { | ||||
|             title={t("buttons.close")} | ||||
|             aria-label={t("buttons.close")} | ||||
|           > | ||||
|             {useDevice().isMobile ? back : close} | ||||
|             {useDevice().isMobile ? back : CloseIcon} | ||||
|           </button> | ||||
|         </h2> | ||||
|         <div className="Dialog__content">{props.children}</div> | ||||
|   | ||||
							
								
								
									
										47
									
								
								src/components/DialogActionButton.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/components/DialogActionButton.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| .excalidraw { | ||||
|   .Dialog__action-button { | ||||
|     position: relative; | ||||
|     display: flex; | ||||
|     column-gap: 0.5rem; | ||||
|     align-items: center; | ||||
|     padding: 0.5rem 1.5rem; | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     background-color: transparent; | ||||
|     height: 3rem; | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     letter-spacing: 0.4px; | ||||
|     color: inherit; | ||||
|     font-family: inherit; | ||||
|     font-size: 0.875rem; | ||||
|     font-weight: 600; | ||||
|     user-select: none; | ||||
|  | ||||
|     svg { | ||||
|       display: block; | ||||
|       width: 1rem; | ||||
|       height: 1rem; | ||||
|     } | ||||
|  | ||||
|     &--danger { | ||||
|       background-color: var(--color-danger); | ||||
|       border-color: var(--color-danger); | ||||
|       color: #fff; | ||||
|     } | ||||
|  | ||||
|     &--primary { | ||||
|       background-color: var(--color-primary); | ||||
|       border-color: var(--color-primary); | ||||
|       color: #fff; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .Dialog__action-button--danger { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|  | ||||
|     .Dialog__action-button--primary { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/components/DialogActionButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/DialogActionButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import clsx from "clsx"; | ||||
| import { ReactNode } from "react"; | ||||
| import "./DialogActionButton.scss"; | ||||
| import Spinner from "./Spinner"; | ||||
|  | ||||
| interface DialogActionButtonProps { | ||||
|   label: string; | ||||
|   children?: ReactNode; | ||||
|   actionType?: "primary" | "danger"; | ||||
|   isLoading?: boolean; | ||||
| } | ||||
|  | ||||
| const DialogActionButton = ({ | ||||
|   label, | ||||
|   onClick, | ||||
|   className, | ||||
|   children, | ||||
|   actionType, | ||||
|   type = "button", | ||||
|   isLoading, | ||||
|   ...rest | ||||
| }: DialogActionButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||
|   const cs = actionType ? `Dialog__action-button--${actionType}` : ""; | ||||
|  | ||||
|   return ( | ||||
|     <button | ||||
|       className={clsx("Dialog__action-button", cs, className)} | ||||
|       type={type} | ||||
|       aria-label={label} | ||||
|       onClick={onClick} | ||||
|       {...rest} | ||||
|     > | ||||
|       {children && ( | ||||
|         <div style={isLoading ? { visibility: "hidden" } : {}}>{children}</div> | ||||
|       )} | ||||
|       <div style={isLoading ? { visibility: "hidden" } : {}}>{label}</div> | ||||
|       {isLoading && ( | ||||
|         <div style={{ position: "absolute", inset: 0 }}> | ||||
|           <Spinner /> | ||||
|         </div> | ||||
|       )} | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DialogActionButton; | ||||
| @@ -91,6 +91,8 @@ | ||||
|   } | ||||
|  | ||||
|   button.ExportDialog-imageExportButton { | ||||
|     border: 0; | ||||
|  | ||||
|     width: 5rem; | ||||
|     height: 5rem; | ||||
|     margin: 0 0.2em; | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .FixedSideContainer { | ||||
|     position: absolute; | ||||
| @@ -9,9 +11,10 @@ | ||||
|   } | ||||
|  | ||||
|   .FixedSideContainer_side_top { | ||||
|     left: var(--space-factor); | ||||
|     top: var(--space-factor); | ||||
|     right: var(--space-factor); | ||||
|     left: var(--editor-container-padding); | ||||
|     top: var(--editor-container-padding); | ||||
|     right: var(--editor-container-padding); | ||||
|     bottom: var(--editor-container-padding); | ||||
|     z-index: 2; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| import { questionCircle } from "../components/icons"; | ||||
| import { HelpIcon } from "./icons"; | ||||
| 
 | ||||
| type HelpIconProps = { | ||||
| type HelpButtonProps = { | ||||
|   title?: string; | ||||
|   name?: string; | ||||
|   id?: string; | ||||
|   onClick?(): void; | ||||
| }; | ||||
| 
 | ||||
| export const HelpIcon = (props: HelpIconProps) => ( | ||||
| export const HelpButton = (props: HelpButtonProps) => ( | ||||
|   <button | ||||
|     className="help-icon" | ||||
|     onClick={props.onClick} | ||||
| @@ -15,6 +15,6 @@ export const HelpIcon = (props: HelpIconProps) => ( | ||||
|     title={`${props.title} — ?`} | ||||
|     aria-label={props.title} | ||||
|   > | ||||
|     {questionCircle} | ||||
|     {HelpIcon} | ||||
|   </button> | ||||
| ); | ||||
| @@ -1,56 +1,115 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .HelpDialog h3 { | ||||
|     border-bottom: 1px solid var(--button-gray-2); | ||||
|     padding-bottom: 4px; | ||||
|   .HelpDialog { | ||||
|     .Modal__content { | ||||
|       max-width: 960px; | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--island { | ||||
|     border: 1px solid var(--button-gray-2); | ||||
|     margin-bottom: 16px; | ||||
|     h3 { | ||||
|       margin: 1.5rem 0; | ||||
|       font-weight: bold; | ||||
|       font-size: 1.125rem; | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--island-title { | ||||
|     margin: 0; | ||||
|     padding: 4px; | ||||
|     background-color: var(--button-gray-1); | ||||
|     text-align: center; | ||||
|   } | ||||
|  | ||||
|   .HelpDialog--shortcut { | ||||
|     border-top: 1px solid var(--button-gray-2); | ||||
|   } | ||||
|  | ||||
|   .HelpDialog--key { | ||||
|     word-break: keep-all; | ||||
|     border: 1px solid var(--button-gray-2); | ||||
|     padding: 2px 8px; | ||||
|     margin: auto 4px; | ||||
|     background-color: var(--button-gray-1); | ||||
|     border-radius: 2px; | ||||
|     font-size: 0.8em; | ||||
|     min-height: 26px; | ||||
|     box-sizing: border-box; | ||||
|     &__header { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       gap: 0.75rem; | ||||
|     } | ||||
|  | ||||
|     &__btn { | ||||
|       display: flex; | ||||
|       column-gap: 0.5rem; | ||||
|       align-items: center; | ||||
|     font-family: inherit; | ||||
|   } | ||||
|       border: 1px solid var(--default-border-color); | ||||
|       padding: 0.625rem 1rem; | ||||
|       border-radius: var(--border-radius-lg); | ||||
|       color: var(--text-primary-color); | ||||
|       font-weight: 600; | ||||
|       font-size: 0.75rem; | ||||
|       letter-spacing: 0.4px; | ||||
|  | ||||
|   .HelpDialog--header { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-evenly; | ||||
|     margin-bottom: 32px; | ||||
|     padding-bottom: 16px; | ||||
|   } | ||||
|  | ||||
|   .HelpDialog--btn { | ||||
|     border: 1px solid var(--link-color); | ||||
|     padding: 8px 32px; | ||||
|     border-radius: 4px; | ||||
|   } | ||||
|   .HelpDialog--btn:hover { | ||||
|       &:hover { | ||||
|         text-decoration: none; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__link-icon { | ||||
|       line-height: 0; | ||||
|       svg { | ||||
|         width: 1rem; | ||||
|         height: 1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__islands-container { | ||||
|       display: grid; | ||||
|       @media screen and (min-width: 1024px) { | ||||
|         grid-template-columns: 1fr 1fr; | ||||
|       } | ||||
|       grid-column-gap: 1.5rem; | ||||
|       grid-row-gap: 2rem; | ||||
|     } | ||||
|  | ||||
|     @media screen and (min-width: 1024px) { | ||||
|       &__island--tools { | ||||
|         grid-area: 1 / 1 / 2 / 2; | ||||
|       } | ||||
|       &__island--view { | ||||
|         grid-area: 2 / 1 / 3 / 2; | ||||
|       } | ||||
|       &__island--editor { | ||||
|         grid-area: 1 / 2 / 3 / 3; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__island { | ||||
|       h4 { | ||||
|         font-size: 1rem; | ||||
|         font-weight: bold; | ||||
|         margin: 0; | ||||
|         margin-bottom: 0.625rem; | ||||
|       } | ||||
|  | ||||
|       &-content { | ||||
|         border: 1px solid var(--dialog-border-color); | ||||
|         border-radius: var(--border-radius-lg); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__shortcut { | ||||
|       border-bottom: 1px solid var(--dialog-border-color); | ||||
|       padding: 0.375rem 0.75rem; | ||||
|       display: flex; | ||||
|       justify-content: space-between; | ||||
|       align-items: center; | ||||
|       font-size: 0.875rem; | ||||
|       column-gap: 0.5rem; | ||||
|  | ||||
|       &:last-child { | ||||
|         border-bottom: none; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__key-container { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       column-gap: 0.25rem; | ||||
|       flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|     &__key { | ||||
|       display: flex; | ||||
|       box-sizing: border-box; | ||||
|       font-size: 0.625rem; | ||||
|       background-color: var(--color-primary-light); | ||||
|       border-radius: var(--border-radius-md); | ||||
|       padding: 0.5rem; | ||||
|       word-break: keep-all; | ||||
|       align-items: center; | ||||
|       font-family: inherit; | ||||
|       line-height: 1; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,35 +1,39 @@ | ||||
| import React from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { isDarwin, isWindows } from "../keys"; | ||||
| import { isDarwin, isWindows, KEYS } from "../keys"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
| import "./HelpDialog.scss"; | ||||
| import { ExternalLinkIcon } from "./icons"; | ||||
|  | ||||
| const Header = () => ( | ||||
|   <div className="HelpDialog--header"> | ||||
|   <div className="HelpDialog__header"> | ||||
|     <a | ||||
|       className="HelpDialog--btn" | ||||
|       className="HelpDialog__btn" | ||||
|       href="https://github.com/excalidraw/excalidraw#documentation" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       {t("helpDialog.documentation")} | ||||
|       <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> | ||||
|     </a> | ||||
|     <a | ||||
|       className="HelpDialog--btn" | ||||
|       className="HelpDialog__btn" | ||||
|       href="https://blog.excalidraw.com" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       {t("helpDialog.blog")} | ||||
|       <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> | ||||
|     </a> | ||||
|     <a | ||||
|       className="HelpDialog--btn" | ||||
|       className="HelpDialog__btn" | ||||
|       href="https://github.com/excalidraw/excalidraw/issues" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       {t("helpDialog.github")} | ||||
|       <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> | ||||
|     </a> | ||||
|   </div> | ||||
| ); | ||||
| @@ -37,88 +41,61 @@ const Header = () => ( | ||||
| const Section = (props: { title: string; children: React.ReactNode }) => ( | ||||
|   <> | ||||
|     <h3>{props.title}</h3> | ||||
|     {props.children} | ||||
|     <div className="HelpDialog__islands-container">{props.children}</div> | ||||
|   </> | ||||
| ); | ||||
|  | ||||
| const Columns = (props: { children: React.ReactNode }) => ( | ||||
|   <div | ||||
|     style={{ | ||||
|       display: "flex", | ||||
|       flexDirection: "row", | ||||
|       flexWrap: "wrap", | ||||
|       justifyContent: "space-between", | ||||
|     }} | ||||
|   > | ||||
|     {props.children} | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const Column = (props: { children: React.ReactNode }) => ( | ||||
|   <div style={{ width: "49%" }}>{props.children}</div> | ||||
| ); | ||||
|  | ||||
| const ShortcutIsland = (props: { | ||||
|   caption: string; | ||||
|   children: React.ReactNode; | ||||
|   className?: string; | ||||
| }) => ( | ||||
|   <div className="HelpDialog--island"> | ||||
|     <h3 className="HelpDialog--island-title">{props.caption}</h3> | ||||
|     {props.children} | ||||
|   <div className={`HelpDialog__island ${props.className}`}> | ||||
|     <h4 className="HelpDialog__island-title">{props.caption}</h4> | ||||
|     <div className="HelpDialog__island-content">{props.children}</div> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const Shortcut = (props: { | ||||
| function* intersperse(as: JSX.Element[][], delim: string | null) { | ||||
|   let first = true; | ||||
|   for (const x of as) { | ||||
|     if (!first) { | ||||
|       yield delim; | ||||
|     } | ||||
|     first = false; | ||||
|     yield x; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const Shortcut = ({ | ||||
|   label, | ||||
|   shortcuts, | ||||
|   isOr = true, | ||||
| }: { | ||||
|   label: string; | ||||
|   shortcuts: string[]; | ||||
|   isOr: boolean; | ||||
|   isOr?: boolean; | ||||
| }) => { | ||||
|   const splitShortcutKeys = shortcuts.map((shortcut) => { | ||||
|     const keys = shortcut.endsWith("++") | ||||
|       ? [...shortcut.slice(0, -2).split("+"), "+"] | ||||
|       : shortcut.split("+"); | ||||
|  | ||||
|     return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>); | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <div className="HelpDialog--shortcut"> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           margin: "0", | ||||
|           padding: "4px 8px", | ||||
|           alignItems: "center", | ||||
|         }} | ||||
|       > | ||||
|         <div | ||||
|           style={{ | ||||
|             lineHeight: 1.4, | ||||
|           }} | ||||
|         > | ||||
|           {props.label} | ||||
|         </div> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             flex: "0 0 auto", | ||||
|             justifyContent: "flex-end", | ||||
|             marginInlineStart: "auto", | ||||
|             minWidth: "30%", | ||||
|           }} | ||||
|         > | ||||
|           {props.shortcuts.map((shortcut, index) => ( | ||||
|             <React.Fragment key={index}> | ||||
|               <ShortcutKey>{shortcut}</ShortcutKey> | ||||
|               {props.isOr && | ||||
|                 index !== props.shortcuts.length - 1 && | ||||
|                 t("helpDialog.or")} | ||||
|             </React.Fragment> | ||||
|           ))} | ||||
|         </div> | ||||
|     <div className="HelpDialog__shortcut"> | ||||
|       <div>{label}</div> | ||||
|       <div className="HelpDialog__key-container"> | ||||
|         {[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : null)]} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| Shortcut.defaultProps = { | ||||
|   isOr: true, | ||||
| }; | ||||
|  | ||||
| const ShortcutKey = (props: { children: React.ReactNode }) => ( | ||||
|   <kbd className="HelpDialog--key" {...props} /> | ||||
|   <kbd className="HelpDialog__key" {...props} /> | ||||
| ); | ||||
|  | ||||
| export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
| @@ -137,37 +114,52 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|       > | ||||
|         <Header /> | ||||
|         <Section title={t("helpDialog.shortcuts")}> | ||||
|           <Columns> | ||||
|             <Column> | ||||
|               <ShortcutIsland caption={t("helpDialog.tools")}> | ||||
|           <ShortcutIsland | ||||
|             className="HelpDialog__island--tools" | ||||
|             caption={t("helpDialog.tools")} | ||||
|           > | ||||
|             <Shortcut | ||||
|               label={t("toolBar.selection")} | ||||
|                   shortcuts={["V", "1"]} | ||||
|               shortcuts={[KEYS.V, KEYS["1"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.rectangle")} | ||||
|                   shortcuts={["R", "2"]} | ||||
|               shortcuts={[KEYS.R, KEYS["2"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.diamond")} | ||||
|               shortcuts={[KEYS.D, KEYS["3"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.ellipse")} | ||||
|               shortcuts={[KEYS.O, KEYS["4"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.arrow")} | ||||
|               shortcuts={[KEYS.A, KEYS["5"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.line")} | ||||
|               shortcuts={[KEYS.L, KEYS["6"]]} | ||||
|             /> | ||||
|                 <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> | ||||
|                 <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 | ||||
|               label={t("toolBar.freedraw")} | ||||
|                   shortcuts={["Shift + P", "X", "7"]} | ||||
|               shortcuts={[KEYS.P, KEYS["7"]]} | ||||
|             /> | ||||
|                 <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> | ||||
|                 <Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> | ||||
|                 <Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.text")} | ||||
|               shortcuts={[KEYS.T, KEYS["8"]]} | ||||
|             /> | ||||
|             <Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.eraser")} | ||||
|                   shortcuts={[getShortcutKey("E")]} | ||||
|               shortcuts={[KEYS.E, KEYS["0"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.editSelectedShape")} | ||||
|               shortcuts={[ | ||||
|                     getShortcutKey("Enter"), | ||||
|                     t("helpDialog.doubleClick"), | ||||
|                 getShortcutKey("CtrlOrCmd+Enter"), | ||||
|                 getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`), | ||||
|               ]} | ||||
|             /> | ||||
|             <Shortcut | ||||
| @@ -204,7 +196,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|               ]} | ||||
|               isOr={false} | ||||
|             /> | ||||
|                 <Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} /> | ||||
|             <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.preventBinding")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd")]} | ||||
| @@ -214,7 +206,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+K")]} | ||||
|             /> | ||||
|           </ShortcutIsland> | ||||
|               <ShortcutIsland caption={t("helpDialog.view")}> | ||||
|           <ShortcutIsland | ||||
|             className="HelpDialog__island--view" | ||||
|             caption={t("helpDialog.view")} | ||||
|           > | ||||
|             <Shortcut | ||||
|               label={t("buttons.zoomIn")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd++")]} | ||||
| @@ -235,6 +230,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|               label={t("helpDialog.zoomToSelection")} | ||||
|               shortcuts={["Shift+2"]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.movePageUpDown")} | ||||
|               shortcuts={["PgUp/PgDn"]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.movePageLeftRight")} | ||||
|               shortcuts={["Shift+PgUp/PgDn"]} | ||||
|             /> | ||||
|             <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> | ||||
|             <Shortcut | ||||
|               label={t("buttons.zenMode")} | ||||
| @@ -257,9 +260,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|               shortcuts={[getShortcutKey("Alt+/")]} | ||||
|             /> | ||||
|           </ShortcutIsland> | ||||
|             </Column> | ||||
|             <Column> | ||||
|               <ShortcutIsland caption={t("helpDialog.editor")}> | ||||
|           <ShortcutIsland | ||||
|             className="HelpDialog__island--editor" | ||||
|             caption={t("helpDialog.editor")} | ||||
|           > | ||||
|             <Shortcut | ||||
|               label={t("labels.selectAll")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+A")]} | ||||
| @@ -270,15 +274,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.deepSelect")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`), | ||||
|                   ]} | ||||
|               shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.deepBoxSelect")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`), | ||||
|                   ]} | ||||
|               shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.moveCanvas")} | ||||
| @@ -300,6 +300,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|               label={t("labels.paste")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+V")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.pasteAsPlaintext")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.copyAsPng")} | ||||
|               shortcuts={[getShortcutKey("Shift+Alt+C")]} | ||||
| @@ -314,7 +318,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.delete")} | ||||
|                   shortcuts={[getShortcutKey("Del")]} | ||||
|               shortcuts={[getShortcutKey("Delete")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.sendToBack")} | ||||
| @@ -415,8 +419,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]} | ||||
|             /> | ||||
|           </ShortcutIsland> | ||||
|             </Column> | ||||
|           </Columns> | ||||
|         </Section> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   | ||||
| @@ -14,20 +14,24 @@ $wide-viewport-width: 1000px; | ||||
|     top: 100%; | ||||
|     max-width: 100%; | ||||
|     width: 100%; | ||||
|     margin-top: 6px; | ||||
|     margin-top: 0.5rem; | ||||
|     text-align: center; | ||||
|     color: $oc-gray-6; | ||||
|     font-size: 0.8rem; | ||||
|     color: var(--color-gray-40); | ||||
|     font-size: 0.75rem; | ||||
|  | ||||
|     @include isMobile { | ||||
|       position: static; | ||||
|       padding-right: 2em; | ||||
|       padding-right: 2rem; | ||||
|     } | ||||
|  | ||||
|     > span { | ||||
|       padding: 0.2rem 0.4rem; | ||||
|       background-color: var(--overlay-bg-color); | ||||
|       border-radius: 4px; | ||||
|       padding: 0.25rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .HintViewer { | ||||
|       color: var(--color-gray-60); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
|  | ||||
| import "./HintViewer.scss"; | ||||
| import { AppState } from "../types"; | ||||
| import { AppState, Device } from "../types"; | ||||
| import { | ||||
|   isImageElement, | ||||
|   isLinearElement, | ||||
| @@ -17,13 +17,19 @@ interface HintViewerProps { | ||||
|   appState: AppState; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
|   isMobile: boolean; | ||||
|   device: Device; | ||||
| } | ||||
|  | ||||
| const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { | ||||
| const getHints = ({ | ||||
|   appState, | ||||
|   elements, | ||||
|   isMobile, | ||||
|   device, | ||||
| }: HintViewerProps) => { | ||||
|   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; | ||||
|   const multiMode = appState.multiElement !== null; | ||||
|  | ||||
|   if (appState.isLibraryOpen) { | ||||
|   if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
| @@ -111,11 +117,13 @@ export const HintViewer = ({ | ||||
|   appState, | ||||
|   elements, | ||||
|   isMobile, | ||||
|   device, | ||||
| }: HintViewerProps) => { | ||||
|   let hint = getHints({ | ||||
|     appState, | ||||
|     elements, | ||||
|     isMobile, | ||||
|     device, | ||||
|   }); | ||||
|   if (!hint) { | ||||
|     return null; | ||||
|   | ||||
| @@ -10,7 +10,8 @@ | ||||
|   .picker { | ||||
|     background: var(--popup-bg-color); | ||||
|     border: 0 solid transparentize($oc-white, 0.75); | ||||
|     box-shadow: transparentize($oc-black, 0.75) 0 1px 4px; | ||||
|     // ˇˇ yeah, i dunno, open to suggestions here :D | ||||
|     box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px; | ||||
|     border-radius: 4px; | ||||
|     position: absolute; | ||||
|   } | ||||
| @@ -46,7 +47,6 @@ | ||||
|       margin: 0; | ||||
|       width: 36px; | ||||
|       height: 18px; | ||||
|       opacity: 0.6; | ||||
|       pointer-events: none; | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { Popover } from "./Popover"; | ||||
| import "./IconPicker.scss"; | ||||
| import { isArrowKey, KEYS } from "../keys"; | ||||
| import { getLanguage } from "../i18n"; | ||||
| import clsx from "clsx"; | ||||
|  | ||||
| function Picker<T>({ | ||||
|   options, | ||||
| @@ -102,7 +103,9 @@ function Picker<T>({ | ||||
|       <div className="picker-content" ref={rGallery}> | ||||
|         {options.map((option, i) => ( | ||||
|           <button | ||||
|             className="picker-option" | ||||
|             className={clsx("picker-option", { | ||||
|               active: value === option.value, | ||||
|             })} | ||||
|             onClick={(event) => { | ||||
|               (event.currentTarget as HTMLButtonElement).focus(); | ||||
|               onChange(option.value); | ||||
| @@ -150,7 +153,7 @@ export function IconPicker<T>({ | ||||
|   const isRTL = getLanguage().rtl; | ||||
|  | ||||
|   return ( | ||||
|     <label className={"picker-container"}> | ||||
|     <div> | ||||
|       <button | ||||
|         name={group} | ||||
|         className={isActive ? "active" : ""} | ||||
| @@ -184,6 +187,6 @@ export function IconPicker<T>({ | ||||
|           </> | ||||
|         ) : null} | ||||
|       </React.Suspense> | ||||
|     </label> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,18 +1,14 @@ | ||||
| import React, { useEffect, useRef, useState } from "react"; | ||||
| import { render, unmountComponentAtNode } from "react-dom"; | ||||
| import { probablySupportsClipboardBlob } from "../clipboard"; | ||||
| import { canvasToBlob } from "../data/blob"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { CanvasError } from "../errors"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "./App"; | ||||
| import { getSelectedElements, isSomeElementSelected } from "../scene"; | ||||
| import { exportToCanvas } from "../scene/export"; | ||||
| import { AppState, BinaryFiles } from "../types"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { clipboard, exportImage } from "./icons"; | ||||
| import { clipboard } from "./icons"; | ||||
| import Stack from "./Stack"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import "./ExportDialog.scss"; | ||||
| import OpenColor from "open-color"; | ||||
| import { CheckboxItem } from "./CheckboxItem"; | ||||
| @@ -35,19 +31,6 @@ export const ErrorCanvasPreview = () => { | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const renderPreview = ( | ||||
|   content: HTMLCanvasElement | Error, | ||||
|   previewNode: HTMLDivElement, | ||||
| ) => { | ||||
|   unmountComponentAtNode(previewNode); | ||||
|   previewNode.innerHTML = ""; | ||||
|   if (content instanceof HTMLCanvasElement) { | ||||
|     previewNode.appendChild(content); | ||||
|   } else { | ||||
|     render(<ErrorCanvasPreview />, previewNode); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export type ExportCB = ( | ||||
|   elements: readonly NonDeletedExcalidrawElement[], | ||||
|   scale?: number, | ||||
| @@ -101,6 +84,7 @@ const ImageExportModal = ({ | ||||
|   const [exportSelected, setExportSelected] = useState(someElementIsSelected); | ||||
|   const previewRef = useRef<HTMLDivElement>(null); | ||||
|   const { exportBackground, viewBackgroundColor } = appState; | ||||
|   const [renderError, setRenderError] = useState<Error | null>(null); | ||||
|  | ||||
|   const exportedElements = exportSelected | ||||
|     ? getSelectedElements(elements, appState, true) | ||||
| @@ -121,15 +105,16 @@ const ImageExportModal = ({ | ||||
|       exportPadding, | ||||
|     }) | ||||
|       .then((canvas) => { | ||||
|         setRenderError(null); | ||||
|         // if converting to blob fails, there's some problem that will | ||||
|         // likely prevent preview and export (e.g. canvas too big) | ||||
|         return canvasToBlob(canvas).then(() => { | ||||
|           renderPreview(canvas, previewNode); | ||||
|           previewNode.replaceChildren(canvas); | ||||
|         }); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error(error); | ||||
|         renderPreview(new CanvasError(), previewNode); | ||||
|         setRenderError(error); | ||||
|       }); | ||||
|   }, [ | ||||
|     appState, | ||||
| @@ -142,7 +127,9 @@ const ImageExportModal = ({ | ||||
|  | ||||
|   return ( | ||||
|     <div className="ExportDialog"> | ||||
|       <div className="ExportDialog__preview" ref={previewRef} /> | ||||
|       <div className="ExportDialog__preview" ref={previewRef}> | ||||
|         {renderError && <ErrorCanvasPreview />} | ||||
|       </div> | ||||
|       {supportsContextFilters && | ||||
|         actionManager.renderAction("exportWithDarkMode")} | ||||
|       <div style={{ display: "grid", gridTemplateColumns: "1fr" }}> | ||||
| @@ -221,6 +208,7 @@ const ImageExportModal = ({ | ||||
| export const ImageExportDialog = ({ | ||||
|   elements, | ||||
|   appState, | ||||
|   setAppState, | ||||
|   files, | ||||
|   exportPadding = DEFAULT_EXPORT_PADDING, | ||||
|   actionManager, | ||||
| @@ -229,6 +217,7 @@ export const ImageExportDialog = ({ | ||||
|   onExportToClipboard, | ||||
| }: { | ||||
|   appState: AppState; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
|   files: BinaryFiles; | ||||
|   exportPadding?: number; | ||||
| @@ -237,26 +226,13 @@ export const ImageExportDialog = ({ | ||||
|   onExportToSvg: ExportCB; | ||||
|   onExportToClipboard: ExportCB; | ||||
| }) => { | ||||
|   const [modalIsShown, setModalIsShown] = useState(false); | ||||
|  | ||||
|   const handleClose = React.useCallback(() => { | ||||
|     setModalIsShown(false); | ||||
|   }, []); | ||||
|     setAppState({ openDialog: null }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         onClick={() => { | ||||
|           setModalIsShown(true); | ||||
|         }} | ||||
|         data-testid="image-export-button" | ||||
|         icon={exportImage} | ||||
|         type="button" | ||||
|         aria-label={t("buttons.exportImage")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|         title={t("buttons.exportImage")} | ||||
|       /> | ||||
|       {modalIsShown && ( | ||||
|       {appState.openDialog === "imageExport" && ( | ||||
|         <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}> | ||||
|           <ImageExportModal | ||||
|             elements={elements} | ||||
|   | ||||
| @@ -2,10 +2,12 @@ import React, { useEffect, useState } from "react"; | ||||
|  | ||||
| import { LoadingMessage } from "./LoadingMessage"; | ||||
| import { defaultLang, Language, languages, setLanguage } from "../i18n"; | ||||
| import { Theme } from "../element/types"; | ||||
|  | ||||
| interface Props { | ||||
|   langCode: Language["code"]; | ||||
|   children: React.ReactElement; | ||||
|   theme?: Theme; | ||||
| } | ||||
|  | ||||
| export const InitializeApp = (props: Props) => { | ||||
| @@ -21,5 +23,5 @@ export const InitializeApp = (props: Props) => { | ||||
|     updateLang(); | ||||
|   }, [props.langCode]); | ||||
|  | ||||
|   return loading ? <LoadingMessage /> : props.children; | ||||
|   return loading ? <LoadingMessage theme={props.theme} /> : props.children; | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| .excalidraw { | ||||
|   .Island { | ||||
|     --padding: 0; | ||||
|     box-sizing: border-box; | ||||
|     background-color: var(--island-bg-color); | ||||
|     box-shadow: var(--shadow-island); | ||||
|     border-radius: var(--border-radius-lg); | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import React, { useState } from "react"; | ||||
| import React from "react"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "./App"; | ||||
|  | ||||
| import { AppState, ExportOpts, BinaryFiles } from "../types"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { exportFile, exportToFileIcon, link } from "./icons"; | ||||
| import { exportToFileIcon, LinkIcon } from "./icons"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { actionSaveFileToDisk } from "../actions/actionExport"; | ||||
| import { Card } from "./Card"; | ||||
| @@ -63,7 +63,7 @@ const JSONExportModal = ({ | ||||
|         )} | ||||
|         {onExportToBackend && ( | ||||
|           <Card color="pink"> | ||||
|             <div className="Card-icon">{link}</div> | ||||
|             <div className="Card-icon">{LinkIcon}</div> | ||||
|             <h2>{t("exportDialog.link_title")}</h2> | ||||
|             <div className="Card-details">{t("exportDialog.link_details")}</div> | ||||
|             <ToolButton | ||||
| @@ -93,6 +93,7 @@ export const JSONExportDialog = ({ | ||||
|   actionManager, | ||||
|   exportOpts, | ||||
|   canvas, | ||||
|   setAppState, | ||||
| }: { | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
|   appState: AppState; | ||||
| @@ -100,27 +101,15 @@ export const JSONExportDialog = ({ | ||||
|   actionManager: ActionManager; | ||||
|   exportOpts: ExportOpts; | ||||
|   canvas: HTMLCanvasElement | null; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
| }) => { | ||||
|   const [modalIsShown, setModalIsShown] = useState(false); | ||||
|  | ||||
|   const handleClose = React.useCallback(() => { | ||||
|     setModalIsShown(false); | ||||
|   }, []); | ||||
|     setAppState({ openDialog: null }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         onClick={() => { | ||||
|           setModalIsShown(true); | ||||
|         }} | ||||
|         data-testid="json-export-button" | ||||
|         icon={exportFile} | ||||
|         type="button" | ||||
|         aria-label={t("buttons.export")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|         title={t("buttons.export")} | ||||
|       /> | ||||
|       {modalIsShown && ( | ||||
|       {appState.openDialog === "jsonExport" && ( | ||||
|         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}> | ||||
|           <JSONExportModal | ||||
|             elements={elements} | ||||
|   | ||||
| @@ -1,48 +1,6 @@ | ||||
| @import "open-color/open-color"; | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .layer-ui__sidebar { | ||||
|   position: absolute; | ||||
|   top: var(--sat); | ||||
|   bottom: var(--sab); | ||||
|   right: var(--sar); | ||||
|   z-index: 5; | ||||
|  | ||||
|   box-shadow: var(--shadow-island); | ||||
|   overflow: hidden; | ||||
|   border-radius: var(--border-radius-lg); | ||||
|   margin: var(--space-factor); | ||||
|   width: calc(#{$right-sidebar-width} - var(--space-factor) * 2); | ||||
|  | ||||
|   .Island { | ||||
|     box-shadow: none; | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     border-radius: var(--border-radius-md); | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon__close { | ||||
|     .Modal__close { | ||||
|       width: calc(var(--space-factor) * 7); | ||||
|       height: calc(var(--space-factor) * 7); | ||||
|       display: flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       color: var(--color-text); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .Island { | ||||
|     --padding: 0; | ||||
|     background-color: var(--island-bg-color); | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     padding: calc(var(--padding) * var(--space-factor)); | ||||
|     position: relative; | ||||
|     transition: box-shadow 0.5s ease-in-out; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .excalidraw { | ||||
|   .layer-ui__wrapper.animate { | ||||
|     transition: width 0.1s ease-in-out; | ||||
| @@ -58,8 +16,10 @@ | ||||
|     height: 100%; | ||||
|     pointer-events: none; | ||||
|     z-index: var(--zIndex-layerUI); | ||||
|  | ||||
|     &__top-right { | ||||
|       display: flex; | ||||
|       gap: 0.75rem; | ||||
|     } | ||||
|  | ||||
|     &__footer { | ||||
| @@ -90,13 +50,6 @@ | ||||
|         transform: translate(-999px, 0); | ||||
|       } | ||||
|  | ||||
|       :root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left { | ||||
|         transform: translate(-76px, 0); | ||||
|       } | ||||
|       :root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left { | ||||
|         transform: translate(76px, 0); | ||||
|       } | ||||
|  | ||||
|       &.layer-ui__wrapper__footer-left--transition-bottom { | ||||
|         transform: translate(0, 92px); | ||||
|       } | ||||
| @@ -127,26 +80,15 @@ | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .layer-ui__wrapper__footer-center { | ||||
|       pointer-events: none; | ||||
|       & > * { | ||||
|         pointer-events: all; | ||||
|       } | ||||
|     } | ||||
|     .layer-ui__wrapper__footer-left, | ||||
|     .layer-ui__wrapper__footer-right, | ||||
|     .disable-zen-mode--visible { | ||||
|       pointer-events: all; | ||||
|     } | ||||
|  | ||||
|     .layer-ui__wrapper__footer-left { | ||||
|       margin-bottom: 0.2em; | ||||
|     } | ||||
|  | ||||
|     .layer-ui__wrapper__footer-right { | ||||
|       margin-top: auto; | ||||
|       margin-bottom: auto; | ||||
|       margin-inline-end: 1em; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,18 +1,23 @@ | ||||
| import clsx from "clsx"; | ||||
| import React, { useCallback } from "react"; | ||||
| import React from "react"; | ||||
| import { ActionManager } from "../actions/manager"; | ||||
| import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; | ||||
| import { exportCanvas } from "../data"; | ||||
| import { isTextElement, showSelectedShapeActions } from "../element"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { Language, t } from "../i18n"; | ||||
| import { calculateScrollCenter, getSelectedElements } from "../scene"; | ||||
| import { calculateScrollCenter } from "../scene"; | ||||
| import { ExportType } from "../scene/types"; | ||||
| import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; | ||||
| import { muteFSAbortError } from "../utils"; | ||||
| import { | ||||
|   AppProps, | ||||
|   AppState, | ||||
|   ExcalidrawProps, | ||||
|   BinaryFiles, | ||||
|   UIChildrenComponents, | ||||
|   UIWelcomeScreenComponents, | ||||
| } from "../types"; | ||||
| import { muteFSAbortError, getReactChildren } from "../utils"; | ||||
| import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; | ||||
| import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; | ||||
| import CollabButton from "./CollabButton"; | ||||
| import { ErrorDialog } from "./ErrorDialog"; | ||||
| import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; | ||||
| import { FixedSideContainer } from "./FixedSideContainer"; | ||||
| @@ -26,7 +31,7 @@ import { Section } from "./Section"; | ||||
| import { HelpDialog } from "./HelpDialog"; | ||||
| import Stack from "./Stack"; | ||||
| import { UserList } from "./UserList"; | ||||
| import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; | ||||
| import Library from "../data/library"; | ||||
| import { JSONExportDialog } from "./JSONExportDialog"; | ||||
| import { LibraryButton } from "./LibraryButton"; | ||||
| import { isImageFileHandle } from "../data/blob"; | ||||
| @@ -39,33 +44,40 @@ import { trackEvent } from "../analytics"; | ||||
| import { useDevice } from "../components/App"; | ||||
| import { Stats } from "./Stats"; | ||||
| import { actionToggleStats } from "../actions/actionToggleStats"; | ||||
| import Footer from "./Footer"; | ||||
| import Footer from "./footer/Footer"; | ||||
| import WelcomeScreen from "./welcome-screen/WelcomeScreen"; | ||||
| import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; | ||||
| import { jotaiScope } from "../jotai"; | ||||
| import { useAtom } from "jotai"; | ||||
| import MainMenu from "./main-menu/MainMenu"; | ||||
|  | ||||
| interface LayerUIProps { | ||||
|   test: JSX.Element; | ||||
|   actionManager: ActionManager; | ||||
|   appState: AppState; | ||||
|   files: BinaryFiles; | ||||
|   canvas: HTMLCanvasElement | null; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
|   onCollabButtonClick?: () => void; | ||||
|   onLockToggle: () => void; | ||||
|   onPenModeToggle: () => void; | ||||
|   onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; | ||||
|   showExitZenModeBtn: boolean; | ||||
|   showThemeBtn: boolean; | ||||
|   langCode: Language["code"]; | ||||
|   isCollaborating: boolean; | ||||
|   renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; | ||||
|   renderCustomFooter?: ExcalidrawProps["renderFooter"]; | ||||
|   renderCustomStats?: ExcalidrawProps["renderCustomStats"]; | ||||
|   renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   UIOptions: AppProps["UIOptions"]; | ||||
|   focusContainer: () => void; | ||||
|   library: Library; | ||||
|   id: string; | ||||
|   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; | ||||
|   renderWelcomeScreen: boolean; | ||||
|   children?: React.ReactNode; | ||||
| } | ||||
|  | ||||
| const LayerUI = ({ | ||||
|   actionManager, | ||||
|   appState, | ||||
| @@ -73,25 +85,47 @@ const LayerUI = ({ | ||||
|   setAppState, | ||||
|   elements, | ||||
|   canvas, | ||||
|   onCollabButtonClick, | ||||
|   onLockToggle, | ||||
|   onPenModeToggle, | ||||
|   onInsertElements, | ||||
|   showExitZenModeBtn, | ||||
|   showThemeBtn, | ||||
|   isCollaborating, | ||||
|   renderTopRightUI, | ||||
|   renderCustomFooter, | ||||
|   renderCustomStats, | ||||
|   renderCustomSidebar, | ||||
|   libraryReturnUrl, | ||||
|   UIOptions, | ||||
|   focusContainer, | ||||
|   library, | ||||
|   id, | ||||
|   onImageAction, | ||||
|   renderWelcomeScreen, | ||||
|   children, | ||||
| }: LayerUIProps) => { | ||||
|   const device = useDevice(); | ||||
|  | ||||
|   const [childrenComponents, restChildren] = | ||||
|     getReactChildren<UIChildrenComponents>(children, { | ||||
|       Menu: true, | ||||
|       FooterCenter: true, | ||||
|       WelcomeScreen: true, | ||||
|     }); | ||||
|  | ||||
|   const [WelcomeScreenComponents] = getReactChildren<UIWelcomeScreenComponents>( | ||||
|     renderWelcomeScreen | ||||
|       ? ( | ||||
|           childrenComponents?.WelcomeScreen ?? ( | ||||
|             <WelcomeScreen> | ||||
|               <WelcomeScreen.Center /> | ||||
|               <WelcomeScreen.Hints.MenuHint /> | ||||
|               <WelcomeScreen.Hints.ToolbarHint /> | ||||
|               <WelcomeScreen.Hints.HelpHint /> | ||||
|             </WelcomeScreen> | ||||
|           ) | ||||
|         )?.props?.children | ||||
|       : null, | ||||
|   ); | ||||
|  | ||||
|   const renderJSONExportDialog = () => { | ||||
|     if (!UIOptions.canvasActions.export) { | ||||
|       return null; | ||||
| @@ -105,6 +139,7 @@ const LayerUI = ({ | ||||
|         actionManager={actionManager} | ||||
|         exportOpts={UIOptions.canvasActions.export} | ||||
|         canvas={canvas} | ||||
|         setAppState={setAppState} | ||||
|       /> | ||||
|     ); | ||||
|   }; | ||||
| @@ -148,6 +183,7 @@ const LayerUI = ({ | ||||
|       <ImageExportDialog | ||||
|         elements={elements} | ||||
|         appState={appState} | ||||
|         setAppState={setAppState} | ||||
|         files={files} | ||||
|         actionManager={actionManager} | ||||
|         onExportToPng={createExporter("png")} | ||||
| @@ -157,76 +193,44 @@ const LayerUI = ({ | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const Separator = () => { | ||||
|     return <div style={{ width: ".625em" }} />; | ||||
|   }; | ||||
|  | ||||
|   const renderViewModeCanvasActions = () => { | ||||
|   const renderMenu = () => { | ||||
|     return ( | ||||
|       <Section | ||||
|         heading="canvasActions" | ||||
|         className={clsx("zen-mode-transition", { | ||||
|           "transition-left": appState.zenModeEnabled, | ||||
|         })} | ||||
|       > | ||||
|         {/* the zIndex ensures this menu has higher stacking order, | ||||
|          see https://github.com/excalidraw/excalidraw/pull/1445 */} | ||||
|         <Island padding={2} style={{ zIndex: 1 }}> | ||||
|           <Stack.Col gap={4}> | ||||
|             <Stack.Row gap={1} justifyContent="space-between"> | ||||
|               {renderJSONExportDialog()} | ||||
|               {renderImageExportDialog()} | ||||
|             </Stack.Row> | ||||
|           </Stack.Col> | ||||
|         </Island> | ||||
|       </Section> | ||||
|       childrenComponents.Menu || ( | ||||
|         <MainMenu> | ||||
|           <MainMenu.DefaultItems.LoadScene /> | ||||
|           <MainMenu.DefaultItems.SaveToActiveFile /> | ||||
|           {/* FIXME we should to test for this inside the item itself */} | ||||
|           {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />} | ||||
|           {/* FIXME we should to test for this inside the item itself */} | ||||
|           {UIOptions.canvasActions.saveAsImage && ( | ||||
|             <MainMenu.DefaultItems.SaveAsImage /> | ||||
|           )} | ||||
|           <MainMenu.DefaultItems.Help /> | ||||
|           <MainMenu.DefaultItems.ClearCanvas /> | ||||
|           <MainMenu.Separator /> | ||||
|           <MainMenu.Group title="Excalidraw links"> | ||||
|             <MainMenu.DefaultItems.Socials /> | ||||
|           </MainMenu.Group> | ||||
|           <MainMenu.Separator /> | ||||
|           <MainMenu.DefaultItems.ToggleTheme /> | ||||
|           <MainMenu.DefaultItems.ChangeCanvasBackground /> | ||||
|         </MainMenu> | ||||
|       ) | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const renderCanvasActions = () => ( | ||||
|     <Section | ||||
|       heading="canvasActions" | ||||
|       className={clsx("zen-mode-transition", { | ||||
|         "transition-left": appState.zenModeEnabled, | ||||
|       })} | ||||
|     > | ||||
|       {/* the zIndex ensures this menu has higher stacking order, | ||||
|          see https://github.com/excalidraw/excalidraw/pull/1445 */} | ||||
|       <Island padding={2} style={{ zIndex: 1 }}> | ||||
|         <Stack.Col gap={4}> | ||||
|           <Stack.Row gap={1} justifyContent="space-between"> | ||||
|             {actionManager.renderAction("clearCanvas")} | ||||
|             <Separator /> | ||||
|             {actionManager.renderAction("loadScene")} | ||||
|             {renderJSONExportDialog()} | ||||
|             {renderImageExportDialog()} | ||||
|             <Separator /> | ||||
|             {onCollabButtonClick && ( | ||||
|               <CollabButton | ||||
|                 isCollaborating={isCollaborating} | ||||
|                 collaboratorCount={appState.collaborators.size} | ||||
|                 onClick={onCollabButtonClick} | ||||
|               /> | ||||
|             )} | ||||
|           </Stack.Row> | ||||
|           <BackgroundPickerAndDarkModeToggle | ||||
|             appState={appState} | ||||
|             actionManager={actionManager} | ||||
|             setAppState={setAppState} | ||||
|             showThemeBtn={showThemeBtn} | ||||
|           /> | ||||
|           {appState.fileHandle && ( | ||||
|             <>{actionManager.renderAction("saveToActiveFile")}</> | ||||
|           )} | ||||
|         </Stack.Col> | ||||
|       </Island> | ||||
|     </Section> | ||||
|     <div style={{ position: "relative" }}> | ||||
|       {WelcomeScreenComponents.MenuHint} | ||||
|       {/* wrapping to Fragment stops React from occasionally complaining | ||||
|                 about identical Keys */} | ||||
|       <>{renderMenu()}</> | ||||
|     </div> | ||||
|   ); | ||||
|  | ||||
|   const renderSelectedShapeActions = () => ( | ||||
|     <Section | ||||
|       heading="selectedShapeActions" | ||||
|       className={clsx("zen-mode-transition", { | ||||
|       className={clsx("selected-shape-actions zen-mode-transition", { | ||||
|         "transition-left": appState.zenModeEnabled, | ||||
|       })} | ||||
|     > | ||||
| @@ -234,10 +238,9 @@ const LayerUI = ({ | ||||
|         className={CLASSES.SHAPE_ACTIONS_MENU} | ||||
|         padding={2} | ||||
|         style={{ | ||||
|           // we want to make sure this doesn't overflow so subtracting 200 | ||||
|           // which is approximately height of zoom footer and top left menu items with some buffer | ||||
|           // if active file name is displayed, subtracting 248 to account for its height | ||||
|           maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`, | ||||
|           // we want to make sure this doesn't overflow so subtracting the | ||||
|           // approximate height of hamburgerMenu + footer | ||||
|           maxHeight: `${appState.height - 166}px`, | ||||
|         }} | ||||
|       > | ||||
|         <SelectedShapeActions | ||||
| @@ -249,41 +252,6 @@ const LayerUI = ({ | ||||
|     </Section> | ||||
|   ); | ||||
|  | ||||
|   const closeLibrary = useCallback(() => { | ||||
|     const isDialogOpen = !!document.querySelector(".Dialog"); | ||||
|  | ||||
|     // Prevent closing if any dialog is open | ||||
|     if (isDialogOpen) { | ||||
|       return; | ||||
|     } | ||||
|     setAppState({ isLibraryOpen: false }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   const deselectItems = useCallback(() => { | ||||
|     setAppState({ | ||||
|       selectedElementIds: {}, | ||||
|       selectedGroupIds: {}, | ||||
|     }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   const libraryMenu = appState.isLibraryOpen ? ( | ||||
|     <LibraryMenu | ||||
|       pendingElements={getSelectedElements(elements, appState, true)} | ||||
|       onClose={closeLibrary} | ||||
|       onInsertLibraryItems={(libraryItems) => { | ||||
|         onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); | ||||
|       }} | ||||
|       onAddToLibrary={deselectItems} | ||||
|       setAppState={setAppState} | ||||
|       libraryReturnUrl={libraryReturnUrl} | ||||
|       focusContainer={focusContainer} | ||||
|       library={library} | ||||
|       files={files} | ||||
|       id={id} | ||||
|       appState={appState} | ||||
|     /> | ||||
|   ) : null; | ||||
|  | ||||
|   const renderFixedSideContainer = () => { | ||||
|     const shouldRenderSelectedShapeActions = showSelectedShapeActions( | ||||
|       appState, | ||||
| @@ -292,21 +260,22 @@ const LayerUI = ({ | ||||
|  | ||||
|     return ( | ||||
|       <FixedSideContainer side="top"> | ||||
|         {WelcomeScreenComponents.Center} | ||||
|         <div className="App-menu App-menu_top"> | ||||
|           <Stack.Col | ||||
|             gap={4} | ||||
|             className={clsx({ | ||||
|             gap={6} | ||||
|             className={clsx("App-menu_top__left", { | ||||
|               "disable-pointerEvents": appState.zenModeEnabled, | ||||
|             })} | ||||
|           > | ||||
|             {appState.viewModeEnabled | ||||
|               ? renderViewModeCanvasActions() | ||||
|               : renderCanvasActions()} | ||||
|             {renderCanvasActions()} | ||||
|             {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} | ||||
|           </Stack.Col> | ||||
|           {!appState.viewModeEnabled && ( | ||||
|             <Section heading="shapes"> | ||||
|             <Section heading="shapes" className="shapes-section"> | ||||
|               {(heading: React.ReactNode) => ( | ||||
|                 <div style={{ position: "relative" }}> | ||||
|                   {WelcomeScreenComponents.ToolbarHint} | ||||
|                   <Stack.Col gap={4} align="start"> | ||||
|                     <Stack.Row | ||||
|                       gap={1} | ||||
| @@ -314,6 +283,20 @@ const LayerUI = ({ | ||||
|                         "zen-mode": appState.zenModeEnabled, | ||||
|                       })} | ||||
|                     > | ||||
|                       <Island | ||||
|                         padding={1} | ||||
|                         className={clsx("App-toolbar", { | ||||
|                           "zen-mode": appState.zenModeEnabled, | ||||
|                         })} | ||||
|                       > | ||||
|                         <HintViewer | ||||
|                           appState={appState} | ||||
|                           elements={elements} | ||||
|                           isMobile={device.isMobile} | ||||
|                           device={device} | ||||
|                         /> | ||||
|                         {heading} | ||||
|                         <Stack.Row gap={1}> | ||||
|                           <PenModeButton | ||||
|                             zenModeEnabled={appState.zenModeEnabled} | ||||
|                             checked={appState.penMode} | ||||
| @@ -327,19 +310,8 @@ const LayerUI = ({ | ||||
|                             onChange={() => onLockToggle()} | ||||
|                             title={t("toolBar.lock")} | ||||
|                           /> | ||||
|                     <Island | ||||
|                       padding={1} | ||||
|                       className={clsx("App-toolbar", { | ||||
|                         "zen-mode": appState.zenModeEnabled, | ||||
|                       })} | ||||
|                     > | ||||
|                       <HintViewer | ||||
|                         appState={appState} | ||||
|                         elements={elements} | ||||
|                         isMobile={device.isMobile} | ||||
|                       /> | ||||
|                       {heading} | ||||
|                       <Stack.Row gap={1}> | ||||
|                           <div className="App-toolbar__divider"></div> | ||||
|  | ||||
|                           <ShapesSwitcher | ||||
|                             appState={appState} | ||||
|                             canvas={canvas} | ||||
| @@ -351,14 +323,14 @@ const LayerUI = ({ | ||||
|                               }); | ||||
|                             }} | ||||
|                           /> | ||||
|                           {/* {actionManager.renderAction("eraser", { | ||||
|                           // size: "small", | ||||
|                         })} */} | ||||
|                         </Stack.Row> | ||||
|                       </Island> | ||||
|                     <LibraryButton | ||||
|                       appState={appState} | ||||
|                       setAppState={setAppState} | ||||
|                     /> | ||||
|                     </Stack.Row> | ||||
|                   </Stack.Col> | ||||
|                 </div> | ||||
|               )} | ||||
|             </Section> | ||||
|           )} | ||||
| @@ -370,19 +342,37 @@ const LayerUI = ({ | ||||
|               }, | ||||
|             )} | ||||
|           > | ||||
|             <UserList | ||||
|               collaborators={appState.collaborators} | ||||
|               actionManager={actionManager} | ||||
|             /> | ||||
|             <UserList collaborators={appState.collaborators} /> | ||||
|             {renderTopRightUI?.(device.isMobile, appState)} | ||||
|             {!appState.viewModeEnabled && ( | ||||
|               <LibraryButton appState={appState} setAppState={setAppState} /> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       </FixedSideContainer> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const renderSidebars = () => { | ||||
|     return appState.openSidebar === "customSidebar" ? ( | ||||
|       renderCustomSidebar?.() || null | ||||
|     ) : appState.openSidebar === "library" ? ( | ||||
|       <LibraryMenu | ||||
|         appState={appState} | ||||
|         onInsertElements={onInsertElements} | ||||
|         libraryReturnUrl={libraryReturnUrl} | ||||
|         focusContainer={focusContainer} | ||||
|         library={library} | ||||
|         id={id} | ||||
|       /> | ||||
|     ) : null; | ||||
|   }; | ||||
|  | ||||
|   const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {restChildren} | ||||
|       {appState.isLoading && <LoadingMessage delay={250} />} | ||||
|       {appState.errorMessage && ( | ||||
|         <ErrorDialog | ||||
| @@ -390,13 +380,15 @@ const LayerUI = ({ | ||||
|           onClose={() => setAppState({ errorMessage: null })} | ||||
|         /> | ||||
|       )} | ||||
|       {appState.showHelpDialog && ( | ||||
|       {appState.openDialog === "help" && ( | ||||
|         <HelpDialog | ||||
|           onClose={() => { | ||||
|             setAppState({ showHelpDialog: false }); | ||||
|             setAppState({ openDialog: null }); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {renderImageExportDialog()} | ||||
|       {renderJSONExportDialog()} | ||||
|       {appState.pasteDialog.shown && ( | ||||
|         <PasteChartDialog | ||||
|           setAppState={setAppState} | ||||
| @@ -414,20 +406,19 @@ const LayerUI = ({ | ||||
|           appState={appState} | ||||
|           elements={elements} | ||||
|           actionManager={actionManager} | ||||
|           libraryMenu={libraryMenu} | ||||
|           renderJSONExportDialog={renderJSONExportDialog} | ||||
|           renderImageExportDialog={renderImageExportDialog} | ||||
|           setAppState={setAppState} | ||||
|           onCollabButtonClick={onCollabButtonClick} | ||||
|           onLockToggle={() => onLockToggle()} | ||||
|           onPenModeToggle={onPenModeToggle} | ||||
|           canvas={canvas} | ||||
|           isCollaborating={isCollaborating} | ||||
|           renderCustomFooter={renderCustomFooter} | ||||
|           showThemeBtn={showThemeBtn} | ||||
|           onImageAction={onImageAction} | ||||
|           renderTopRightUI={renderTopRightUI} | ||||
|           renderCustomStats={renderCustomStats} | ||||
|           renderSidebars={renderSidebars} | ||||
|           device={device} | ||||
|           renderMenu={renderMenu} | ||||
|           welcomeScreenCenter={WelcomeScreenComponents.Center} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
| @@ -442,8 +433,9 @@ const LayerUI = ({ | ||||
|                   !isTextElement(appState.editingElement)), | ||||
|             })} | ||||
|             style={ | ||||
|               appState.isLibraryOpen && | ||||
|               appState.isLibraryMenuDocked && | ||||
|               ((appState.openSidebar === "library" && | ||||
|                 appState.isSidebarDocked) || | ||||
|                 hostSidebarCounters.docked) && | ||||
|               device.canDeviceFitSidebar | ||||
|                 ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } | ||||
|                 : {} | ||||
| @@ -453,8 +445,9 @@ const LayerUI = ({ | ||||
|             <Footer | ||||
|               appState={appState} | ||||
|               actionManager={actionManager} | ||||
|               renderCustomFooter={renderCustomFooter} | ||||
|               showExitZenModeBtn={showExitZenModeBtn} | ||||
|               footerCenter={childrenComponents.FooterCenter} | ||||
|               welcomeScreenHelp={WelcomeScreenComponents.HelpHint} | ||||
|             /> | ||||
|             {appState.showStats && ( | ||||
|               <Stats | ||||
| @@ -480,9 +473,7 @@ const LayerUI = ({ | ||||
|               </button> | ||||
|             )} | ||||
|           </div> | ||||
|           {appState.isLibraryOpen && ( | ||||
|             <div className="layer-ui__sidebar">{libraryMenu}</div> | ||||
|           )} | ||||
|           {renderSidebars()} | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
| @@ -502,8 +493,11 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { | ||||
|   const nextAppState = getNecessaryObj(next.appState); | ||||
|  | ||||
|   const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[]; | ||||
|  | ||||
|   return ( | ||||
|     prev.renderCustomFooter === next.renderCustomFooter && | ||||
|     prev.renderTopRightUI === next.renderTopRightUI && | ||||
|     prev.renderCustomStats === next.renderCustomStats && | ||||
|     prev.renderCustomSidebar === next.renderCustomSidebar && | ||||
|     prev.langCode === next.langCode && | ||||
|     prev.elements === next.elements && | ||||
|     prev.files === next.files && | ||||
|   | ||||
							
								
								
									
										32
									
								
								src/components/LibraryButton.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/components/LibraryButton.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .library-button { | ||||
|   @include outlineButtonStyles; | ||||
|  | ||||
|   background-color: var(--island-bg-color); | ||||
|  | ||||
|   width: auto; | ||||
|   height: var(--lg-button-size); | ||||
|  | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.5rem; | ||||
|  | ||||
|   line-height: 0; | ||||
|  | ||||
|   font-size: 0.75rem; | ||||
|   letter-spacing: 0.4px; | ||||
|  | ||||
|   svg { | ||||
|     width: var(--lg-icon-size); | ||||
|     height: var(--lg-icon-size); | ||||
|   } | ||||
|  | ||||
|   &__label { | ||||
|     display: none; | ||||
|  | ||||
|     @media screen and (min-width: 1024px) { | ||||
|       display: block; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,19 +1,11 @@ | ||||
| import React from "react"; | ||||
| import clsx from "clsx"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppState } from "../types"; | ||||
| import { capitalizeString } from "../utils"; | ||||
| import { trackEvent } from "../analytics"; | ||||
| import { useDevice } from "./App"; | ||||
|  | ||||
| const LIBRARY_ICON = ( | ||||
|   <svg viewBox="0 0 576 512"> | ||||
|     <path | ||||
|       fill="currentColor" | ||||
|       d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z" | ||||
|     ></path> | ||||
|   </svg> | ||||
| ); | ||||
| import "./LibraryButton.scss"; | ||||
| import { LibraryIcon } from "./icons"; | ||||
|  | ||||
| export const LibraryButton: React.FC<{ | ||||
|   appState: AppState; | ||||
| @@ -21,17 +13,16 @@ export const LibraryButton: React.FC<{ | ||||
|   isMobile?: boolean; | ||||
| }> = ({ appState, setAppState, isMobile }) => { | ||||
|   const device = useDevice(); | ||||
|   const showLabel = !isMobile; | ||||
|  | ||||
|   // TODO barnabasmolnar/redesign | ||||
|   // not great, toolbar jumps in a jarring manner | ||||
|   if (appState.isSidebarDocked && appState.openSidebar === "library") { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <label | ||||
|       className={clsx( | ||||
|         "ToolIcon ToolIcon_type_floating ToolIcon__library", | ||||
|         `ToolIcon_size_medium`, | ||||
|         { | ||||
|           "is-mobile": isMobile, | ||||
|         }, | ||||
|       )} | ||||
|       title={`${capitalizeString(t("toolBar.library"))} — 0`} | ||||
|     > | ||||
|     <label title={`${capitalizeString(t("toolBar.library"))}`}> | ||||
|       <input | ||||
|         className="ToolIcon_type_checkbox" | ||||
|         type="checkbox" | ||||
| @@ -40,10 +31,10 @@ export const LibraryButton: React.FC<{ | ||||
|           document | ||||
|             .querySelector(".layer-ui__wrapper") | ||||
|             ?.classList.remove("animate"); | ||||
|           const nextState = event.target.checked; | ||||
|           setAppState({ isLibraryOpen: nextState }); | ||||
|           const isOpen = event.target.checked; | ||||
|           setAppState({ openSidebar: isOpen ? "library" : null }); | ||||
|           // track only openings | ||||
|           if (nextState) { | ||||
|           if (isOpen) { | ||||
|             trackEvent( | ||||
|               "library", | ||||
|               "toggleLibrary (open)", | ||||
| @@ -51,11 +42,16 @@ export const LibraryButton: React.FC<{ | ||||
|             ); | ||||
|           } | ||||
|         }} | ||||
|         checked={appState.isLibraryOpen} | ||||
|         checked={appState.openSidebar === "library"} | ||||
|         aria-label={capitalizeString(t("toolBar.library"))} | ||||
|         aria-keyshortcuts="0" | ||||
|       /> | ||||
|       <div className="ToolIcon__icon">{LIBRARY_ICON}</div> | ||||
|       <div className="library-button"> | ||||
|         <div>{LibraryIcon}</div> | ||||
|         {showLabel && ( | ||||
|           <div className="library-button__label">{t("toolBar.library")}</div> | ||||
|         )} | ||||
|       </div> | ||||
|     </label> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,10 +1,16 @@ | ||||
| @import "open-color/open-color"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .layer-ui__library-sidebar { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|   } | ||||
|  | ||||
|   .layer-ui__library { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     flex-direction: column; | ||||
|  | ||||
|     flex: 1 1 auto; | ||||
|  | ||||
|     .layer-ui__library-header { | ||||
|       display: flex; | ||||
| @@ -23,25 +29,38 @@ | ||||
|   } | ||||
|  | ||||
|   .layer-ui__sidebar { | ||||
|     .layer-ui__library { | ||||
|       padding: 0; | ||||
|       height: 100%; | ||||
|     } | ||||
|     .library-menu-items-container { | ||||
|       height: 100%; | ||||
|       width: 100%; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-actions-counter { | ||||
|     background-color: var(--color-primary); | ||||
|     color: var(--color-primary-light); | ||||
|     font-weight: bold; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     border-radius: 50%; | ||||
|     width: 1rem; | ||||
|     height: 1rem; | ||||
|     position: absolute; | ||||
|     bottom: -0.25rem; | ||||
|     right: -0.25rem; | ||||
|     font-size: 0.625rem; | ||||
|     pointer-events: none; | ||||
|   } | ||||
|  | ||||
|   .layer-ui__library-message { | ||||
|     padding: 2em 4em; | ||||
|     padding: 2rem; | ||||
|     min-width: 200px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     .Spinner { | ||||
|       margin-bottom: 1em; | ||||
|     } | ||||
|     flex-grow: 1; | ||||
|     justify-content: center; | ||||
|  | ||||
|     span { | ||||
|       font-size: 0.8em; | ||||
|     } | ||||
| @@ -69,11 +88,10 @@ | ||||
|   } | ||||
|  | ||||
|   .library-menu-browse-button { | ||||
|     width: 80%; | ||||
|     min-height: 22px; | ||||
|     margin: 0 auto; | ||||
|     margin-top: 1rem; | ||||
|     padding: 10px; | ||||
|     margin: 1rem auto; | ||||
|  | ||||
|     padding: 0.875rem 1rem; | ||||
|  | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| @@ -86,6 +104,10 @@ | ||||
|     text-align: center; | ||||
|     white-space: nowrap; | ||||
|     text-decoration: none !important; | ||||
|  | ||||
|     font-weight: 600; | ||||
|     font-size: 0.75rem; | ||||
|  | ||||
|     &:hover { | ||||
|       background-color: var(--color-primary-darker); | ||||
|     } | ||||
| @@ -94,6 +116,12 @@ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .library-menu-browse-button { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-menu-browse-button--mobile { | ||||
|     min-height: 22px; | ||||
|     margin-left: auto; | ||||
| @@ -101,4 +129,27 @@ | ||||
|       padding-right: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .layer-ui__sidebar__header .dropdown-menu { | ||||
|     &.dropdown-menu--mobile { | ||||
|       top: 100%; | ||||
|     } | ||||
|     .dropdown-menu-container { | ||||
|       --gap: 0; | ||||
|       z-index: 1; | ||||
|       position: absolute; | ||||
|       top: 100%; | ||||
|       left: 0; | ||||
|  | ||||
|       :root[dir="rtl"] & { | ||||
|         right: 0; | ||||
|         left: auto; | ||||
|       } | ||||
|  | ||||
|       width: 196px; | ||||
|       box-shadow: var(--library-dropdown-shadow); | ||||
|       border-radius: var(--border-radius-lg); | ||||
|       padding: 0.25rem 0.5rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,20 +6,13 @@ import { | ||||
|   RefObject, | ||||
|   forwardRef, | ||||
| } from "react"; | ||||
| import Library, { libraryItemsAtom } from "../data/library"; | ||||
| import Library, { | ||||
|   distributeLibraryItemsOnSquareGrid, | ||||
|   libraryItemsAtom, | ||||
| } from "../data/library"; | ||||
| import { t } from "../i18n"; | ||||
| import { randomId } from "../random"; | ||||
| import { | ||||
|   LibraryItems, | ||||
|   LibraryItem, | ||||
|   AppState, | ||||
|   BinaryFiles, | ||||
|   ExcalidrawProps, | ||||
| } from "../types"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { Island } from "./Island"; | ||||
| import PublishLibrary from "./PublishLibrary"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types"; | ||||
|  | ||||
| import "./LibraryMenu.scss"; | ||||
| import LibraryMenuItems from "./LibraryMenuItems"; | ||||
| @@ -29,7 +22,16 @@ import { trackEvent } from "../analytics"; | ||||
| import { useAtom } from "jotai"; | ||||
| import { jotaiScope } from "../jotai"; | ||||
| import Spinner from "./Spinner"; | ||||
| import { useDevice } from "./App"; | ||||
| import { | ||||
|   useDevice, | ||||
|   useExcalidrawElements, | ||||
|   useExcalidrawSetAppState, | ||||
| } from "./App"; | ||||
| import { Sidebar } from "./Sidebar/Sidebar"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { LibraryMenuHeader } from "./LibraryMenuHeaderContent"; | ||||
| import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; | ||||
|  | ||||
| const useOnClickOutside = ( | ||||
|   ref: RefObject<HTMLElement>, | ||||
| @@ -59,110 +61,42 @@ const useOnClickOutside = ( | ||||
|   }, [ref, cb]); | ||||
| }; | ||||
|  | ||||
| const getSelectedItems = ( | ||||
|   libraryItems: LibraryItems, | ||||
|   selectedItems: LibraryItem["id"][], | ||||
| ) => libraryItems.filter((item) => selectedItems.includes(item.id)); | ||||
|  | ||||
| const LibraryMenuWrapper = forwardRef< | ||||
|   HTMLDivElement, | ||||
|   { children: React.ReactNode } | ||||
| >(({ children }, ref) => { | ||||
|   return ( | ||||
|     <Island padding={1} ref={ref} className="layer-ui__library"> | ||||
|     <div ref={ref} className="layer-ui__library"> | ||||
|       {children} | ||||
|     </Island> | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| export const LibraryMenu = ({ | ||||
|   onClose, | ||||
| export const LibraryMenuContent = ({ | ||||
|   onInsertLibraryItems, | ||||
|   pendingElements, | ||||
|   onAddToLibrary, | ||||
|   setAppState, | ||||
|   files, | ||||
|   libraryReturnUrl, | ||||
|   focusContainer, | ||||
|   library, | ||||
|   id, | ||||
|   appState, | ||||
|   selectedItems, | ||||
|   onSelectItems, | ||||
| }: { | ||||
|   pendingElements: LibraryItem["elements"]; | ||||
|   onClose: () => void; | ||||
|   onInsertLibraryItems: (libraryItems: LibraryItems) => void; | ||||
|   onAddToLibrary: () => void; | ||||
|   files: BinaryFiles; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   focusContainer: () => void; | ||||
|   library: Library; | ||||
|   id: string; | ||||
|   appState: AppState; | ||||
|   selectedItems: LibraryItem["id"][]; | ||||
|   onSelectItems: (id: LibraryItem["id"][]) => void; | ||||
| }) => { | ||||
|   const ref = useRef<HTMLDivElement | null>(null); | ||||
|  | ||||
|   const device = useDevice(); | ||||
|   useOnClickOutside( | ||||
|     ref, | ||||
|     useCallback( | ||||
|       (event) => { | ||||
|         // If click on the library icon, do nothing so that LibraryButton | ||||
|         // can toggle library menu | ||||
|         if ((event.target as Element).closest(".ToolIcon__library")) { | ||||
|           return; | ||||
|         } | ||||
|         if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) { | ||||
|           onClose(); | ||||
|         } | ||||
|       }, | ||||
|       [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar], | ||||
|     ), | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (event: KeyboardEvent) => { | ||||
|       if ( | ||||
|         event.key === KEYS.ESCAPE && | ||||
|         (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) | ||||
|       ) { | ||||
|         onClose(); | ||||
|       } | ||||
|     }; | ||||
|     document.addEventListener(EVENT.KEYDOWN, handleKeyDown); | ||||
|     return () => { | ||||
|       document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); | ||||
|     }; | ||||
|   }, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]); | ||||
|  | ||||
|   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); | ||||
|   const [showPublishLibraryDialog, setShowPublishLibraryDialog] = | ||||
|     useState(false); | ||||
|   const [publishLibSuccess, setPublishLibSuccess] = useState<null | { | ||||
|     url: string; | ||||
|     authorName: string; | ||||
|   }>(null); | ||||
|  | ||||
|   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); | ||||
|  | ||||
|   const removeFromLibrary = useCallback( | ||||
|     async (libraryItems: LibraryItems) => { | ||||
|       const nextItems = libraryItems.filter( | ||||
|         (item) => !selectedItems.includes(item.id), | ||||
|       ); | ||||
|       library.setLibrary(nextItems).catch(() => { | ||||
|         setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); | ||||
|       }); | ||||
|       setSelectedItems([]); | ||||
|     }, | ||||
|     [library, setAppState, selectedItems, setSelectedItems], | ||||
|   ); | ||||
|  | ||||
|   const resetLibrary = useCallback(() => { | ||||
|     library.resetLibrary(); | ||||
|     focusContainer(); | ||||
|   }, [library, focusContainer]); | ||||
|  | ||||
|   const addToLibrary = useCallback( | ||||
|     async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => { | ||||
|       trackEvent("element", "addToLibrary", "ui"); | ||||
| @@ -188,114 +122,188 @@ export const LibraryMenu = ({ | ||||
|     [onAddToLibrary, library, setAppState], | ||||
|   ); | ||||
|  | ||||
|   const renderPublishSuccess = useCallback(() => { | ||||
|     return ( | ||||
|       <Dialog | ||||
|         onCloseRequest={() => setPublishLibSuccess(null)} | ||||
|         title={t("publishSuccessDialog.title")} | ||||
|         className="publish-library-success" | ||||
|         small={true} | ||||
|       > | ||||
|         <p> | ||||
|           {t("publishSuccessDialog.content", { | ||||
|             authorName: publishLibSuccess!.authorName, | ||||
|           })}{" "} | ||||
|           <a | ||||
|             href={publishLibSuccess?.url} | ||||
|             target="_blank" | ||||
|             rel="noopener noreferrer" | ||||
|           > | ||||
|             {t("publishSuccessDialog.link")} | ||||
|           </a> | ||||
|         </p> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={t("buttons.close")} | ||||
|           aria-label={t("buttons.close")} | ||||
|           label={t("buttons.close")} | ||||
|           onClick={() => setPublishLibSuccess(null)} | ||||
|           data-testid="publish-library-success-close" | ||||
|           className="publish-library-success-close" | ||||
|         /> | ||||
|       </Dialog> | ||||
|     ); | ||||
|   }, [setPublishLibSuccess, publishLibSuccess]); | ||||
|  | ||||
|   const onPublishLibSuccess = useCallback( | ||||
|     (data: { url: string; authorName: string }, libraryItems: LibraryItems) => { | ||||
|       setShowPublishLibraryDialog(false); | ||||
|       setPublishLibSuccess({ url: data.url, authorName: data.authorName }); | ||||
|       const nextLibItems = libraryItems.slice(); | ||||
|       nextLibItems.forEach((libItem) => { | ||||
|         if (selectedItems.includes(libItem.id)) { | ||||
|           libItem.status = "published"; | ||||
|         } | ||||
|       }); | ||||
|       library.setLibrary(nextLibItems); | ||||
|     }, | ||||
|     [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library], | ||||
|   ); | ||||
|  | ||||
|   if ( | ||||
|     libraryItemsData.status === "loading" && | ||||
|     !libraryItemsData.isInitialized | ||||
|   ) { | ||||
|     return ( | ||||
|       <LibraryMenuWrapper ref={ref}> | ||||
|       <LibraryMenuWrapper> | ||||
|         <div className="layer-ui__library-message"> | ||||
|           <div> | ||||
|             <Spinner size="2em" /> | ||||
|             <span>{t("labels.libraryLoadingMessage")}</span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </LibraryMenuWrapper> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const showBtn = | ||||
|     libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0; | ||||
|  | ||||
|   return ( | ||||
|     <LibraryMenuWrapper ref={ref}> | ||||
|       {showPublishLibraryDialog && ( | ||||
|         <PublishLibrary | ||||
|           onClose={() => setShowPublishLibraryDialog(false)} | ||||
|           libraryItems={getSelectedItems( | ||||
|             libraryItemsData.libraryItems, | ||||
|             selectedItems, | ||||
|           )} | ||||
|           appState={appState} | ||||
|           onSuccess={(data) => | ||||
|             onPublishLibSuccess(data, libraryItemsData.libraryItems) | ||||
|           } | ||||
|           onError={(error) => window.alert(error)} | ||||
|           updateItemsInStorage={() => | ||||
|             library.setLibrary(libraryItemsData.libraryItems) | ||||
|           } | ||||
|           onRemove={(id: string) => | ||||
|             setSelectedItems(selectedItems.filter((_id) => _id !== id)) | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       {publishLibSuccess && renderPublishSuccess()} | ||||
|     <LibraryMenuWrapper> | ||||
|       <LibraryMenuItems | ||||
|         isLoading={libraryItemsData.status === "loading"} | ||||
|         libraryItems={libraryItemsData.libraryItems} | ||||
|         onRemoveFromLibrary={() => | ||||
|           removeFromLibrary(libraryItemsData.libraryItems) | ||||
|         } | ||||
|         onAddToLibrary={(elements) => | ||||
|           addToLibrary(elements, libraryItemsData.libraryItems) | ||||
|         } | ||||
|         onInsertLibraryItems={onInsertLibraryItems} | ||||
|         pendingElements={pendingElements} | ||||
|         setAppState={setAppState} | ||||
|         appState={appState} | ||||
|         libraryReturnUrl={libraryReturnUrl} | ||||
|         library={library} | ||||
|         theme={appState.theme} | ||||
|         files={files} | ||||
|         id={id} | ||||
|         selectedItems={selectedItems} | ||||
|         onSelectItems={(ids) => setSelectedItems(ids)} | ||||
|         onPublish={() => setShowPublishLibraryDialog(true)} | ||||
|         resetLibrary={resetLibrary} | ||||
|         onSelectItems={onSelectItems} | ||||
|         id={id} | ||||
|         libraryReturnUrl={libraryReturnUrl} | ||||
|         theme={appState.theme} | ||||
|       /> | ||||
|       {showBtn && ( | ||||
|         <LibraryMenuBrowseButton | ||||
|           id={id} | ||||
|           libraryReturnUrl={libraryReturnUrl} | ||||
|           theme={appState.theme} | ||||
|         /> | ||||
|       )} | ||||
|     </LibraryMenuWrapper> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const LibraryMenu: React.FC<{ | ||||
|   appState: AppState; | ||||
|   onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   focusContainer: () => void; | ||||
|   library: Library; | ||||
|   id: string; | ||||
| }> = ({ | ||||
|   appState, | ||||
|   onInsertElements, | ||||
|   libraryReturnUrl, | ||||
|   focusContainer, | ||||
|   library, | ||||
|   id, | ||||
| }) => { | ||||
|   const setAppState = useExcalidrawSetAppState(); | ||||
|   const elements = useExcalidrawElements(); | ||||
|   const device = useDevice(); | ||||
|  | ||||
|   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); | ||||
|   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); | ||||
|  | ||||
|   const ref = useRef<HTMLDivElement | null>(null); | ||||
|  | ||||
|   const closeLibrary = useCallback(() => { | ||||
|     const isDialogOpen = !!document.querySelector(".Dialog"); | ||||
|  | ||||
|     // Prevent closing if any dialog is open | ||||
|     if (isDialogOpen) { | ||||
|       return; | ||||
|     } | ||||
|     setAppState({ openSidebar: null }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   useOnClickOutside( | ||||
|     ref, | ||||
|     useCallback( | ||||
|       (event) => { | ||||
|         // If click on the library icon, do nothing so that LibraryButton | ||||
|         // can toggle library menu | ||||
|         if ((event.target as Element).closest(".ToolIcon__library")) { | ||||
|           return; | ||||
|         } | ||||
|         if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) { | ||||
|           closeLibrary(); | ||||
|         } | ||||
|       }, | ||||
|       [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar], | ||||
|     ), | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (event: KeyboardEvent) => { | ||||
|       if ( | ||||
|         event.key === KEYS.ESCAPE && | ||||
|         (!appState.isSidebarDocked || !device.canDeviceFitSidebar) | ||||
|       ) { | ||||
|         closeLibrary(); | ||||
|       } | ||||
|     }; | ||||
|     document.addEventListener(EVENT.KEYDOWN, handleKeyDown); | ||||
|     return () => { | ||||
|       document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); | ||||
|     }; | ||||
|   }, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]); | ||||
|  | ||||
|   const deselectItems = useCallback(() => { | ||||
|     setAppState({ | ||||
|       selectedElementIds: {}, | ||||
|       selectedGroupIds: {}, | ||||
|     }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   const removeFromLibrary = useCallback( | ||||
|     async (libraryItems: LibraryItems) => { | ||||
|       const nextItems = libraryItems.filter( | ||||
|         (item) => !selectedItems.includes(item.id), | ||||
|       ); | ||||
|       library.setLibrary(nextItems).catch(() => { | ||||
|         setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); | ||||
|       }); | ||||
|       setSelectedItems([]); | ||||
|     }, | ||||
|     [library, setAppState, selectedItems, setSelectedItems], | ||||
|   ); | ||||
|  | ||||
|   const resetLibrary = useCallback(() => { | ||||
|     library.resetLibrary(); | ||||
|     focusContainer(); | ||||
|   }, [library, focusContainer]); | ||||
|  | ||||
|   return ( | ||||
|     <Sidebar | ||||
|       __isInternal | ||||
|       // necessary to remount when switching between internal | ||||
|       // and custom (host app) sidebar, so that the `props.onClose` | ||||
|       // is colled correctly | ||||
|       key="library" | ||||
|       className="layer-ui__library-sidebar" | ||||
|       initialDockedState={appState.isSidebarDocked} | ||||
|       onDock={(docked) => { | ||||
|         trackEvent( | ||||
|           "library", | ||||
|           `toggleLibraryDock (${docked ? "dock" : "undock"})`, | ||||
|           `sidebar (${device.isMobile ? "mobile" : "desktop"})`, | ||||
|         ); | ||||
|       }} | ||||
|       ref={ref} | ||||
|     > | ||||
|       <Sidebar.Header className="layer-ui__library-header"> | ||||
|         <LibraryMenuHeader | ||||
|           appState={appState} | ||||
|           setAppState={setAppState} | ||||
|           selectedItems={selectedItems} | ||||
|           onSelectItems={setSelectedItems} | ||||
|           library={library} | ||||
|           onRemoveFromLibrary={() => | ||||
|             removeFromLibrary(libraryItemsData.libraryItems) | ||||
|           } | ||||
|           resetLibrary={resetLibrary} | ||||
|         /> | ||||
|       </Sidebar.Header> | ||||
|       <LibraryMenuContent | ||||
|         pendingElements={getSelectedElements(elements, appState, true)} | ||||
|         onInsertLibraryItems={(libraryItems) => { | ||||
|           onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); | ||||
|         }} | ||||
|         onAddToLibrary={deselectItems} | ||||
|         setAppState={setAppState} | ||||
|         libraryReturnUrl={libraryReturnUrl} | ||||
|         library={library} | ||||
|         id={id} | ||||
|         appState={appState} | ||||
|         selectedItems={selectedItems} | ||||
|         onSelectItems={setSelectedItems} | ||||
|       /> | ||||
|     </Sidebar> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										31
									
								
								src/components/LibraryMenuBrowseButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/components/LibraryMenuBrowseButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import { VERSIONS } from "../constants"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppState, ExcalidrawProps } from "../types"; | ||||
|  | ||||
| const LibraryMenuBrowseButton = ({ | ||||
|   theme, | ||||
|   id, | ||||
|   libraryReturnUrl, | ||||
| }: { | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   theme: AppState["theme"]; | ||||
|   id: string; | ||||
| }) => { | ||||
|   const referrer = | ||||
|     libraryReturnUrl || window.location.origin + window.location.pathname; | ||||
|   return ( | ||||
|     <a | ||||
|       className="library-menu-browse-button" | ||||
|       href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ | ||||
|         window.name || "_blank" | ||||
|       }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${ | ||||
|         VERSIONS.excalidrawLibrary | ||||
|       }`} | ||||
|       target="_excalidraw_libraries" | ||||
|     > | ||||
|       {t("labels.libraries")} | ||||
|     </a> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default LibraryMenuBrowseButton; | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user