mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	Compare commits
	
		
			121 Commits
		
	
	
		
			expose_app
			...
			improve_pn
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 75e2d9e359 | ||
|   | 6592517122 | ||
|   | bd953a6287 | ||
|   | b6ef953dc9 | ||
|   | 620b662085 | ||
|   | 1c11df011a | ||
|   | 59e9651547 | ||
|   | 1c48d122e0 | ||
|   | e4d02fb275 | ||
|   | 34a382ace9 | ||
|   | e60e48e67d | ||
|   | 84d1d9993c | ||
|   | 3ff9744b39 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b9abcc825a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9679eaf74c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 284747d742 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 876f85fd7a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | efc2bbed21 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 61d193b87b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3989d6a989 | ||
|   | f6559b65ef | ||
|   | bc6b066c07 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6370d517a2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b8a37c42e4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 76763b80a9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d2a2c9d6b5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3a72f347d2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c1d9456235 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c4f8b98208 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b6eb57d3f1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 473b8ca0ca | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 45206c4ef1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 56b4a29aaa | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bb4dda64b5 | ||
|   | 39e53b4ae7 | ||
|   | 6143d5195a | ||
|   | f59e608f18 | ||
|   | 6b24592e4a | ||
|   | 7b442997dc | ||
|   | 4bfc5bbcaa | ||
|   | 2b29b9a96d | ||
|   | cc201a6d80 | ||
|   | 5be58b59e0 | ||
|   | f1eb969565 | ||
|   | 8d4f455cd3 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 60262cb4cc | ||
|   | 7501c24f22 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 00d81aa982 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 67fe156d06 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ef433233d1 | ||
|   | 1c7056bdaa | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 277ffaacb9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2a3e242cfd | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b1c6051d6b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8df9742463 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9fdc382d71 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | f70d11c2d1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 05e54d6785 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 795a6e4546 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a01a4ad739 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e09b96ac6f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d48fb17718 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ede3c4af82 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8bcfd97fc5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5721c6dfb5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9b1f77c3be | ||
|   | 3369035f40 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | dbc7a8599b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 09f649daf7 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d357664850 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | f973fdfa89 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c15bc50f17 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c2d0107cc5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c43fac31a1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9dfaf1752b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d9a1eb2f01 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | f1e17a320f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 75ecd818b3 | ||
|   | a7abc71f6a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6d0f0c8f21 | ||
|   | 790e6da500 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8df1a11535 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b61ee56dc8 | ||
|   | c61f95a327 | ||
|   | d6d629f416 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 65dec605f2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | cacec0b5c4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 87a302d7e9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 899b36c206 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 534cbef982 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b7f118404e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | aab5067718 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b679da02ee | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ec652820ea | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5d941ed107 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | adc478ca34 | ||
|   | f1202adb15 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | fd439cf38a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 83c63be846 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b59d49dd7f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0116b70edf | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3f390d4858 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | fdde73bff4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 90a416e265 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a828b2e5de | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7c51d3c24c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4d2d6f181a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 071416f6ef | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d675b07089 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3975fd592a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 34a9a4dac6 | ||
|   | 78e419b790 | ||
|   | 8d8769ba4e | ||
|   | d89fb3371b | ||
|   | 8410972cff | ||
|   | 2c8d041987 | ||
|   | 94519c8250 | ||
|   | add8a1b1a7 | ||
|   | 516e7656f3 | ||
|   | d7cdee37bf | ||
|   | 5c5b8c517f | 
							
								
								
									
										13
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								.env
									
									
									
									
									
								
							| @@ -1,5 +1,8 @@ | ||||
| REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/ | ||||
| REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ | ||||
| REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ | ||||
| REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com | ||||
| REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' | ||||
| REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ | ||||
| REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ | ||||
|  | ||||
| REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com | ||||
| REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries | ||||
|  | ||||
| REACT_APP_SOCKET_SERVER_URL=http://localhost:3000 | ||||
| REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' | ||||
|   | ||||
| @@ -1 +1,11 @@ | ||||
| REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ | ||||
| REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ | ||||
|  | ||||
| REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com | ||||
| REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries | ||||
|  | ||||
| REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com | ||||
| REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' | ||||
|  | ||||
| # production-only vars | ||||
| REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| { | ||||
|   "extends": ["@excalidraw/eslint-config", "react-app"], | ||||
|   "rules": { | ||||
|     "import/no-anonymous-default-export": "off" | ||||
|     "import/no-anonymous-default-export": "off", | ||||
|     "no-restricted-globals": "off" | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -10,6 +10,7 @@ updates: | ||||
|       - lipis | ||||
|     assignees: | ||||
|       - lipis | ||||
|     open-pull-requests-limit: 20 | ||||
|  | ||||
|   - package-ecosystem: npm | ||||
|     directory: /src/packages/excalidraw/ | ||||
| @@ -21,6 +22,7 @@ updates: | ||||
|       - ad1992 | ||||
|     assignees: | ||||
|       - ad1992 | ||||
|     open-pull-requests-limit: 20 | ||||
|  | ||||
|   - package-ecosystem: npm | ||||
|     directory: /src/packages/utils/ | ||||
| @@ -32,3 +34,4 @@ updates: | ||||
|       - ad1992 | ||||
|     assignees: | ||||
|       - ad1992 | ||||
|     open-pull-requests-limit: 20 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -11,7 +11,7 @@ jobs: | ||||
|  | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: docker/build-push-action@v1 | ||||
|       - uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|   | ||||
							
								
								
									
										1
									
								
								.husky/pre-commit
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										1
									
								
								.husky/pre-commit
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1 @@ | ||||
| yarn lint-staged | ||||
							
								
								
									
										57
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										57
									
								
								package.json
									
									
									
									
									
								
							| @@ -19,28 +19,28 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@dwelle/browser-fs-access": "0.21.3", | ||||
|     "@sentry/browser": "6.2.5", | ||||
|     "@sentry/integrations": "6.2.5", | ||||
|     "@testing-library/jest-dom": "5.11.10", | ||||
|     "@testing-library/react": "11.2.6", | ||||
|     "@tldraw/vec": "0.0.106", | ||||
|     "@types/jest": "26.0.22", | ||||
|     "@testing-library/jest-dom": "5.15.0", | ||||
|     "@testing-library/react": "12.1.2", | ||||
|     "@tldraw/vec": "0.1.3", | ||||
|     "@types/jest": "27.0.2", | ||||
|     "@types/pica": "5.1.3", | ||||
|     "@types/react": "17.0.3", | ||||
|     "@types/react-dom": "17.0.3", | ||||
|     "@types/react": "17.0.34", | ||||
|     "@types/react-dom": "17.0.11", | ||||
|     "@types/socket.io-client": "1.4.36", | ||||
|     "browser-fs-access": "0.21.1", | ||||
|     "clsx": "1.1.1", | ||||
|     "fake-indexeddb": "3.1.3", | ||||
|     "fake-indexeddb": "3.1.7", | ||||
|     "firebase": "8.3.3", | ||||
|     "i18next-browser-languagedetector": "6.1.0", | ||||
|     "idb-keyval": "5.1.3", | ||||
|     "i18next-browser-languagedetector": "6.1.2", | ||||
|     "idb-keyval": "6.0.3", | ||||
|     "image-blob-reduce": "3.0.1", | ||||
|     "lodash.throttle": "4.1.1", | ||||
|     "nanoid": "3.1.22", | ||||
|     "open-color": "1.8.0", | ||||
|     "nanoid": "3.1.30", | ||||
|     "open-color": "1.9.1", | ||||
|     "pako": "1.0.11", | ||||
|     "perfect-freehand": "1.0.15", | ||||
|     "perfect-freehand": "1.0.16", | ||||
|     "png-chunk-text": "1.0.0", | ||||
|     "png-chunks-encode": "1.0.0", | ||||
|     "png-chunks-extract": "1.0.0", | ||||
| @@ -49,39 +49,39 @@ | ||||
|     "react": "17.0.2", | ||||
|     "react-dom": "17.0.2", | ||||
|     "react-scripts": "4.0.3", | ||||
|     "roughjs": "4.4.1", | ||||
|     "sass": "1.32.10", | ||||
|     "roughjs": "4.5.0", | ||||
|     "sass": "1.43.4", | ||||
|     "socket.io-client": "2.3.1", | ||||
|     "typescript": "4.2.4" | ||||
|     "typescript": "4.5.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@excalidraw/eslint-config": "1.0.0", | ||||
|     "@excalidraw/prettier-config": "1.0.2", | ||||
|     "@types/chai": "4.2.22", | ||||
|     "@types/lodash.throttle": "4.1.6", | ||||
|     "@types/pako": "1.0.1", | ||||
|     "@types/resize-observer-browser": "0.1.5", | ||||
|     "@types/pako": "1.0.2", | ||||
|     "@types/resize-observer-browser": "0.1.6", | ||||
|     "chai": "4.3.4", | ||||
|     "eslint-config-prettier": "8.3.0", | ||||
|     "eslint-plugin-prettier": "3.3.1", | ||||
|     "firebase-tools": "9.9.0", | ||||
|     "husky": "4.3.8", | ||||
|     "firebase-tools": "9.22.0", | ||||
|     "husky": "7.0.4", | ||||
|     "jest-canvas-mock": "2.3.1", | ||||
|     "lint-staged": "10.5.4", | ||||
|     "lint-staged": "12.0.1", | ||||
|     "pepjs": "0.5.3", | ||||
|     "prettier": "2.2.1", | ||||
|     "prettier": "2.4.1", | ||||
|     "rewire": "5.0.0" | ||||
|   }, | ||||
|   "resolutions": { | ||||
|     "@typescript-eslint/typescript-estree": "5.3.0" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "node": ">=14.0.0" | ||||
|   }, | ||||
|   "homepage": ".", | ||||
|   "husky": { | ||||
|     "hooks": { | ||||
|       "pre-commit": "lint-staged" | ||||
|     } | ||||
|   }, | ||||
|   "jest": { | ||||
|     "transformIgnorePatterns": [ | ||||
|       "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)" | ||||
|       "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" | ||||
|     ], | ||||
|     "resetMocks": false | ||||
|   }, | ||||
| @@ -100,6 +100,7 @@ | ||||
|     "fix": "yarn fix:other && yarn fix:code", | ||||
|     "locales-coverage": "node scripts/build-locales-coverage.js", | ||||
|     "locales-coverage:description": "node scripts/locales-coverage-description.js", | ||||
|     "prepare": "husky install", | ||||
|     "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", | ||||
|     "start": "react-scripts start", | ||||
|     "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", | ||||
|   | ||||
| @@ -13,18 +13,6 @@ | ||||
|  | ||||
|     <meta name="theme-color" content="#000" /> | ||||
|  | ||||
|     <!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) --> | ||||
|     <meta | ||||
|       http-equiv="origin-trial" | ||||
|       content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ==" | ||||
|     /> | ||||
|  | ||||
|     <!-- File Handling (https://web.dev/file-handling/) --> | ||||
|     <meta | ||||
|       http-equiv="origin-trial" | ||||
|       content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9" | ||||
|     /> | ||||
|  | ||||
|     <!-- General tags --> | ||||
|     <meta | ||||
|       name="description" | ||||
|   | ||||
| @@ -26,7 +26,6 @@ | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "capture_links": "new-client", | ||||
|   "share_target": { | ||||
|     "action": "/web-share-target", | ||||
|     "method": "POST", | ||||
|   | ||||
| @@ -15,8 +15,8 @@ const publish = () => { | ||||
|     execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); | ||||
|     execSync(`yarn run build:umd`, { cwd: excalidrawDir }); | ||||
|     execSync(`yarn --cwd ${excalidrawDir} publish`); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,16 @@ | ||||
| const { readdirSync, writeFileSync } = require("fs"); | ||||
| const files = readdirSync(`${__dirname}/../src/locales`); | ||||
|  | ||||
| const flatten = (object) => | ||||
|   Object.keys(object).reduce( | ||||
|     (initial, current) => ({ ...initial, ...object[current] }), | ||||
|     {}, | ||||
|   ); | ||||
| const flatten = (object = {}, result = {}, extraKey = "") => { | ||||
|   for (const key in object) { | ||||
|     if (typeof object[key] !== "object") { | ||||
|       result[extraKey + key] = object[key]; | ||||
|     } else { | ||||
|       flatten(object[key], result, `${extraKey}${key}.`); | ||||
|     } | ||||
|   } | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| const locales = files.filter( | ||||
|   (file) => file !== "README.md" && file !== "percentages.json", | ||||
| @@ -19,10 +24,8 @@ for (let index = 0; index < locales.length; index++) { | ||||
|  | ||||
|   const allKeys = Object.keys(data); | ||||
|   const translatedKeys = allKeys.filter((item) => data[item] !== ""); | ||||
|  | ||||
|   const percentage = (100 * translatedKeys.length) / allKeys.length; | ||||
|  | ||||
|   percentages[currentLocale.replace(".json", "")] = parseInt(percentage); | ||||
|   const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length); | ||||
|   percentages[currentLocale.replace(".json", "")] = percentage; | ||||
| } | ||||
|  | ||||
| writeFileSync( | ||||
|   | ||||
| @@ -5,7 +5,9 @@ const THRESSHOLD = 85; | ||||
| const crowdinMap = { | ||||
|   "ar-SA": "en-ar", | ||||
|   "bg-BG": "en-bg", | ||||
|   "bn-BD": "en-bn", | ||||
|   "ca-ES": "en-ca", | ||||
|   "da-DK": "en-da", | ||||
|   "de-DE": "en-de", | ||||
|   "el-GR": "en-el", | ||||
|   "es-ES": "en-es", | ||||
| @@ -31,11 +33,14 @@ const crowdinMap = { | ||||
|   "pt-PT": "en-pt", | ||||
|   "ro-RO": "en-ro", | ||||
|   "ru-RU": "en-ru", | ||||
|   "si-LK": "en-silk", | ||||
|   "sk-SK": "en-sk", | ||||
|   "sv-SE": "en-sv", | ||||
|   "ta-IN": "en-ta", | ||||
|   "tr-TR": "en-tr", | ||||
|   "uk-UA": "en-uk", | ||||
|   "zh-CN": "en-zhcn", | ||||
|   "zh-HK": "en-zhhk", | ||||
|   "zh-TW": "en-zhtw", | ||||
|   "lv-LV": "en-lv", | ||||
|   "cs-CZ": "en-cs", | ||||
| @@ -45,7 +50,10 @@ const crowdinMap = { | ||||
| const flags = { | ||||
|   "ar-SA": "🇸🇦", | ||||
|   "bg-BG": "🇧🇬", | ||||
|   "bn-BD": "🇧🇩", | ||||
|   "ca-ES": "🏳", | ||||
|   "cs-CZ": "🇨🇿", | ||||
|   "da-DK": "🇩🇰", | ||||
|   "de-DE": "🇩🇪", | ||||
|   "el-GR": "🇬🇷", | ||||
|   "es-ES": "🇪🇸", | ||||
| @@ -59,7 +67,9 @@ const flags = { | ||||
|   "it-IT": "🇮🇹", | ||||
|   "ja-JP": "🇯🇵", | ||||
|   "kab-KAB": "🏳", | ||||
|   "kk-KZ": "🇰🇿", | ||||
|   "ko-KR": "🇰🇷", | ||||
|   "lv-LV": "🇱🇻", | ||||
|   "my-MM": "🇲🇲", | ||||
|   "nb-NO": "🇳🇴", | ||||
|   "nl-NL": "🇳🇱", | ||||
| @@ -71,21 +81,24 @@ const flags = { | ||||
|   "pt-PT": "🇵🇹", | ||||
|   "ro-RO": "🇷🇴", | ||||
|   "ru-RU": "🇷🇺", | ||||
|   "si-LK": "🇱🇰", | ||||
|   "sk-SK": "🇸🇰", | ||||
|   "sv-SE": "🇸🇪", | ||||
|   "ta-IN": "🇮🇳", | ||||
|   "tr-TR": "🇹🇷", | ||||
|   "uk-UA": "🇺🇦", | ||||
|   "zh-CN": "🇨🇳", | ||||
|   "zh-HK": "🇭🇰", | ||||
|   "zh-TW": "🇹🇼", | ||||
|   "lv-LV": "🇱🇻", | ||||
|   "cs-CZ": "🇨🇿", | ||||
|   "kk-KZ": "🇰🇿", | ||||
| }; | ||||
|  | ||||
| const languages = { | ||||
|   "ar-SA": "العربية", | ||||
|   "bg-BG": "Български", | ||||
|   "bn-BD": "Bengali", | ||||
|   "ca-ES": "Català", | ||||
|   "cs-CZ": "Česky", | ||||
|   "da-DK": "Dansk", | ||||
|   "de-DE": "Deutsch", | ||||
|   "el-GR": "Ελληνικά", | ||||
|   "es-ES": "Español", | ||||
| @@ -99,7 +112,9 @@ const languages = { | ||||
|   "it-IT": "Italiano", | ||||
|   "ja-JP": "日本語", | ||||
|   "kab-KAB": "Taqbaylit", | ||||
|   "kk-KZ": "Қазақ тілі", | ||||
|   "ko-KR": "한국어", | ||||
|   "lv-LV": "Latviešu", | ||||
|   "my-MM": "Burmese", | ||||
|   "nb-NO": "Norsk bokmål", | ||||
|   "nl-NL": "Nederlands", | ||||
| @@ -111,15 +126,15 @@ const languages = { | ||||
|   "pt-PT": "Português", | ||||
|   "ro-RO": "Română", | ||||
|   "ru-RU": "Русский", | ||||
|   "si-LK": "සිංහල", | ||||
|   "sk-SK": "Slovenčina", | ||||
|   "sv-SE": "Svenska", | ||||
|   "ta-IN": "Tamil", | ||||
|   "tr-TR": "Türkçe", | ||||
|   "uk-UA": "Українська", | ||||
|   "zh-CN": "简体中文", | ||||
|   "zh-HK": "繁體中文 (香港)", | ||||
|   "zh-TW": "繁體中文", | ||||
|   "lv-LV": "Latviešu", | ||||
|   "cs-CZ": "Česky", | ||||
|   "kk-KZ": "Қазақ тілі", | ||||
| }; | ||||
|  | ||||
| const percentages = fs.readFileSync( | ||||
|   | ||||
| @@ -25,8 +25,8 @@ const release = async (nextVersion) => { | ||||
|     ); | ||||
|     /* eslint-disable no-console */ | ||||
|     console.log("Done!"); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|     process.exit(1); | ||||
|   } | ||||
| }; | ||||
|   | ||||
| @@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => { | ||||
|       `git log --format=format:"%H" --grep=${commitMessage}`, | ||||
|     ); | ||||
|     return stdout; | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -2,22 +2,56 @@ import { register } from "./register"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
| import { getNonDeletedElements } from "../element"; | ||||
| import { deepCopyElement } from "../element/newElement"; | ||||
| import { randomId } from "../random"; | ||||
| import { t } from "../i18n"; | ||||
|  | ||||
| export const actionAddToLibrary = register({ | ||||
|   name: "addToLibrary", | ||||
|   perform: (elements, appState, _, app) => { | ||||
|     const selectedElements = getSelectedElements( | ||||
|       getNonDeletedElements(elements), | ||||
|       appState, | ||||
|     ); | ||||
|     if (elements.some((element) => element.type === "image")) { | ||||
|       return { | ||||
|         commitToHistory: false, | ||||
|         appState: { | ||||
|           ...appState, | ||||
|           errorMessage: "Support for adding images to the library coming soon!", | ||||
|         }, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     app.library.loadLibrary().then((items) => { | ||||
|       app.library.saveLibrary([ | ||||
|         ...items, | ||||
|         selectedElements.map(deepCopyElement), | ||||
|       ]); | ||||
|     }); | ||||
|     return false; | ||||
|     return app.library | ||||
|       .loadLibrary() | ||||
|       .then((items) => { | ||||
|         return app.library.saveLibrary([ | ||||
|           { | ||||
|             id: randomId(), | ||||
|             status: "unpublished", | ||||
|             elements: getSelectedElements( | ||||
|               getNonDeletedElements(elements), | ||||
|               appState, | ||||
|             ).map(deepCopyElement), | ||||
|             created: Date.now(), | ||||
|           }, | ||||
|           ...items, | ||||
|         ]); | ||||
|       }) | ||||
|       .then(() => { | ||||
|         return { | ||||
|           commitToHistory: false, | ||||
|           appState: { | ||||
|             ...appState, | ||||
|             toastMessage: t("toast.addedToLibrary"), | ||||
|           }, | ||||
|         }; | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         return { | ||||
|           commitToHistory: false, | ||||
|           appState: { | ||||
|             ...appState, | ||||
|             errorMessage: error.message, | ||||
|           }, | ||||
|         }; | ||||
|       }); | ||||
|   }, | ||||
|   contextItemLabel: "labels.addToLibrary", | ||||
| }); | ||||
|   | ||||
| @@ -56,7 +56,7 @@ export const actionCopyAsSvg = register({ | ||||
|       return { | ||||
|         commitToHistory: false, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       return { | ||||
|         appState: { | ||||
| @@ -106,7 +106,7 @@ export const actionCopyAsPng = register({ | ||||
|         }, | ||||
|         commitToHistory: false, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       return { | ||||
|         appState: { | ||||
|   | ||||
| @@ -110,10 +110,8 @@ export const actionDeleteSelected = register({ | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     let { | ||||
|       elements: nextElements, | ||||
|       appState: nextAppState, | ||||
|     } = deleteSelectedElements(elements, appState); | ||||
|     let { elements: nextElements, appState: nextAppState } = | ||||
|       deleteSelectedElements(elements, appState); | ||||
|     fixBindingsAfterDeletion( | ||||
|       nextElements, | ||||
|       elements.filter(({ id }) => appState.selectedElementIds[id]), | ||||
|   | ||||
| @@ -151,9 +151,11 @@ export const actionSaveToActiveFile = register({ | ||||
|             : null, | ||||
|         }, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       if (error?.name !== "AbortError") { | ||||
|         console.error(error); | ||||
|       } else { | ||||
|         console.warn(error); | ||||
|       } | ||||
|       return { commitToHistory: false }; | ||||
|     } | ||||
| @@ -181,9 +183,11 @@ export const actionSaveFileToDisk = register({ | ||||
|         app.files, | ||||
|       ); | ||||
|       return { commitToHistory: false, appState: { ...appState, fileHandle } }; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       if (error?.name !== "AbortError") { | ||||
|         console.error(error); | ||||
|       } else { | ||||
|         console.warn(error); | ||||
|       } | ||||
|       return { commitToHistory: false }; | ||||
|     } | ||||
| @@ -219,8 +223,9 @@ export const actionLoadScene = register({ | ||||
|         files, | ||||
|         commitToHistory: true, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       if (error?.name === "AbortError") { | ||||
|         console.warn(error); | ||||
|         return false; | ||||
|       } | ||||
|       return { | ||||
|   | ||||
| @@ -19,11 +19,8 @@ export const actionFinalize = register({ | ||||
|   name: "finalize", | ||||
|   perform: (elements, appState, _, { canvas, focusContainer }) => { | ||||
|     if (appState.editingLinearElement) { | ||||
|       const { | ||||
|         elementId, | ||||
|         startBindingElement, | ||||
|         endBindingElement, | ||||
|       } = appState.editingLinearElement; | ||||
|       const { elementId, startBindingElement, endBindingElement } = | ||||
|         appState.editingLinearElement; | ||||
|       const element = LinearElementEditor.getElement(elementId); | ||||
|  | ||||
|       if (element) { | ||||
|   | ||||
| @@ -99,9 +99,8 @@ export const actionGroup = register({ | ||||
|     // to the z order of the highest element in the layer stack | ||||
|     const elementsInGroup = getElementsInGroup(updatedElements, newGroupId); | ||||
|     const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; | ||||
|     const lastGroupElementIndex = updatedElements.lastIndexOf( | ||||
|       lastElementInGroup, | ||||
|     ); | ||||
|     const lastGroupElementIndex = | ||||
|       updatedElements.lastIndexOf(lastElementInGroup); | ||||
|     const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1); | ||||
|     const elementsBeforeGroup = updatedElements | ||||
|       .slice(0, lastGroupElementIndex) | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { | ||||
|   ArrowheadArrowIcon, | ||||
|   ArrowheadBarIcon, | ||||
|   ArrowheadDotIcon, | ||||
|   ArrowheadTriangleIcon, | ||||
|   ArrowheadNoneIcon, | ||||
|   EdgeRoundIcon, | ||||
|   EdgeSharpIcon, | ||||
| @@ -738,6 +739,14 @@ export const actionChangeArrowhead = register({ | ||||
|                 icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />, | ||||
|                 keyBinding: "r", | ||||
|               }, | ||||
|               { | ||||
|                 value: "triangle", | ||||
|                 text: t("labels.arrowhead_triangle"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} /> | ||||
|                 ), | ||||
|                 keyBinding: "t", | ||||
|               }, | ||||
|             ]} | ||||
|             value={getFormValue<Arrowhead | null>( | ||||
|               elements, | ||||
| @@ -780,6 +789,14 @@ export const actionChangeArrowhead = register({ | ||||
|                 keyBinding: "r", | ||||
|                 icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "triangle", | ||||
|                 text: t("labels.arrowhead_triangle"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} /> | ||||
|                 ), | ||||
|                 keyBinding: "t", | ||||
|               }, | ||||
|             ]} | ||||
|             value={getFormValue<Arrowhead | null>( | ||||
|               elements, | ||||
|   | ||||
							
								
								
									
										14
									
								
								src/align.ts
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/align.ts
									
									
									
									
									
								
							| @@ -1,13 +1,6 @@ | ||||
| import { ExcalidrawElement } from "./element/types"; | ||||
| import { newElementWith } from "./element/mutateElement"; | ||||
| import { getCommonBounds } from "./element"; | ||||
|  | ||||
| interface Box { | ||||
|   minX: number; | ||||
|   minY: number; | ||||
|   maxX: number; | ||||
|   maxY: number; | ||||
| } | ||||
| import { Box, getCommonBoundingBox } from "./element/bounds"; | ||||
|  | ||||
| export interface Alignment { | ||||
|   position: "start" | "center" | "end"; | ||||
| @@ -88,8 +81,3 @@ const calculateTranslation = ( | ||||
|       (groupBoundingBox[min] + groupBoundingBox[max]) / 2, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => { | ||||
|   const [minX, minY, maxX, maxY] = getCommonBounds(elements); | ||||
|   return { minX, minY, maxX, maxY }; | ||||
| }; | ||||
|   | ||||
| @@ -96,10 +96,9 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|     /** server (shareLink/collab/...) */ | ||||
|     server: boolean; | ||||
|   }, | ||||
|   T extends Record<keyof AppState, Values> | ||||
| >( | ||||
|   config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }, | ||||
| ) => config)({ | ||||
|   T extends Record<keyof AppState, Values>, | ||||
| >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => | ||||
|   config)({ | ||||
|   theme: { browser: true, export: false, server: false }, | ||||
|   collaborators: { browser: false, export: false, server: false }, | ||||
|   currentChartType: { browser: true, export: false, server: false }, | ||||
| @@ -172,7 +171,7 @@ const APP_STATE_STORAGE_CONF = (< | ||||
| }); | ||||
|  | ||||
| const _clearAppStateForStorage = < | ||||
|   ExportType extends "export" | "browser" | "server" | ||||
|   ExportType extends "export" | "browser" | "server", | ||||
| >( | ||||
|   appState: Partial<AppState>, | ||||
|   exportType: ExportType, | ||||
|   | ||||
| @@ -74,7 +74,7 @@ export const copyToClipboard = async ( | ||||
|   try { | ||||
|     PREFER_APP_CLIPBOARD = false; | ||||
|     await copyTextToSystemClipboard(json); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     PREFER_APP_CLIPBOARD = true; | ||||
|     console.error(error); | ||||
|   } | ||||
| @@ -87,7 +87,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => { | ||||
|  | ||||
|   try { | ||||
|     return JSON.parse(CLIPBOARD); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error(error); | ||||
|     return {}; | ||||
|   } | ||||
| @@ -179,7 +179,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => { | ||||
|       // not focused | ||||
|       await navigator.clipboard.writeText(text || ""); | ||||
|       copied = true; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|     } | ||||
|   } | ||||
| @@ -219,7 +219,7 @@ const copyTextViaExecCommand = (text: string) => { | ||||
|     textarea.setSelectionRange(0, textarea.value.length); | ||||
|  | ||||
|     success = document.execCommand("copy"); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error(error); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -43,7 +43,8 @@ import { | ||||
| import { | ||||
|   APP_NAME, | ||||
|   CURSOR_TYPE, | ||||
|   DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, | ||||
|   DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG, | ||||
|   DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER, | ||||
|   DEFAULT_UI_OPTIONS, | ||||
|   DEFAULT_VERTICAL_ALIGN, | ||||
|   DRAGGING_THRESHOLD, | ||||
| @@ -72,7 +73,7 @@ import { | ||||
| import { loadFromBlob } from "../data"; | ||||
| import { isValidLibrary } from "../data/json"; | ||||
| import Library from "../data/library"; | ||||
| import { restore, restoreElements } from "../data/restore"; | ||||
| import { restore, restoreElements, restoreLibraryItems } from "../data/restore"; | ||||
| import { | ||||
|   dragNewElement, | ||||
|   dragSelectedElements, | ||||
| @@ -113,7 +114,11 @@ import { | ||||
|   updateBoundElements, | ||||
| } from "../element/binding"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { bumpVersion, mutateElement } from "../element/mutateElement"; | ||||
| import { | ||||
|   bumpVersion, | ||||
|   mutateElement, | ||||
|   newElementWith, | ||||
| } from "../element/mutateElement"; | ||||
| import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; | ||||
| import { | ||||
|   isBindingElement, | ||||
| @@ -218,6 +223,7 @@ import { | ||||
| } from "../data/blob"; | ||||
| import { | ||||
|   getInitializedImageElements, | ||||
|   hasTransparentPixels, | ||||
|   loadHTMLImageElement, | ||||
|   normalizeSVG, | ||||
|   updateImageCache as _updateImageCache, | ||||
| @@ -332,7 +338,6 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         importLibrary: this.importLibraryFromUrl, | ||||
|         setToastMessage: this.setToastMessage, | ||||
|         id: this.id, | ||||
|         app: this, | ||||
|       } as const; | ||||
|       if (typeof excalidrawRef === "function") { | ||||
|         excalidrawRef(api); | ||||
| @@ -618,7 +623,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     this.onBlur(); | ||||
|   }; | ||||
|  | ||||
|   private disableEvent: EventHandlerNonNull = (event) => { | ||||
|   private disableEvent: EventListener = (event) => { | ||||
|     event.preventDefault(); | ||||
|   }; | ||||
|  | ||||
| @@ -652,17 +657,19 @@ class App extends React.Component<AppProps, AppState> { | ||||
|       if ( | ||||
|         token === this.id || | ||||
|         window.confirm( | ||||
|           t("alerts.confirmAddLibrary", { numShapes: json.library.length }), | ||||
|           t("alerts.confirmAddLibrary", { | ||||
|             numShapes: (json.libraryItems || json.library || []).length, | ||||
|           }), | ||||
|         ) | ||||
|       ) { | ||||
|         await this.library.importLibrary(blob); | ||||
|         await this.library.importLibrary(blob, "published"); | ||||
|         // hack to rerender the library items after import | ||||
|         if (this.state.isLibraryOpen) { | ||||
|           this.setState({ isLibraryOpen: false }); | ||||
|         } | ||||
|         this.setState({ isLibraryOpen: true }); | ||||
|       } | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       window.alert(t("alerts.errorLoadingLibrary")); | ||||
|       console.error(error); | ||||
|     } finally { | ||||
| @@ -729,7 +736,10 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     try { | ||||
|       initialData = (await this.props.initialData) || null; | ||||
|       if (initialData?.libraryItems) { | ||||
|         this.libraryItemsFromStorage = initialData.libraryItems; | ||||
|         this.libraryItemsFromStorage = restoreLibraryItems( | ||||
|           initialData.libraryItems, | ||||
|           "unpublished", | ||||
|         ) as LibraryItems; | ||||
|       } | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
| @@ -791,7 +801,8 @@ class App extends React.Component<AppProps, AppState> { | ||||
|   }; | ||||
|  | ||||
|   public async componentDidMount() { | ||||
|     this.excalidrawContainerValue.container = this.excalidrawContainerRef.current; | ||||
|     this.excalidrawContainerValue.container = | ||||
|       this.excalidrawContainerRef.current; | ||||
|  | ||||
|     if ( | ||||
|       process.env.NODE_ENV === ENV.TEST || | ||||
| @@ -833,10 +844,8 @@ class App extends React.Component<AppProps, AppState> { | ||||
|       this.resizeObserver = new ResizeObserver(() => { | ||||
|         // compute isMobile state | ||||
|         // --------------------------------------------------------------------- | ||||
|         const { | ||||
|           width, | ||||
|           height, | ||||
|         } = this.excalidrawContainerRef.current!.getBoundingClientRect(); | ||||
|         const { width, height } = | ||||
|           this.excalidrawContainerRef.current!.getBoundingClientRect(); | ||||
|         this.isMobile = | ||||
|           width < MQ_MAX_WIDTH_PORTRAIT || | ||||
|           (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE); | ||||
| @@ -1240,9 +1249,8 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     async (event: ClipboardEvent | null) => { | ||||
|       // #686 | ||||
|       const target = document.activeElement; | ||||
|       const isExcalidrawActive = this.excalidrawContainerRef.current?.contains( | ||||
|         target, | ||||
|       ); | ||||
|       const isExcalidrawActive = | ||||
|         this.excalidrawContainerRef.current?.contains(target); | ||||
|       if (!isExcalidrawActive) { | ||||
|         return; | ||||
|       } | ||||
| @@ -1293,8 +1301,8 @@ class App extends React.Component<AppProps, AppState> { | ||||
|           if ((await this.props.onPaste(data, event)) === false) { | ||||
|             return; | ||||
|           } | ||||
|         } catch (e) { | ||||
|           console.error(e); | ||||
|         } catch (error: any) { | ||||
|           console.error(error); | ||||
|         } | ||||
|       } | ||||
|       if (data.errorMessage) { | ||||
| @@ -1521,19 +1529,23 @@ class App extends React.Component<AppProps, AppState> { | ||||
|       }, new Map<FileId, BinaryFileData>()); | ||||
|  | ||||
|       this.files = { ...this.files, ...Object.fromEntries(filesMap) }; | ||||
|       this.addNewImagesToImageCache(); | ||||
|  | ||||
|       // bump versions for elements that reference added files so that | ||||
|       // we/host apps can detect the change | ||||
|       // we/host apps can detect the change, and invalidate the image & shape | ||||
|       // cache | ||||
|       this.scene.getElements().forEach((element) => { | ||||
|         if ( | ||||
|           isInitializedImageElement(element) && | ||||
|           filesMap.has(element.fileId) | ||||
|         ) { | ||||
|           this.imageCache.delete(element.fileId); | ||||
|           invalidateShapeForElement(element); | ||||
|           bumpVersion(element); | ||||
|         } | ||||
|       }); | ||||
|       this.scene.informMutation(); | ||||
|  | ||||
|       this.addNewImagesToImageCache(); | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
| @@ -2200,7 +2212,10 @@ class App extends React.Component<AppProps, AppState> { | ||||
|       })); | ||||
|       this.resetShouldCacheIgnoreZoomDebounced(); | ||||
|     } else { | ||||
|       gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null; | ||||
|       gesture.lastCenter = | ||||
|         gesture.initialDistance = | ||||
|         gesture.initialScale = | ||||
|           null; | ||||
|     } | ||||
|  | ||||
|     if (isHoldingSpace || isPanning || isDraggingScrollBar) { | ||||
| @@ -2509,13 +2524,11 @@ class App extends React.Component<AppProps, AppState> { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     const onPointerMove = this.onPointerMoveFromPointerDownHandler( | ||||
|       pointerDownState, | ||||
|     ); | ||||
|     const onPointerMove = | ||||
|       this.onPointerMoveFromPointerDownHandler(pointerDownState); | ||||
|  | ||||
|     const onPointerUp = this.onPointerUpFromPointerDownHandler( | ||||
|       pointerDownState, | ||||
|     ); | ||||
|     const onPointerUp = | ||||
|       this.onPointerUpFromPointerDownHandler(pointerDownState); | ||||
|  | ||||
|     const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState); | ||||
|     const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState); | ||||
| @@ -2720,10 +2733,11 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         allHitElements: [], | ||||
|         wasAddedToSelection: false, | ||||
|         hasBeenDuplicated: false, | ||||
|         hasHitCommonBoundingBoxOfSelectedElements: this.isHittingCommonBoundingBoxOfSelectedElements( | ||||
|           origin, | ||||
|           selectedElements, | ||||
|         ), | ||||
|         hasHitCommonBoundingBoxOfSelectedElements: | ||||
|           this.isHittingCommonBoundingBoxOfSelectedElements( | ||||
|             origin, | ||||
|             selectedElements, | ||||
|           ), | ||||
|       }, | ||||
|       drag: { | ||||
|         hasOccurred: false, | ||||
| @@ -2800,14 +2814,15 @@ class App extends React.Component<AppProps, AppState> { | ||||
|       const elements = this.scene.getElements(); | ||||
|       const selectedElements = getSelectedElements(elements, this.state); | ||||
|       if (selectedElements.length === 1 && !this.state.editingLinearElement) { | ||||
|         const elementWithTransformHandleType = getElementWithTransformHandleType( | ||||
|           elements, | ||||
|           this.state, | ||||
|           pointerDownState.origin.x, | ||||
|           pointerDownState.origin.y, | ||||
|           this.state.zoom, | ||||
|           event.pointerType, | ||||
|         ); | ||||
|         const elementWithTransformHandleType = | ||||
|           getElementWithTransformHandleType( | ||||
|             elements, | ||||
|             this.state, | ||||
|             pointerDownState.origin.x, | ||||
|             pointerDownState.origin.y, | ||||
|             this.state.zoom, | ||||
|             event.pointerType, | ||||
|           ); | ||||
|         if (elementWithTransformHandleType != null) { | ||||
|           this.setState({ | ||||
|             resizingElement: elementWithTransformHandleType.element, | ||||
| @@ -2883,9 +2898,10 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         ); | ||||
|  | ||||
|         const hitElement = pointerDownState.hit.element; | ||||
|         const someHitElementIsSelected = pointerDownState.hit.allHitElements.some( | ||||
|           (element) => this.isASelectedElement(element), | ||||
|         ); | ||||
|         const someHitElementIsSelected = | ||||
|           pointerDownState.hit.allHitElements.some((element) => | ||||
|             this.isASelectedElement(element), | ||||
|           ); | ||||
|         if ( | ||||
|           (hitElement === null || !someHitElementIsSelected) && | ||||
|           !event.shiftKey && | ||||
| @@ -3547,8 +3563,8 @@ class App extends React.Component<AppProps, AppState> { | ||||
|                   ? { | ||||
|                       // if using ctrl/cmd, select the hitElement only if we | ||||
|                       // haven't box-selected anything else | ||||
|                       [pointerDownState.hit.element | ||||
|                         .id]: !elementsWithinSelection.length, | ||||
|                       [pointerDownState.hit.element.id]: | ||||
|                         !elementsWithinSelection.length, | ||||
|                     } | ||||
|                   : null), | ||||
|               }, | ||||
| @@ -3696,7 +3712,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|               this.actionManager.executeAction(actionFinalize); | ||||
|             }, | ||||
|           ); | ||||
|         } catch (error) { | ||||
|         } catch (error: any) { | ||||
|           console.error(error); | ||||
|           this.scene.replaceAllElements( | ||||
|             this.scene | ||||
| @@ -3965,7 +3981,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|           await normalizeSVG(await imageFile.text()), | ||||
|           imageFile.name, | ||||
|         ); | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         console.warn(error); | ||||
|         throw new Error(t("errors.svgImageInsertError")); | ||||
|       } | ||||
| @@ -3987,20 +4003,30 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     const existingFileData = this.files[fileId]; | ||||
|     if (!existingFileData?.dataURL) { | ||||
|       try { | ||||
|         imageFile = await resizeImageFile( | ||||
|           imageFile, | ||||
|           DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, | ||||
|         ); | ||||
|       } catch (error) { | ||||
|         if (!(await hasTransparentPixels(imageFile))) { | ||||
|           const _imageFile = await resizeImageFile(imageFile, { | ||||
|             maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG, | ||||
|             outputType: MIME_TYPES.jpg, | ||||
|           }); | ||||
|           if (_imageFile.size > MAX_ALLOWED_FILE_BYTES) { | ||||
|             imageFile = await resizeImageFile(imageFile, { | ||||
|               maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER, | ||||
|               outputType: MIME_TYPES.jpg, | ||||
|             }); | ||||
|           } else { | ||||
|             imageFile = _imageFile; | ||||
|           } | ||||
|         } else { | ||||
|           imageFile = await resizeImageFile(imageFile, { | ||||
|             maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER, | ||||
|           }); | ||||
|         } | ||||
|       } catch (error: any) { | ||||
|         console.error("error trying to resing image file on insertion", error); | ||||
|       } | ||||
|  | ||||
|       if (imageFile.size > MAX_ALLOWED_FILE_BYTES) { | ||||
|         throw new Error( | ||||
|           t("errors.fileTooBig", { | ||||
|             maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`, | ||||
|           }), | ||||
|         ); | ||||
|         throw new Error(t("errors.fileTooBig")); | ||||
|       } | ||||
|     } | ||||
|  | ||||
| @@ -4052,7 +4078,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|             this.initializeImageDimensions(imageElement, true); | ||||
|           } | ||||
|           resolve(imageElement); | ||||
|         } catch (error) { | ||||
|         } catch (error: any) { | ||||
|           console.error(error); | ||||
|           reject(new Error(t("errors.imageInsertError"))); | ||||
|         } finally { | ||||
| @@ -4083,7 +4109,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         imageElement, | ||||
|         showCursorImagePreview, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       mutateElement(imageElement, { | ||||
|         isDeleted: true, | ||||
|       }); | ||||
| @@ -4099,7 +4125,9 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property | ||||
|     const cursorImageSizePx = 96; | ||||
|  | ||||
|     const imagePreview = await resizeImageFile(imageFile, cursorImageSizePx); | ||||
|     const imagePreview = await resizeImageFile(imageFile, { | ||||
|       maxWidthOrHeight: cursorImageSizePx, | ||||
|     }); | ||||
|  | ||||
|     let previewDataURL = await getDataURL(imagePreview); | ||||
|  | ||||
| @@ -4178,9 +4206,11 @@ class App extends React.Component<AppProps, AppState> { | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       if (error.name !== "AbortError") { | ||||
|         console.error(error); | ||||
|       } else { | ||||
|         console.warn(error); | ||||
|       } | ||||
|       this.setState( | ||||
|         { | ||||
| @@ -4263,16 +4293,24 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         if (updatedFiles.has(element.fileId)) { | ||||
|           invalidateShapeForElement(element); | ||||
|         } | ||||
|  | ||||
|         if (erroredFiles.has(element.fileId)) { | ||||
|           mutateElement( | ||||
|             element, | ||||
|             { status: "error" }, | ||||
|             /* informMutation */ false, | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     if (erroredFiles.size) { | ||||
|       this.scene.replaceAllElements( | ||||
|         this.scene.getElementsIncludingDeleted().map((element) => { | ||||
|           if ( | ||||
|             isInitializedImageElement(element) && | ||||
|             erroredFiles.has(element.fileId) | ||||
|           ) { | ||||
|             return newElementWith(element, { | ||||
|               status: "error", | ||||
|             }); | ||||
|           } | ||||
|           return element; | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return { updatedFiles, erroredFiles }; | ||||
|   }; | ||||
|  | ||||
| @@ -4414,7 +4452,9 @@ class App extends React.Component<AppProps, AppState> { | ||||
|                 // This will only work as of Chrome 86, | ||||
|                 // but can be safely ignored on older releases. | ||||
|                 const item = event.dataTransfer.items[0]; | ||||
|                 (file as any).handle = await (item as any).getAsFileSystemHandle(); | ||||
|                 (file as any).handle = await ( | ||||
|                   item as any | ||||
|                 ).getAsFileSystemHandle(); | ||||
|               } catch (error: any) { | ||||
|                 console.warn(error.name, error.message); | ||||
|               } | ||||
| @@ -4536,10 +4576,8 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     const type = element ? "element" : "canvas"; | ||||
|  | ||||
|     const container = this.excalidrawContainerRef.current!; | ||||
|     const { | ||||
|       top: offsetTop, | ||||
|       left: offsetLeft, | ||||
|     } = container.getBoundingClientRect(); | ||||
|     const { top: offsetTop, left: offsetLeft } = | ||||
|       container.getBoundingClientRect(); | ||||
|     const left = event.clientX - offsetLeft; | ||||
|     const top = event.clientY - offsetTop; | ||||
|  | ||||
|   | ||||
| @@ -7,15 +7,18 @@ import "./CheckboxItem.scss"; | ||||
| export const CheckboxItem: React.FC<{ | ||||
|   checked: boolean; | ||||
|   onChange: (checked: boolean) => void; | ||||
| }> = ({ children, checked, onChange }) => { | ||||
|   className?: string; | ||||
| }> = ({ children, checked, onChange, className }) => { | ||||
|   return ( | ||||
|     <div | ||||
|       className={clsx("Checkbox", { "is-checked": checked })} | ||||
|       className={clsx("Checkbox", className, { "is-checked": checked })} | ||||
|       onClick={(event) => { | ||||
|         onChange(!checked); | ||||
|         ((event.currentTarget as HTMLDivElement).querySelector( | ||||
|           ".Checkbox-box", | ||||
|         ) as HTMLButtonElement).focus(); | ||||
|         ( | ||||
|           (event.currentTarget as HTMLDivElement).querySelector( | ||||
|             ".Checkbox-box", | ||||
|           ) as HTMLButtonElement | ||||
|         ).focus(); | ||||
|       }} | ||||
|     > | ||||
|       <button className="Checkbox-box" role="checkbox" aria-checked={checked}> | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { useState } from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { useIsMobile } from "./App"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { trash } from "./icons"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import "./ClearCanvas.scss"; | ||||
| import ConfirmDialog from "./ConfirmDialog"; | ||||
|  | ||||
| const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { | ||||
|   const [showDialog, setShowDialog] = useState(false); | ||||
| @@ -26,39 +25,16 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { | ||||
|       /> | ||||
|  | ||||
|       {showDialog && ( | ||||
|         <Dialog | ||||
|           onCloseRequest={toggleDialog} | ||||
|         <ConfirmDialog | ||||
|           onConfirm={() => { | ||||
|             onConfirm(); | ||||
|             toggleDialog(); | ||||
|           }} | ||||
|           onCancel={toggleDialog} | ||||
|           title={t("clearCanvasDialog.title")} | ||||
|           className="clear-canvas" | ||||
|           small={true} | ||||
|         > | ||||
|           <> | ||||
|             <p className="clear-canvas__content"> {t("alerts.clearReset")}</p> | ||||
|             <div className="clear-canvas-buttons"> | ||||
|               <ToolButton | ||||
|                 type="button" | ||||
|                 title={t("buttons.clear")} | ||||
|                 aria-label={t("buttons.clear")} | ||||
|                 label={t("buttons.clear")} | ||||
|                 onClick={() => { | ||||
|                   onConfirm(); | ||||
|                   toggleDialog(); | ||||
|                 }} | ||||
|                 data-testid="confirm-clear-canvas-button" | ||||
|                 className="clear-canvas--confirm" | ||||
|               /> | ||||
|               <ToolButton | ||||
|                 type="button" | ||||
|                 title={t("buttons.cancel")} | ||||
|                 aria-label={t("buttons.cancel")} | ||||
|                 label={t("buttons.cancel")} | ||||
|                 onClick={toggleDialog} | ||||
|                 data-testid="cancel-clear-canvas-button" | ||||
|                 className="clear-canvas--cancel" | ||||
|               /> | ||||
|             </div> | ||||
|           </> | ||||
|         </Dialog> | ||||
|           <p className="clear-canvas__content"> {t("alerts.clearReset")}</p> | ||||
|         </ConfirmDialog> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -1,22 +1,21 @@ | ||||
| @import "../css/variables.module"; | ||||
| 
 | ||||
| .excalidraw { | ||||
|   .clear-canvas { | ||||
|   .confirm-dialog { | ||||
|     &-buttons { | ||||
|       display: flex; | ||||
|       padding: 0.2rem 0; | ||||
|       justify-content: flex-end; | ||||
|     } | ||||
|     .ToolIcon__icon { | ||||
|       min-width: 2.5rem; | ||||
|       width: auto; | ||||
|       font-size: 1rem; | ||||
|     } | ||||
| 
 | ||||
|       .ToolIcon__icon { | ||||
|         min-width: 2.5rem; | ||||
|         width: auto; | ||||
|         font-size: 1rem; | ||||
|       } | ||||
| 
 | ||||
|       .ToolIcon_type_button { | ||||
|         margin-left: 1.5rem; | ||||
|         padding: 0 0.5rem; | ||||
|       } | ||||
|     .ToolIcon_type_button { | ||||
|       margin-left: 0.8rem; | ||||
|       padding: 0 0.5rem; | ||||
|     } | ||||
| 
 | ||||
|     &__content { | ||||
| @@ -34,9 +33,5 @@ | ||||
|         color: $oc-white; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     &--cancel.ToolIcon_type_button { | ||||
|       background-color: $oc-gray-2; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										52
									
								
								src/components/ConfirmDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/components/ConfirmDialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| import { t } from "../i18n"; | ||||
| import { Dialog, DialogProps } from "./Dialog"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import "./ConfirmDialog.scss"; | ||||
|  | ||||
| interface Props extends Omit<DialogProps, "onCloseRequest"> { | ||||
|   onConfirm: () => void; | ||||
|   onCancel: () => void; | ||||
|   confirmText?: string; | ||||
|   cancelText?: string; | ||||
| } | ||||
| const ConfirmDialog = (props: Props) => { | ||||
|   const { | ||||
|     onConfirm, | ||||
|     onCancel, | ||||
|     children, | ||||
|     confirmText = t("buttons.confirm"), | ||||
|     cancelText = t("buttons.cancel"), | ||||
|     className = "", | ||||
|     ...rest | ||||
|   } = props; | ||||
|   return ( | ||||
|     <Dialog | ||||
|       onCloseRequest={onCancel} | ||||
|       small={true} | ||||
|       {...rest} | ||||
|       className={`confirm-dialog ${className}`} | ||||
|     > | ||||
|       {children} | ||||
|       <div className="confirm-dialog-buttons"> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={cancelText} | ||||
|           aria-label={cancelText} | ||||
|           label={cancelText} | ||||
|           onClick={onCancel} | ||||
|           className="confirm-dialog--cancel" | ||||
|         /> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={confirmText} | ||||
|           aria-label={confirmText} | ||||
|           label={confirmText} | ||||
|           onClick={onConfirm} | ||||
|           className="confirm-dialog--confirm" | ||||
|         /> | ||||
|       </div> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| export default ConfirmDialog; | ||||
| @@ -10,7 +10,7 @@ import { Island } from "./Island"; | ||||
| import { Modal } from "./Modal"; | ||||
| import { AppState } from "../types"; | ||||
|  | ||||
| export const Dialog = (props: { | ||||
| export interface DialogProps { | ||||
|   children: React.ReactNode; | ||||
|   className?: string; | ||||
|   small?: boolean; | ||||
| @@ -18,7 +18,10 @@ export const Dialog = (props: { | ||||
|   title: React.ReactNode; | ||||
|   autofocus?: boolean; | ||||
|   theme?: AppState["theme"]; | ||||
| }) => { | ||||
|   closeOnClickOutside?: boolean; | ||||
| } | ||||
|  | ||||
| export const Dialog = (props: DialogProps) => { | ||||
|   const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); | ||||
|   const [lastActiveElement] = useState(document.activeElement); | ||||
|   const { id } = useExcalidrawContainer(); | ||||
| @@ -81,6 +84,7 @@ export const Dialog = (props: { | ||||
|       maxWidth={props.small ? 550 : 800} | ||||
|       onCloseRequest={onClose} | ||||
|       theme={props.theme} | ||||
|       closeOnClickOutside={props.closeOnClickOutside} | ||||
|     > | ||||
|       <Island ref={setIslandNode}> | ||||
|         <h2 id={`${id}-dialog-title`} className="Dialog__title"> | ||||
|   | ||||
| @@ -11,14 +11,16 @@ import { | ||||
| } from "../element/typeChecks"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
|  | ||||
| interface Hint { | ||||
| interface HintViewerProps { | ||||
|   appState: AppState; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
|   isMobile: boolean; | ||||
| } | ||||
|  | ||||
| const getHints = ({ appState, elements }: Hint) => { | ||||
| const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { | ||||
|   const { elementType, isResizing, isRotating, lastPointerDownWith } = appState; | ||||
|   const multiMode = appState.multiElement !== null; | ||||
|  | ||||
|   if (elementType === "arrow" || elementType === "line") { | ||||
|     if (!multiMode) { | ||||
|       return t("hints.linearElement"); | ||||
| @@ -39,6 +41,7 @@ const getHints = ({ appState, elements }: Hint) => { | ||||
|   } | ||||
|  | ||||
|   const selectedElements = getSelectedElements(elements, appState); | ||||
|  | ||||
|   if ( | ||||
|     isResizing && | ||||
|     lastPointerDownWith === "mouse" && | ||||
| @@ -74,13 +77,22 @@ const getHints = ({ appState, elements }: Hint) => { | ||||
|     return t("hints.text_editing"); | ||||
|   } | ||||
|  | ||||
|   if (elementType === "selection" && !selectedElements.length && !isMobile) { | ||||
|     return t("hints.canvasPanning"); | ||||
|   } | ||||
|  | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| export const HintViewer = ({ appState, elements }: Hint) => { | ||||
| export const HintViewer = ({ | ||||
|   appState, | ||||
|   elements, | ||||
|   isMobile, | ||||
| }: HintViewerProps) => { | ||||
|   let hint = getHints({ | ||||
|     appState, | ||||
|     elements, | ||||
|     isMobile, | ||||
|   }); | ||||
|   if (!hint) { | ||||
|     return null; | ||||
|   | ||||
| @@ -90,7 +90,7 @@ | ||||
|   .picker-content { | ||||
|     padding: 0.5rem; | ||||
|     display: grid; | ||||
|     grid-auto-flow: column; | ||||
|     grid-template-columns: repeat(3, auto); | ||||
|     grid-gap: 0.5rem; | ||||
|     border-radius: 4px; | ||||
|     :root[dir="rtl"] & { | ||||
|   | ||||
| @@ -1,42 +1,6 @@ | ||||
| @import "open-color/open-color"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .layer-ui__library { | ||||
|     margin: auto; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|  | ||||
|     .layer-ui__library-header { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       width: 100%; | ||||
|       margin: 2px 0; | ||||
|  | ||||
|       button { | ||||
|         // 2px from the left to account for focus border of left-most button | ||||
|         margin: 0 2px; | ||||
|       } | ||||
|  | ||||
|       a { | ||||
|         margin-inline-start: auto; | ||||
|         // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra | ||||
|         padding-inline-end: 18px; | ||||
|         white-space: nowrap; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .layer-ui__library-message { | ||||
|     padding: 10px 20px; | ||||
|     max-width: 200px; | ||||
|   } | ||||
|  | ||||
|   .layer-ui__library-items { | ||||
|     max-height: 50vh; | ||||
|     overflow: auto; | ||||
|   } | ||||
|  | ||||
|   .layer-ui__wrapper { | ||||
|     z-index: var(--zIndex-layerUI); | ||||
|  | ||||
|   | ||||
| @@ -1,29 +1,15 @@ | ||||
| import clsx from "clsx"; | ||||
| import React, { | ||||
|   RefObject, | ||||
|   useCallback, | ||||
|   useEffect, | ||||
|   useRef, | ||||
|   useState, | ||||
| } from "react"; | ||||
| import React, { useCallback } from "react"; | ||||
| import { ActionManager } from "../actions/manager"; | ||||
| import { CLASSES } from "../constants"; | ||||
| import { exportCanvas } from "../data"; | ||||
| import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; | ||||
| import { isTextElement, showSelectedShapeActions } from "../element"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { Language, t } from "../i18n"; | ||||
| import { useIsMobile } from "../components/App"; | ||||
| import { calculateScrollCenter, getSelectedElements } from "../scene"; | ||||
| import { ExportType } from "../scene/types"; | ||||
| import { | ||||
|   AppProps, | ||||
|   AppState, | ||||
|   ExcalidrawProps, | ||||
|   BinaryFiles, | ||||
|   LibraryItem, | ||||
|   LibraryItems, | ||||
| } from "../types"; | ||||
| import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; | ||||
| import { muteFSAbortError } from "../utils"; | ||||
| import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; | ||||
| import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; | ||||
| @@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog"; | ||||
| import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; | ||||
| import { FixedSideContainer } from "./FixedSideContainer"; | ||||
| import { HintViewer } from "./HintViewer"; | ||||
| import { exportFile, load, trash } from "./icons"; | ||||
| import { Island } from "./Island"; | ||||
| import "./LayerUI.scss"; | ||||
| import { LibraryUnit } from "./LibraryUnit"; | ||||
| import { LoadingMessage } from "./LoadingMessage"; | ||||
| import { LockButton } from "./LockButton"; | ||||
| import { MobileMenu } from "./MobileMenu"; | ||||
| @@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog"; | ||||
| import { Section } from "./Section"; | ||||
| import { HelpDialog } from "./HelpDialog"; | ||||
| import Stack from "./Stack"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { Tooltip } from "./Tooltip"; | ||||
| import { UserList } from "./UserList"; | ||||
| import Library from "../data/library"; | ||||
| import { JSONExportDialog } from "./JSONExportDialog"; | ||||
| import { LibraryButton } from "./LibraryButton"; | ||||
| import { isImageFileHandle } from "../data/blob"; | ||||
| import { LibraryMenu } from "./LibraryMenu"; | ||||
|  | ||||
| interface LayerUIProps { | ||||
|   actionManager: ActionManager; | ||||
| @@ -81,302 +65,6 @@ interface LayerUIProps { | ||||
|   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; | ||||
| } | ||||
|  | ||||
| const useOnClickOutside = ( | ||||
|   ref: RefObject<HTMLElement>, | ||||
|   cb: (event: MouseEvent) => void, | ||||
| ) => { | ||||
|   useEffect(() => { | ||||
|     const listener = (event: MouseEvent) => { | ||||
|       if (!ref.current) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         event.target instanceof Element && | ||||
|         (ref.current.contains(event.target) || | ||||
|           !document.body.contains(event.target)) | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       cb(event); | ||||
|     }; | ||||
|     document.addEventListener("pointerdown", listener, false); | ||||
|  | ||||
|     return () => { | ||||
|       document.removeEventListener("pointerdown", listener); | ||||
|     }; | ||||
|   }, [ref, cb]); | ||||
| }; | ||||
|  | ||||
| const LibraryMenuItems = ({ | ||||
|   libraryItems, | ||||
|   onRemoveFromLibrary, | ||||
|   onAddToLibrary, | ||||
|   onInsertShape, | ||||
|   pendingElements, | ||||
|   theme, | ||||
|   setAppState, | ||||
|   setLibraryItems, | ||||
|   libraryReturnUrl, | ||||
|   focusContainer, | ||||
|   library, | ||||
|   files, | ||||
|   id, | ||||
| }: { | ||||
|   libraryItems: LibraryItems; | ||||
|   pendingElements: LibraryItem; | ||||
|   onRemoveFromLibrary: (index: number) => void; | ||||
|   onInsertShape: (elements: LibraryItem) => void; | ||||
|   onAddToLibrary: (elements: LibraryItem) => void; | ||||
|   theme: AppState["theme"]; | ||||
|   files: BinaryFiles; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   setLibraryItems: (library: LibraryItems) => void; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   focusContainer: () => void; | ||||
|   library: Library; | ||||
|   id: string; | ||||
| }) => { | ||||
|   const isMobile = useIsMobile(); | ||||
|   const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0); | ||||
|   const CELLS_PER_ROW = isMobile ? 4 : 6; | ||||
|   const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW)); | ||||
|   const rows = []; | ||||
|   let addedPendingElements = false; | ||||
|  | ||||
|   const referrer = | ||||
|     libraryReturnUrl || window.location.origin + window.location.pathname; | ||||
|  | ||||
|   rows.push( | ||||
|     <div className="layer-ui__library-header" key="library-header"> | ||||
|       <ToolButton | ||||
|         key="import" | ||||
|         type="button" | ||||
|         title={t("buttons.load")} | ||||
|         aria-label={t("buttons.load")} | ||||
|         icon={load} | ||||
|         onClick={() => { | ||||
|           importLibraryFromJSON(library) | ||||
|             .then(() => { | ||||
|               // Close and then open to get the libraries updated | ||||
|               setAppState({ isLibraryOpen: false }); | ||||
|               setAppState({ isLibraryOpen: true }); | ||||
|             }) | ||||
|             .catch(muteFSAbortError) | ||||
|             .catch((error) => { | ||||
|               setAppState({ errorMessage: error.message }); | ||||
|             }); | ||||
|         }} | ||||
|       /> | ||||
|       {!!libraryItems.length && ( | ||||
|         <> | ||||
|           <ToolButton | ||||
|             key="export" | ||||
|             type="button" | ||||
|             title={t("buttons.export")} | ||||
|             aria-label={t("buttons.export")} | ||||
|             icon={exportFile} | ||||
|             onClick={() => { | ||||
|               saveLibraryAsJSON(library) | ||||
|                 .catch(muteFSAbortError) | ||||
|                 .catch((error) => { | ||||
|                   setAppState({ errorMessage: error.message }); | ||||
|                 }); | ||||
|             }} | ||||
|           /> | ||||
|           <ToolButton | ||||
|             key="reset" | ||||
|             type="button" | ||||
|             title={t("buttons.resetLibrary")} | ||||
|             aria-label={t("buttons.resetLibrary")} | ||||
|             icon={trash} | ||||
|             onClick={() => { | ||||
|               if (window.confirm(t("alerts.resetLibrary"))) { | ||||
|                 library.resetLibrary(); | ||||
|                 setLibraryItems([]); | ||||
|                 focusContainer(); | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         </> | ||||
|       )} | ||||
|       <a | ||||
|         href={`https://libraries.excalidraw.com?target=${ | ||||
|           window.name || "_blank" | ||||
|         }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`} | ||||
|         target="_excalidraw_libraries" | ||||
|       > | ||||
|         {t("labels.libraries")} | ||||
|       </a> | ||||
|     </div>, | ||||
|   ); | ||||
|  | ||||
|   for (let row = 0; row < numRows; row++) { | ||||
|     const y = CELLS_PER_ROW * row; | ||||
|     const children = []; | ||||
|     for (let x = 0; x < CELLS_PER_ROW; x++) { | ||||
|       const shouldAddPendingElements: boolean = | ||||
|         pendingElements.length > 0 && | ||||
|         !addedPendingElements && | ||||
|         y + x >= libraryItems.length; | ||||
|       addedPendingElements = addedPendingElements || shouldAddPendingElements; | ||||
|  | ||||
|       children.push( | ||||
|         <Stack.Col key={x}> | ||||
|           <LibraryUnit | ||||
|             elements={libraryItems[y + x]} | ||||
|             files={files} | ||||
|             pendingElements={ | ||||
|               shouldAddPendingElements ? pendingElements : undefined | ||||
|             } | ||||
|             onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)} | ||||
|             onClick={ | ||||
|               shouldAddPendingElements | ||||
|                 ? onAddToLibrary.bind(null, pendingElements) | ||||
|                 : onInsertShape.bind(null, libraryItems[y + x]) | ||||
|             } | ||||
|           /> | ||||
|         </Stack.Col>, | ||||
|       ); | ||||
|     } | ||||
|     rows.push( | ||||
|       <Stack.Row align="center" gap={1} key={row}> | ||||
|         {children} | ||||
|       </Stack.Row>, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Stack.Col align="start" gap={1} className="layer-ui__library-items"> | ||||
|       {rows} | ||||
|     </Stack.Col> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const LibraryMenu = ({ | ||||
|   onClickOutside, | ||||
|   onInsertShape, | ||||
|   pendingElements, | ||||
|   onAddToLibrary, | ||||
|   theme, | ||||
|   setAppState, | ||||
|   files, | ||||
|   libraryReturnUrl, | ||||
|   focusContainer, | ||||
|   library, | ||||
|   id, | ||||
| }: { | ||||
|   pendingElements: LibraryItem; | ||||
|   onClickOutside: (event: MouseEvent) => void; | ||||
|   onInsertShape: (elements: LibraryItem) => void; | ||||
|   onAddToLibrary: () => void; | ||||
|   theme: AppState["theme"]; | ||||
|   files: BinaryFiles; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   focusContainer: () => void; | ||||
|   library: Library; | ||||
|   id: string; | ||||
| }) => { | ||||
|   const ref = useRef<HTMLDivElement | null>(null); | ||||
|   useOnClickOutside(ref, (event) => { | ||||
|     // If click on the library icon, do nothing. | ||||
|     if ((event.target as Element).closest(".ToolIcon_type_button__library")) { | ||||
|       return; | ||||
|     } | ||||
|     onClickOutside(event); | ||||
|   }); | ||||
|  | ||||
|   const [libraryItems, setLibraryItems] = useState<LibraryItems>([]); | ||||
|  | ||||
|   const [loadingState, setIsLoading] = useState< | ||||
|     "preloading" | "loading" | "ready" | ||||
|   >("preloading"); | ||||
|  | ||||
|   const loadingTimerRef = useRef<number | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     Promise.race([ | ||||
|       new Promise((resolve) => { | ||||
|         loadingTimerRef.current = window.setTimeout(() => { | ||||
|           resolve("loading"); | ||||
|         }, 100); | ||||
|       }), | ||||
|       library.loadLibrary().then((items) => { | ||||
|         setLibraryItems(items); | ||||
|         setIsLoading("ready"); | ||||
|       }), | ||||
|     ]).then((data) => { | ||||
|       if (data === "loading") { | ||||
|         setIsLoading("loading"); | ||||
|       } | ||||
|     }); | ||||
|     return () => { | ||||
|       clearTimeout(loadingTimerRef.current!); | ||||
|     }; | ||||
|   }, [library]); | ||||
|  | ||||
|   const removeFromLibrary = useCallback( | ||||
|     async (indexToRemove) => { | ||||
|       const items = await library.loadLibrary(); | ||||
|       const nextItems = items.filter((_, index) => index !== indexToRemove); | ||||
|       library.saveLibrary(nextItems).catch((error) => { | ||||
|         setLibraryItems(items); | ||||
|         setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); | ||||
|       }); | ||||
|       setLibraryItems(nextItems); | ||||
|     }, | ||||
|     [library, setAppState], | ||||
|   ); | ||||
|  | ||||
|   const addToLibrary = useCallback( | ||||
|     async (elements: LibraryItem) => { | ||||
|       if (elements.some((element) => element.type === "image")) { | ||||
|         return setAppState({ | ||||
|           errorMessage: "Support for adding images to the library coming soon!", | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       const items = await library.loadLibrary(); | ||||
|       const nextItems = [...items, elements]; | ||||
|       onAddToLibrary(); | ||||
|       library.saveLibrary(nextItems).catch((error) => { | ||||
|         setLibraryItems(items); | ||||
|         setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); | ||||
|       }); | ||||
|       setLibraryItems(nextItems); | ||||
|     }, | ||||
|     [onAddToLibrary, library, setAppState], | ||||
|   ); | ||||
|  | ||||
|   return loadingState === "preloading" ? null : ( | ||||
|     <Island padding={1} ref={ref} className="layer-ui__library"> | ||||
|       {loadingState === "loading" ? ( | ||||
|         <div className="layer-ui__library-message"> | ||||
|           {t("labels.libraryLoadingMessage")} | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <LibraryMenuItems | ||||
|           libraryItems={libraryItems} | ||||
|           onRemoveFromLibrary={removeFromLibrary} | ||||
|           onAddToLibrary={addToLibrary} | ||||
|           onInsertShape={onInsertShape} | ||||
|           pendingElements={pendingElements} | ||||
|           setAppState={setAppState} | ||||
|           setLibraryItems={setLibraryItems} | ||||
|           libraryReturnUrl={libraryReturnUrl} | ||||
|           focusContainer={focusContainer} | ||||
|           library={library} | ||||
|           theme={theme} | ||||
|           files={files} | ||||
|           id={id} | ||||
|         /> | ||||
|       )} | ||||
|     </Island> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const LayerUI = ({ | ||||
|   actionManager, | ||||
|   appState, | ||||
| @@ -426,34 +114,34 @@ const LayerUI = ({ | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const createExporter = (type: ExportType): ExportCB => async ( | ||||
|       exportedElements, | ||||
|     ) => { | ||||
|       const fileHandle = await exportCanvas( | ||||
|         type, | ||||
|         exportedElements, | ||||
|         appState, | ||||
|         files, | ||||
|         { | ||||
|           exportBackground: appState.exportBackground, | ||||
|           name: appState.name, | ||||
|           viewBackgroundColor: appState.viewBackgroundColor, | ||||
|         }, | ||||
|       ) | ||||
|         .catch(muteFSAbortError) | ||||
|         .catch((error) => { | ||||
|           console.error(error); | ||||
|           setAppState({ errorMessage: error.message }); | ||||
|         }); | ||||
|     const createExporter = | ||||
|       (type: ExportType): ExportCB => | ||||
|       async (exportedElements) => { | ||||
|         const fileHandle = await exportCanvas( | ||||
|           type, | ||||
|           exportedElements, | ||||
|           appState, | ||||
|           files, | ||||
|           { | ||||
|             exportBackground: appState.exportBackground, | ||||
|             name: appState.name, | ||||
|             viewBackgroundColor: appState.viewBackgroundColor, | ||||
|           }, | ||||
|         ) | ||||
|           .catch(muteFSAbortError) | ||||
|           .catch((error) => { | ||||
|             console.error(error); | ||||
|             setAppState({ errorMessage: error.message }); | ||||
|           }); | ||||
|  | ||||
|       if ( | ||||
|         appState.exportEmbedScene && | ||||
|         fileHandle && | ||||
|         isImageFileHandle(fileHandle) | ||||
|       ) { | ||||
|         setAppState({ fileHandle }); | ||||
|       } | ||||
|     }; | ||||
|         if ( | ||||
|           appState.exportEmbedScene && | ||||
|           fileHandle && | ||||
|           isImageFileHandle(fileHandle) | ||||
|         ) { | ||||
|           setAppState({ fileHandle }); | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|     return ( | ||||
|       <ImageExportDialog | ||||
| @@ -561,12 +249,15 @@ const LayerUI = ({ | ||||
|     </Section> | ||||
|   ); | ||||
|  | ||||
|   const closeLibrary = useCallback( | ||||
|     (event) => { | ||||
|       setAppState({ isLibraryOpen: false }); | ||||
|     }, | ||||
|     [setAppState], | ||||
|   ); | ||||
|   const closeLibrary = useCallback(() => { | ||||
|     const isDialogOpen = !!document.querySelector(".Dialog"); | ||||
|  | ||||
|     // Prevent closing if any dialog is open | ||||
|     if (isDialogOpen) { | ||||
|       return; | ||||
|     } | ||||
|     setAppState({ isLibraryOpen: false }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   const deselectItems = useCallback(() => { | ||||
|     setAppState({ | ||||
| @@ -578,7 +269,7 @@ const LayerUI = ({ | ||||
|   const libraryMenu = appState.isLibraryOpen ? ( | ||||
|     <LibraryMenu | ||||
|       pendingElements={getSelectedElements(elements, appState)} | ||||
|       onClickOutside={closeLibrary} | ||||
|       onClose={closeLibrary} | ||||
|       onInsertShape={onInsertElements} | ||||
|       onAddToLibrary={deselectItems} | ||||
|       setAppState={setAppState} | ||||
| @@ -588,6 +279,7 @@ const LayerUI = ({ | ||||
|       theme={appState.theme} | ||||
|       files={files} | ||||
|       id={id} | ||||
|       appState={appState} | ||||
|     /> | ||||
|   ) : null; | ||||
|  | ||||
| @@ -624,7 +316,11 @@ const LayerUI = ({ | ||||
|                       padding={1} | ||||
|                       className={clsx({ "zen-mode": zenModeEnabled })} | ||||
|                     > | ||||
|                       <HintViewer appState={appState} elements={elements} /> | ||||
|                       <HintViewer | ||||
|                         appState={appState} | ||||
|                         elements={elements} | ||||
|                         isMobile={isMobile} | ||||
|                       /> | ||||
|                       {heading} | ||||
|                       <Stack.Row gap={1}> | ||||
|                         <ShapesSwitcher | ||||
| @@ -705,7 +401,8 @@ const LayerUI = ({ | ||||
|               {!viewModeEnabled && ( | ||||
|                 <div | ||||
|                   className={clsx("undo-redo-buttons zen-mode-transition", { | ||||
|                     "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled, | ||||
|                     "layer-ui__wrapper__footer-left--transition-bottom": | ||||
|                       zenModeEnabled, | ||||
|                   })} | ||||
|                 > | ||||
|                   {actionManager.renderAction("undo", { size: "small" })} | ||||
| @@ -719,7 +416,8 @@ const LayerUI = ({ | ||||
|           className={clsx( | ||||
|             "layer-ui__wrapper__footer-center zen-mode-transition", | ||||
|             { | ||||
|               "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled, | ||||
|               "layer-ui__wrapper__footer-left--transition-bottom": | ||||
|                 zenModeEnabled, | ||||
|             }, | ||||
|           )} | ||||
|         > | ||||
| @@ -845,6 +543,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { | ||||
|     prev.renderCustomFooter === next.renderCustomFooter && | ||||
|     prev.langCode === next.langCode && | ||||
|     prev.elements === next.elements && | ||||
|     prev.files === next.files && | ||||
|     keys.every((key) => prevAppState[key] === nextAppState[key]) | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										55
									
								
								src/components/LibraryMenu.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/components/LibraryMenu.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| @import "open-color/open-color"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .layer-ui__library { | ||||
|     margin: auto; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|  | ||||
|     .layer-ui__library-header { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       width: 100%; | ||||
|       margin: 2px 0; | ||||
|  | ||||
|       button { | ||||
|         // 2px from the left to account for focus border of left-most button | ||||
|         margin: 0 2px; | ||||
|       } | ||||
|  | ||||
|       a { | ||||
|         margin-inline-start: auto; | ||||
|         // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra | ||||
|         padding-inline-end: 18px; | ||||
|         white-space: nowrap; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .layer-ui__library-message { | ||||
|     padding: 10px 20px; | ||||
|     max-width: 200px; | ||||
|   } | ||||
|  | ||||
|   .publish-library-success { | ||||
|     .Dialog__content { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     &-close.ToolIcon_type_button { | ||||
|       background-color: $oc-blue-6; | ||||
|       align-self: flex-end; | ||||
|       &:hover { | ||||
|         background-color: $oc-blue-8; | ||||
|       } | ||||
|       .ToolIcon__icon { | ||||
|         width: auto; | ||||
|         font-size: 1rem; | ||||
|         color: $oc-white; | ||||
|         padding: 0 0.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										287
									
								
								src/components/LibraryMenu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								src/components/LibraryMenu.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,287 @@ | ||||
| import { useRef, useState, useEffect, useCallback, RefObject } from "react"; | ||||
| import Library from "../data/library"; | ||||
| import { t } from "../i18n"; | ||||
| import { randomId } from "../random"; | ||||
| import { | ||||
|   LibraryItems, | ||||
|   LibraryItem, | ||||
|   AppState, | ||||
|   BinaryFiles, | ||||
|   ExcalidrawProps, | ||||
| } from "../types"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { Island } from "./Island"; | ||||
| import PublishLibrary from "./PublishLibrary"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import "./LibraryMenu.scss"; | ||||
| import LibraryMenuItems from "./LibraryMenuItems"; | ||||
| import { EVENT } from "../constants"; | ||||
| import { KEYS } from "../keys"; | ||||
|  | ||||
| const useOnClickOutside = ( | ||||
|   ref: RefObject<HTMLElement>, | ||||
|   cb: (event: MouseEvent) => void, | ||||
| ) => { | ||||
|   useEffect(() => { | ||||
|     const listener = (event: MouseEvent) => { | ||||
|       if (!ref.current) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         event.target instanceof Element && | ||||
|         (ref.current.contains(event.target) || | ||||
|           !document.body.contains(event.target)) | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       cb(event); | ||||
|     }; | ||||
|     document.addEventListener("pointerdown", listener, false); | ||||
|  | ||||
|     return () => { | ||||
|       document.removeEventListener("pointerdown", listener); | ||||
|     }; | ||||
|   }, [ref, cb]); | ||||
| }; | ||||
|  | ||||
| const getSelectedItems = ( | ||||
|   libraryItems: LibraryItems, | ||||
|   selectedItems: LibraryItem["id"][], | ||||
| ) => libraryItems.filter((item) => selectedItems.includes(item.id)); | ||||
|  | ||||
| export const LibraryMenu = ({ | ||||
|   onClose, | ||||
|   onInsertShape, | ||||
|   pendingElements, | ||||
|   onAddToLibrary, | ||||
|   theme, | ||||
|   setAppState, | ||||
|   files, | ||||
|   libraryReturnUrl, | ||||
|   focusContainer, | ||||
|   library, | ||||
|   id, | ||||
|   appState, | ||||
| }: { | ||||
|   pendingElements: LibraryItem["elements"]; | ||||
|   onClose: () => void; | ||||
|   onInsertShape: (elements: LibraryItem["elements"]) => void; | ||||
|   onAddToLibrary: () => void; | ||||
|   theme: AppState["theme"]; | ||||
|   files: BinaryFiles; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   focusContainer: () => void; | ||||
|   library: Library; | ||||
|   id: string; | ||||
|   appState: AppState; | ||||
| }) => { | ||||
|   const ref = useRef<HTMLDivElement | null>(null); | ||||
|  | ||||
|   useOnClickOutside(ref, (event) => { | ||||
|     // If click on the library icon, do nothing. | ||||
|     if ((event.target as Element).closest(".ToolIcon__library")) { | ||||
|       return; | ||||
|     } | ||||
|     onClose(); | ||||
|   }); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (event: KeyboardEvent) => { | ||||
|       if (event.key === KEYS.ESCAPE) { | ||||
|         onClose(); | ||||
|       } | ||||
|     }; | ||||
|     document.addEventListener(EVENT.KEYDOWN, handleKeyDown); | ||||
|     return () => { | ||||
|       document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); | ||||
|     }; | ||||
|   }, [onClose]); | ||||
|  | ||||
|   const [libraryItems, setLibraryItems] = useState<LibraryItems>([]); | ||||
|  | ||||
|   const [loadingState, setIsLoading] = useState< | ||||
|     "preloading" | "loading" | "ready" | ||||
|   >("preloading"); | ||||
|   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); | ||||
|   const [showPublishLibraryDialog, setShowPublishLibraryDialog] = | ||||
|     useState(false); | ||||
|   const [publishLibSuccess, setPublishLibSuccess] = useState<null | { | ||||
|     url: string; | ||||
|     authorName: string; | ||||
|   }>(null); | ||||
|   const loadingTimerRef = useRef<number | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     Promise.race([ | ||||
|       new Promise((resolve) => { | ||||
|         loadingTimerRef.current = window.setTimeout(() => { | ||||
|           resolve("loading"); | ||||
|         }, 100); | ||||
|       }), | ||||
|       library.loadLibrary().then((items) => { | ||||
|         setLibraryItems(items); | ||||
|         setIsLoading("ready"); | ||||
|       }), | ||||
|     ]).then((data) => { | ||||
|       if (data === "loading") { | ||||
|         setIsLoading("loading"); | ||||
|       } | ||||
|     }); | ||||
|     return () => { | ||||
|       clearTimeout(loadingTimerRef.current!); | ||||
|     }; | ||||
|   }, [library]); | ||||
|  | ||||
|   const removeFromLibrary = useCallback(async () => { | ||||
|     const items = await library.loadLibrary(); | ||||
|  | ||||
|     const nextItems = items.filter((item) => !selectedItems.includes(item.id)); | ||||
|     library.saveLibrary(nextItems).catch((error) => { | ||||
|       setLibraryItems(items); | ||||
|       setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); | ||||
|     }); | ||||
|     setSelectedItems([]); | ||||
|     setLibraryItems(nextItems); | ||||
|   }, [library, setAppState, selectedItems, setSelectedItems]); | ||||
|  | ||||
|   const resetLibrary = useCallback(() => { | ||||
|     library.resetLibrary(); | ||||
|     setLibraryItems([]); | ||||
|     focusContainer(); | ||||
|   }, [library, focusContainer]); | ||||
|  | ||||
|   const addToLibrary = useCallback( | ||||
|     async (elements: LibraryItem["elements"]) => { | ||||
|       if (elements.some((element) => element.type === "image")) { | ||||
|         return setAppState({ | ||||
|           errorMessage: "Support for adding images to the library coming soon!", | ||||
|         }); | ||||
|       } | ||||
|       const items = await library.loadLibrary(); | ||||
|       const nextItems: LibraryItems = [ | ||||
|         { | ||||
|           status: "unpublished", | ||||
|           elements, | ||||
|           id: randomId(), | ||||
|           created: Date.now(), | ||||
|         }, | ||||
|         ...items, | ||||
|       ]; | ||||
|       onAddToLibrary(); | ||||
|       library.saveLibrary(nextItems).catch((error) => { | ||||
|         setLibraryItems(items); | ||||
|         setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); | ||||
|       }); | ||||
|       setLibraryItems(nextItems); | ||||
|     }, | ||||
|     [onAddToLibrary, library, setAppState], | ||||
|   ); | ||||
|  | ||||
|   const renderPublishSuccess = useCallback(() => { | ||||
|     return ( | ||||
|       <Dialog | ||||
|         onCloseRequest={() => setPublishLibSuccess(null)} | ||||
|         title={t("publishSuccessDialog.title")} | ||||
|         className="publish-library-success" | ||||
|         small={true} | ||||
|       > | ||||
|         <p> | ||||
|           {t("publishSuccessDialog.content", { | ||||
|             authorName: publishLibSuccess!.authorName, | ||||
|           })}{" "} | ||||
|           <a | ||||
|             href={publishLibSuccess?.url} | ||||
|             target="_blank" | ||||
|             rel="noopener noreferrer" | ||||
|           > | ||||
|             {t("publishSuccessDialog.link")} | ||||
|           </a> | ||||
|         </p> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={t("buttons.close")} | ||||
|           aria-label={t("buttons.close")} | ||||
|           label={t("buttons.close")} | ||||
|           onClick={() => setPublishLibSuccess(null)} | ||||
|           data-testid="publish-library-success-close" | ||||
|           className="publish-library-success-close" | ||||
|         /> | ||||
|       </Dialog> | ||||
|     ); | ||||
|   }, [setPublishLibSuccess, publishLibSuccess]); | ||||
|  | ||||
|   const onPublishLibSuccess = useCallback( | ||||
|     (data) => { | ||||
|       setShowPublishLibraryDialog(false); | ||||
|       setPublishLibSuccess({ url: data.url, authorName: data.authorName }); | ||||
|       const nextLibItems = libraryItems.slice(); | ||||
|       nextLibItems.forEach((libItem) => { | ||||
|         if (selectedItems.includes(libItem.id)) { | ||||
|           libItem.status = "published"; | ||||
|         } | ||||
|       }); | ||||
|       library.saveLibrary(nextLibItems); | ||||
|       setLibraryItems(nextLibItems); | ||||
|     }, | ||||
|     [ | ||||
|       setShowPublishLibraryDialog, | ||||
|       setPublishLibSuccess, | ||||
|       libraryItems, | ||||
|       selectedItems, | ||||
|       library, | ||||
|     ], | ||||
|   ); | ||||
|  | ||||
|   return loadingState === "preloading" ? null : ( | ||||
|     <Island padding={1} ref={ref} className="layer-ui__library"> | ||||
|       {showPublishLibraryDialog && ( | ||||
|         <PublishLibrary | ||||
|           onClose={() => setShowPublishLibraryDialog(false)} | ||||
|           libraryItems={getSelectedItems(libraryItems, selectedItems)} | ||||
|           appState={appState} | ||||
|           onSuccess={onPublishLibSuccess} | ||||
|           onError={(error) => window.alert(error)} | ||||
|           updateItemsInStorage={() => library.saveLibrary(libraryItems)} | ||||
|           onRemove={(id: string) => | ||||
|             setSelectedItems(selectedItems.filter((_id) => _id !== id)) | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       {publishLibSuccess && renderPublishSuccess()} | ||||
|  | ||||
|       {loadingState === "loading" ? ( | ||||
|         <div className="layer-ui__library-message"> | ||||
|           {t("labels.libraryLoadingMessage")} | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <LibraryMenuItems | ||||
|           libraryItems={libraryItems} | ||||
|           onRemoveFromLibrary={removeFromLibrary} | ||||
|           onAddToLibrary={addToLibrary} | ||||
|           onInsertShape={onInsertShape} | ||||
|           pendingElements={pendingElements} | ||||
|           setAppState={setAppState} | ||||
|           libraryReturnUrl={libraryReturnUrl} | ||||
|           library={library} | ||||
|           theme={theme} | ||||
|           files={files} | ||||
|           id={id} | ||||
|           selectedItems={selectedItems} | ||||
|           onToggle={(id) => { | ||||
|             if (!selectedItems.includes(id)) { | ||||
|               setSelectedItems([...selectedItems, id]); | ||||
|             } else { | ||||
|               setSelectedItems(selectedItems.filter((_id) => _id !== id)); | ||||
|             } | ||||
|           }} | ||||
|           onPublish={() => setShowPublishLibraryDialog(true)} | ||||
|           resetLibrary={resetLibrary} | ||||
|         /> | ||||
|       )} | ||||
|     </Island> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										102
									
								
								src/components/LibraryMenuItems.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/components/LibraryMenuItems.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | ||||
| @import "open-color/open-color"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .library-menu-items-container { | ||||
|     .library-actions { | ||||
|       display: flex; | ||||
|  | ||||
|       button .library-actions-counter { | ||||
|         position: absolute; | ||||
|         right: 2px; | ||||
|         bottom: 2px; | ||||
|         border-radius: 50%; | ||||
|         width: 1em; | ||||
|         height: 1em; | ||||
|         padding: 1px; | ||||
|         font-size: 0.7rem; | ||||
|         background: #fff; | ||||
|       } | ||||
|  | ||||
|       &--remove { | ||||
|         background-color: $oc-red-7; | ||||
|         &:hover { | ||||
|           background-color: $oc-red-8; | ||||
|         } | ||||
|         &:active { | ||||
|           background-color: $oc-red-9; | ||||
|         } | ||||
|         svg { | ||||
|           color: $oc-white; | ||||
|         } | ||||
|         .library-actions-counter { | ||||
|           color: $oc-red-7; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       &--export { | ||||
|         background-color: $oc-lime-5; | ||||
|  | ||||
|         &:hover { | ||||
|           background-color: $oc-lime-7; | ||||
|         } | ||||
|  | ||||
|         &:active { | ||||
|           background-color: $oc-lime-8; | ||||
|         } | ||||
|         svg { | ||||
|           color: $oc-white; | ||||
|         } | ||||
|         .library-actions-counter { | ||||
|           color: $oc-lime-5; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       &--publish { | ||||
|         background-color: $oc-cyan-6; | ||||
|         &:hover { | ||||
|           background-color: $oc-cyan-7; | ||||
|         } | ||||
|         &:active { | ||||
|           background-color: $oc-cyan-9; | ||||
|         } | ||||
|         svg { | ||||
|           color: $oc-white; | ||||
|         } | ||||
|         label { | ||||
|           margin-left: -0.2em; | ||||
|           margin-right: 1.1em; | ||||
|           color: $oc-white; | ||||
|           font-size: 0.86em; | ||||
|         } | ||||
|         .library-actions-counter { | ||||
|           color: $oc-cyan-6; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       &--load { | ||||
|         background-color: $oc-blue-6; | ||||
|         &:hover { | ||||
|           background-color: $oc-blue-7; | ||||
|         } | ||||
|         &:active { | ||||
|           background-color: $oc-blue-9; | ||||
|         } | ||||
|         svg { | ||||
|           color: $oc-white; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     &__items { | ||||
|       max-height: 50vh; | ||||
|       overflow: auto; | ||||
|       margin-top: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     .separator { | ||||
|       font-weight: 500; | ||||
|       font-size: 0.9rem; | ||||
|       margin: 0.6em 0.2em; | ||||
|       color: var(--text-primary-color); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										322
									
								
								src/components/LibraryMenuItems.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								src/components/LibraryMenuItems.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,322 @@ | ||||
| import { chunk } from "lodash"; | ||||
| import { useCallback, useState } from "react"; | ||||
| import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; | ||||
| import Library from "../data/library"; | ||||
| import { ExcalidrawElement, NonDeleted } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| import { | ||||
|   AppState, | ||||
|   BinaryFiles, | ||||
|   ExcalidrawProps, | ||||
|   LibraryItem, | ||||
|   LibraryItems, | ||||
| } from "../types"; | ||||
| import { muteFSAbortError } from "../utils"; | ||||
| import { useIsMobile } from "./App"; | ||||
| import ConfirmDialog from "./ConfirmDialog"; | ||||
| import { exportToFileIcon, load, publishIcon, trash } from "./icons"; | ||||
| import { LibraryUnit } from "./LibraryUnit"; | ||||
| import Stack from "./Stack"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { Tooltip } from "./Tooltip"; | ||||
|  | ||||
| import "./LibraryMenuItems.scss"; | ||||
|  | ||||
| const LibraryMenuItems = ({ | ||||
|   libraryItems, | ||||
|   onRemoveFromLibrary, | ||||
|   onAddToLibrary, | ||||
|   onInsertShape, | ||||
|   pendingElements, | ||||
|   theme, | ||||
|   setAppState, | ||||
|   libraryReturnUrl, | ||||
|   library, | ||||
|   files, | ||||
|   id, | ||||
|   selectedItems, | ||||
|   onToggle, | ||||
|   onPublish, | ||||
|   resetLibrary, | ||||
| }: { | ||||
|   libraryItems: LibraryItems; | ||||
|   pendingElements: LibraryItem["elements"]; | ||||
|   onRemoveFromLibrary: () => void; | ||||
|   onInsertShape: (elements: LibraryItem["elements"]) => void; | ||||
|   onAddToLibrary: (elements: LibraryItem["elements"]) => void; | ||||
|   theme: AppState["theme"]; | ||||
|   files: BinaryFiles; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   library: Library; | ||||
|   id: string; | ||||
|   selectedItems: LibraryItem["id"][]; | ||||
|   onToggle: (id: LibraryItem["id"]) => void; | ||||
|   onPublish: () => void; | ||||
|   resetLibrary: () => void; | ||||
| }) => { | ||||
|   const renderRemoveLibAlert = useCallback(() => { | ||||
|     const content = selectedItems.length | ||||
|       ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) | ||||
|       : t("alerts.resetLibrary"); | ||||
|     const title = selectedItems.length | ||||
|       ? t("confirmDialog.removeItemsFromLib") | ||||
|       : t("confirmDialog.resetLibrary"); | ||||
|     return ( | ||||
|       <ConfirmDialog | ||||
|         onConfirm={() => { | ||||
|           if (selectedItems.length) { | ||||
|             onRemoveFromLibrary(); | ||||
|           } else { | ||||
|             resetLibrary(); | ||||
|           } | ||||
|           setShowRemoveLibAlert(false); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setShowRemoveLibAlert(false); | ||||
|         }} | ||||
|         title={title} | ||||
|       > | ||||
|         <p>{content}</p> | ||||
|       </ConfirmDialog> | ||||
|     ); | ||||
|   }, [selectedItems, onRemoveFromLibrary, resetLibrary]); | ||||
|  | ||||
|   const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); | ||||
|  | ||||
|   const isMobile = useIsMobile(); | ||||
|  | ||||
|   const renderLibraryActions = () => { | ||||
|     const itemsSelected = !!selectedItems.length; | ||||
|     const items = itemsSelected | ||||
|       ? libraryItems.filter((item) => selectedItems.includes(item.id)) | ||||
|       : libraryItems; | ||||
|     const resetLabel = itemsSelected | ||||
|       ? t("buttons.remove") | ||||
|       : t("buttons.resetLibrary"); | ||||
|     return ( | ||||
|       <div className="library-actions"> | ||||
|         {(!itemsSelected || !isMobile) && ( | ||||
|           <ToolButton | ||||
|             key="import" | ||||
|             type="button" | ||||
|             title={t("buttons.load")} | ||||
|             aria-label={t("buttons.load")} | ||||
|             icon={load} | ||||
|             onClick={() => { | ||||
|               importLibraryFromJSON(library) | ||||
|                 .then(() => { | ||||
|                   // Close and then open to get the libraries updated | ||||
|                   setAppState({ isLibraryOpen: false }); | ||||
|                   setAppState({ isLibraryOpen: true }); | ||||
|                 }) | ||||
|                 .catch(muteFSAbortError) | ||||
|                 .catch((error) => { | ||||
|                   setAppState({ errorMessage: error.message }); | ||||
|                 }); | ||||
|             }} | ||||
|             className="library-actions--load" | ||||
|           /> | ||||
|         )} | ||||
|         {!!items.length && ( | ||||
|           <> | ||||
|             <ToolButton | ||||
|               key="export" | ||||
|               type="button" | ||||
|               title={t("buttons.export")} | ||||
|               aria-label={t("buttons.export")} | ||||
|               icon={exportToFileIcon} | ||||
|               onClick={async () => { | ||||
|                 const libraryItems = itemsSelected | ||||
|                   ? items | ||||
|                   : await library.loadLibrary(); | ||||
|                 saveLibraryAsJSON(libraryItems) | ||||
|                   .catch(muteFSAbortError) | ||||
|                   .catch((error) => { | ||||
|                     setAppState({ errorMessage: error.message }); | ||||
|                   }); | ||||
|               }} | ||||
|               className="library-actions--export" | ||||
|             > | ||||
|               {selectedItems.length > 0 && ( | ||||
|                 <span className="library-actions-counter"> | ||||
|                   {selectedItems.length} | ||||
|                 </span> | ||||
|               )} | ||||
|             </ToolButton> | ||||
|             <ToolButton | ||||
|               key="reset" | ||||
|               type="button" | ||||
|               title={resetLabel} | ||||
|               aria-label={resetLabel} | ||||
|               icon={trash} | ||||
|               onClick={() => setShowRemoveLibAlert(true)} | ||||
|               className="library-actions--remove" | ||||
|             > | ||||
|               {selectedItems.length > 0 && ( | ||||
|                 <span className="library-actions-counter"> | ||||
|                   {selectedItems.length} | ||||
|                 </span> | ||||
|               )} | ||||
|             </ToolButton> | ||||
|           </> | ||||
|         )} | ||||
|         {itemsSelected && !isPublished && ( | ||||
|           <Tooltip label={t("hints.publishLibrary")}> | ||||
|             <ToolButton | ||||
|               type="button" | ||||
|               aria-label={t("buttons.publishLibrary")} | ||||
|               label={t("buttons.publishLibrary")} | ||||
|               icon={publishIcon} | ||||
|               className="library-actions--publish" | ||||
|               onClick={onPublish} | ||||
|             > | ||||
|               {!isMobile && <label>{t("buttons.publishLibrary")}</label>} | ||||
|               {selectedItems.length > 0 && ( | ||||
|                 <span className="library-actions-counter"> | ||||
|                   {selectedItems.length} | ||||
|                 </span> | ||||
|               )} | ||||
|             </ToolButton> | ||||
|           </Tooltip> | ||||
|         )} | ||||
|       </div> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const CELLS_PER_ROW = isMobile ? 4 : 6; | ||||
|  | ||||
|   const referrer = | ||||
|     libraryReturnUrl || window.location.origin + window.location.pathname; | ||||
|   const isPublished = selectedItems.some( | ||||
|     (id) => libraryItems.find((item) => item.id === id)?.status === "published", | ||||
|   ); | ||||
|  | ||||
|   const createLibraryItemCompo = (params: { | ||||
|     item: | ||||
|       | LibraryItem | ||||
|       | /* pending library item */ { | ||||
|           id: null; | ||||
|           elements: readonly NonDeleted<ExcalidrawElement>[]; | ||||
|         } | ||||
|       | null; | ||||
|     onClick?: () => void; | ||||
|     key: string; | ||||
|   }) => { | ||||
|     return ( | ||||
|       <Stack.Col key={params.key}> | ||||
|         <LibraryUnit | ||||
|           elements={params.item?.elements} | ||||
|           files={files} | ||||
|           isPending={!params.item?.id && !!params.item?.elements} | ||||
|           onClick={params.onClick || (() => {})} | ||||
|           id={params.item?.id || null} | ||||
|           selected={!!params.item?.id && selectedItems.includes(params.item.id)} | ||||
|           onToggle={() => { | ||||
|             if (params.item?.id) { | ||||
|               onToggle(params.item.id); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </Stack.Col> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const renderLibrarySection = ( | ||||
|     items: ( | ||||
|       | LibraryItem | ||||
|       | /* pending library item */ { | ||||
|           id: null; | ||||
|           elements: readonly NonDeleted<ExcalidrawElement>[]; | ||||
|         } | ||||
|     )[], | ||||
|   ) => { | ||||
|     const _items = items.map((item) => { | ||||
|       if (item.id) { | ||||
|         return createLibraryItemCompo({ | ||||
|           item, | ||||
|           onClick: () => onInsertShape(item.elements), | ||||
|           key: item.id, | ||||
|         }); | ||||
|       } | ||||
|       return createLibraryItemCompo({ | ||||
|         key: "__pending__item__", | ||||
|         item, | ||||
|         onClick: () => onAddToLibrary(pendingElements), | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     // ensure we render all empty cells if no items are present | ||||
|     let rows = chunk(_items, CELLS_PER_ROW); | ||||
|     if (!rows.length) { | ||||
|       rows = [[]]; | ||||
|     } | ||||
|  | ||||
|     return rows.map((rowItems, index, rows) => { | ||||
|       if (index === rows.length - 1) { | ||||
|         // pad row with empty cells | ||||
|         rowItems = rowItems.concat( | ||||
|           new Array(CELLS_PER_ROW - rowItems.length) | ||||
|             .fill(null) | ||||
|             .map((_, index) => { | ||||
|               return createLibraryItemCompo({ | ||||
|                 key: `empty_${index}`, | ||||
|                 item: null, | ||||
|               }); | ||||
|             }), | ||||
|         ); | ||||
|       } | ||||
|       return ( | ||||
|         <Stack.Row align="center" gap={1} key={index}> | ||||
|           {rowItems} | ||||
|         </Stack.Row> | ||||
|       ); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const publishedItems = libraryItems.filter( | ||||
|     (item) => item.status === "published", | ||||
|   ); | ||||
|   const unpublishedItems = [ | ||||
|     // append pending library item | ||||
|     ...(pendingElements.length | ||||
|       ? [{ id: null, elements: pendingElements }] | ||||
|       : []), | ||||
|     ...libraryItems.filter((item) => item.status !== "published"), | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <div className="library-menu-items-container"> | ||||
|       {showRemoveLibAlert && renderRemoveLibAlert()} | ||||
|       <div className="layer-ui__library-header" key="library-header"> | ||||
|         {renderLibraryActions()} | ||||
|         <a | ||||
|           href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ | ||||
|             window.name || "_blank" | ||||
|           }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`} | ||||
|           target="_excalidraw_libraries" | ||||
|         > | ||||
|           {t("labels.libraries")} | ||||
|         </a> | ||||
|       </div> | ||||
|       <Stack.Col | ||||
|         className="library-menu-items-container__items" | ||||
|         align="start" | ||||
|         gap={1} | ||||
|       > | ||||
|         <> | ||||
|           <div className="separator">{t("labels.personalLib")}</div> | ||||
|           {renderLibrarySection(unpublishedItems)} | ||||
|         </> | ||||
|  | ||||
|         <> | ||||
|           <div className="separator">{t("labels.excalidrawLib")} </div> | ||||
|  | ||||
|           {renderLibrarySection(publishedItems)} | ||||
|         </> | ||||
|       </Stack.Col> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default LibraryMenuItems; | ||||
| @@ -1,3 +1,5 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .library-unit { | ||||
|     align-items: center; | ||||
| @@ -7,6 +9,20 @@ | ||||
|     position: relative; | ||||
|     width: 63px; | ||||
|     height: 63px; // match width | ||||
|  | ||||
|     &--hover { | ||||
|       box-shadow: inset 0px 0px 0px 2px $oc-blue-5; | ||||
|       border-color: $oc-blue-5; | ||||
|     } | ||||
|  | ||||
|     &--selected { | ||||
|       box-shadow: inset 0px 0px 0px 2px $oc-blue-8; | ||||
|       border-color: $oc-blue-8; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark .library-unit { | ||||
|     border-color: rgb(48, 48, 48); | ||||
|   } | ||||
|  | ||||
|   .library-unit__dragger { | ||||
| @@ -22,9 +38,9 @@ | ||||
|     max-width: 100%; | ||||
|   } | ||||
|  | ||||
|   .library-unit__removeFromLibrary, | ||||
|   .library-unit__removeFromLibrary:hover, | ||||
|   .library-unit__removeFromLibrary:active { | ||||
|   .library-unit__checkbox-container, | ||||
|   .library-unit__checkbox-container:hover, | ||||
|   .library-unit__checkbox-container:active { | ||||
|     align-items: center; | ||||
|     background: none; | ||||
|     border: none; | ||||
| @@ -32,10 +48,35 @@ | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     padding: 0.5rem; | ||||
|     position: absolute; | ||||
|     right: 5px; | ||||
|     top: 5px; | ||||
|     left: 2rem; | ||||
|     bottom: 2rem; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     input { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-unit__checkbox { | ||||
|     position: absolute; | ||||
|     left: 2.3rem; | ||||
|     bottom: 2.3rem; | ||||
|  | ||||
|     .Checkbox-box { | ||||
|       width: 13px; | ||||
|       height: 13px; | ||||
|       border-radius: 2px; | ||||
|       margin: 0.5em 0.5em 0.2em 0.2em; | ||||
|       background-color: $oc-blue-1; | ||||
|     } | ||||
|  | ||||
|     &.Checkbox:hover { | ||||
|       .Checkbox-box { | ||||
|         background-color: $oc-blue-2; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-unit__removeFromLibrary > svg { | ||||
| @@ -43,29 +84,32 @@ | ||||
|     width: 16px; | ||||
|   } | ||||
|  | ||||
|   .library-unit__pulse { | ||||
|   .library-unit__adder { | ||||
|     transform: scale(1); | ||||
|     animation: library-unit__pulse-animation 1s ease-in infinite; | ||||
|     animation: library-unit__adder-animation 1s ease-in infinite; | ||||
|   } | ||||
|  | ||||
|   .library-unit__adder { | ||||
|     position: absolute; | ||||
|     left: 50%; | ||||
|     top: 50%; | ||||
|     width: 20px; | ||||
|     height: 20px; | ||||
|     left: 40%; | ||||
|     top: 40%; | ||||
|     width: 2rem; | ||||
|     height: 2rem; | ||||
|     margin-left: -10px; | ||||
|     margin-top: -10px; | ||||
|     pointer-events: none; | ||||
|   } | ||||
|   .library-unit--hover .library-unit__adder { | ||||
|     color: $oc-blue-7; | ||||
|   } | ||||
|  | ||||
|   .library-unit__active { | ||||
|     cursor: pointer; | ||||
|   } | ||||
|  | ||||
|   @keyframes library-unit__pulse-animation { | ||||
|   @keyframes library-unit__adder-animation { | ||||
|     0% { | ||||
|       transform: scale(0.95); | ||||
|       transform: scale(0.85); | ||||
|     } | ||||
|  | ||||
|     50% { | ||||
| @@ -73,7 +117,7 @@ | ||||
|     } | ||||
|  | ||||
|     100% { | ||||
|       transform: scale(0.95); | ||||
|       transform: scale(0.85); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import clsx from "clsx"; | ||||
| import oc from "open-color"; | ||||
| import { useEffect, useRef, useState } from "react"; | ||||
| import { close } from "../components/icons"; | ||||
| import { MIME_TYPES } from "../constants"; | ||||
| import { t } from "../i18n"; | ||||
| import { useIsMobile } from "../components/App"; | ||||
| import { exportToSvg } from "../scene/export"; | ||||
| import { BinaryFiles, LibraryItem } from "../types"; | ||||
| import "./LibraryUnit.scss"; | ||||
| import { CheckboxItem } from "./CheckboxItem"; | ||||
|  | ||||
| // fa-plus | ||||
| const PLUS_ICON = ( | ||||
| @@ -20,17 +19,21 @@ const PLUS_ICON = ( | ||||
| ); | ||||
|  | ||||
| export const LibraryUnit = ({ | ||||
|   id, | ||||
|   elements, | ||||
|   files, | ||||
|   pendingElements, | ||||
|   onRemoveFromLibrary, | ||||
|   isPending, | ||||
|   onClick, | ||||
|   selected, | ||||
|   onToggle, | ||||
| }: { | ||||
|   elements?: LibraryItem; | ||||
|   id: LibraryItem["id"] | /** for pending item */ null; | ||||
|   elements?: LibraryItem["elements"]; | ||||
|   files: BinaryFiles; | ||||
|   pendingElements?: LibraryItem; | ||||
|   onRemoveFromLibrary: () => void; | ||||
|   isPending?: boolean; | ||||
|   onClick: () => void; | ||||
|   selected: boolean; | ||||
|   onToggle: (id: string) => void; | ||||
| }) => { | ||||
|   const ref = useRef<HTMLDivElement | null>(null); | ||||
|   useEffect(() => { | ||||
| @@ -40,12 +43,11 @@ export const LibraryUnit = ({ | ||||
|     } | ||||
|  | ||||
|     (async () => { | ||||
|       const elementsToRender = elements || pendingElements; | ||||
|       if (!elementsToRender) { | ||||
|       if (!elements) { | ||||
|         return; | ||||
|       } | ||||
|       const svg = await exportToSvg( | ||||
|         elementsToRender, | ||||
|         elements, | ||||
|         { | ||||
|           exportBackground: false, | ||||
|           viewBackgroundColor: oc.white, | ||||
| @@ -58,30 +60,31 @@ export const LibraryUnit = ({ | ||||
|     return () => { | ||||
|       node.innerHTML = ""; | ||||
|     }; | ||||
|   }, [elements, pendingElements, files]); | ||||
|   }, [elements, files]); | ||||
|  | ||||
|   const [isHovered, setIsHovered] = useState(false); | ||||
|   const isMobile = useIsMobile(); | ||||
|  | ||||
|   const adder = (isHovered || isMobile) && pendingElements && ( | ||||
|   const adder = isPending && ( | ||||
|     <div className="library-unit__adder">{PLUS_ICON}</div> | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className={clsx("library-unit", { | ||||
|         "library-unit__active": elements || pendingElements, | ||||
|         "library-unit__active": elements, | ||||
|         "library-unit--hover": elements && isHovered, | ||||
|         "library-unit--selected": selected, | ||||
|       })} | ||||
|       onMouseEnter={() => setIsHovered(true)} | ||||
|       onMouseLeave={() => setIsHovered(false)} | ||||
|     > | ||||
|       <div | ||||
|         className={clsx("library-unit__dragger", { | ||||
|           "library-unit__pulse": !!pendingElements, | ||||
|           "library-unit__pulse": !!isPending, | ||||
|         })} | ||||
|         ref={ref} | ||||
|         draggable={!!elements} | ||||
|         onClick={!!elements || !!pendingElements ? onClick : undefined} | ||||
|         onClick={!!elements || !!isPending ? onClick : undefined} | ||||
|         onDragStart={(event) => { | ||||
|           setIsHovered(false); | ||||
|           event.dataTransfer.setData( | ||||
| @@ -91,14 +94,12 @@ export const LibraryUnit = ({ | ||||
|         }} | ||||
|       /> | ||||
|       {adder} | ||||
|       {elements && (isHovered || isMobile) && ( | ||||
|         <button | ||||
|           className="library-unit__removeFromLibrary" | ||||
|           aria-label={t("labels.removeFromLibrary")} | ||||
|           onClick={onRemoveFromLibrary} | ||||
|         > | ||||
|           {close} | ||||
|         </button> | ||||
|       {id && elements && (isHovered || isMobile || selected) && ( | ||||
|         <CheckboxItem | ||||
|           checked={selected} | ||||
|           onChange={() => onToggle(id)} | ||||
|           className="library-unit__checkbox" | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
|   | ||||
| @@ -92,7 +92,7 @@ export const MobileMenu = ({ | ||||
|             </Stack.Col> | ||||
|           )} | ||||
|         </Section> | ||||
|         <HintViewer appState={appState} elements={elements} /> | ||||
|         <HintViewer appState={appState} elements={elements} isMobile={true} /> | ||||
|       </FixedSideContainer> | ||||
|     ); | ||||
|   }; | ||||
|   | ||||
| @@ -15,8 +15,9 @@ export const Modal = (props: { | ||||
|   onCloseRequest(): void; | ||||
|   labelledBy: string; | ||||
|   theme?: AppState["theme"]; | ||||
|   closeOnClickOutside?: boolean; | ||||
| }) => { | ||||
|   const { theme = THEME.LIGHT } = props; | ||||
|   const { theme = THEME.LIGHT, closeOnClickOutside = true } = props; | ||||
|   const modalRoot = useBodyRoot(theme); | ||||
|  | ||||
|   if (!modalRoot) { | ||||
| @@ -39,7 +40,10 @@ export const Modal = (props: { | ||||
|       onKeyDown={handleKeydown} | ||||
|       aria-labelledby={props.labelledBy} | ||||
|     > | ||||
|       <div className="Modal__background" onClick={props.onCloseRequest}></div> | ||||
|       <div | ||||
|         className="Modal__background" | ||||
|         onClick={closeOnClickOutside ? props.onCloseRequest : undefined} | ||||
|       ></div> | ||||
|       <div | ||||
|         className="Modal__content" | ||||
|         style={{ "--max-width": `${props.maxWidth}px` }} | ||||
|   | ||||
| @@ -82,7 +82,7 @@ export const PasteChartDialog = ({ | ||||
|   appState: AppState; | ||||
|   onClose: () => void; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   onInsertChart: (elements: LibraryItem) => void; | ||||
|   onInsertChart: (elements: LibraryItem["elements"]) => void; | ||||
| }) => { | ||||
|   const handleClose = React.useCallback(() => { | ||||
|     if (onClose) { | ||||
|   | ||||
| @@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => { | ||||
|       </label> | ||||
|       {props.isNameEditable ? ( | ||||
|         <input | ||||
|           type="text" | ||||
|           className="TextInput" | ||||
|           onBlur={handleBlur} | ||||
|           onKeyDown={handleKeyDown} | ||||
|   | ||||
							
								
								
									
										92
									
								
								src/components/PublishLibrary.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/PublishLibrary.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .publish-library { | ||||
|     &__fields { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|  | ||||
|       label { | ||||
|         padding: 1em; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
|         span { | ||||
|           font-weight: 500; | ||||
|           font-size: 1rem; | ||||
|           color: $oc-gray-6; | ||||
|         } | ||||
|         input, | ||||
|         textarea { | ||||
|           width: 70%; | ||||
|           padding: 0.6em; | ||||
|           font-family: var(--ui-font); | ||||
|         } | ||||
|  | ||||
|         .required { | ||||
|           color: $oc-red-8; | ||||
|           margin: 0.2rem; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__buttons { | ||||
|       display: flex; | ||||
|       padding: 0.2rem 0; | ||||
|       justify-content: flex-end; | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         min-width: 2.5rem; | ||||
|         width: auto; | ||||
|         font-size: 1rem; | ||||
|       } | ||||
|  | ||||
|       .ToolIcon_type_button { | ||||
|         margin-left: 1rem; | ||||
|         padding: 0 0.5rem; | ||||
|       } | ||||
|  | ||||
|       &--confirm.ToolIcon_type_button { | ||||
|         background-color: $oc-blue-6; | ||||
|  | ||||
|         &:hover { | ||||
|           background-color: $oc-blue-8; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       &--cancel.ToolIcon_type_button { | ||||
|         background-color: $oc-gray-5; | ||||
|         &:hover { | ||||
|           background-color: $oc-gray-6; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         color: $oc-white; | ||||
|         .Spinner { | ||||
|           --spinner-color: #fff; | ||||
|           svg { | ||||
|             padding: 0.5rem; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .selected-library-items { | ||||
|       display: flex; | ||||
|       padding: 0 0.8rem; | ||||
|       flex-wrap: wrap; | ||||
|  | ||||
|       .single-library-item-wrapper { | ||||
|         width: 9rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &-note { | ||||
|       padding: 1em; | ||||
|       font-style: italic; | ||||
|       font-size: 14px; | ||||
|       display: block; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										429
									
								
								src/components/PublishLibrary.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										429
									
								
								src/components/PublishLibrary.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,429 @@ | ||||
| import { ReactNode, useCallback, useEffect, useState } from "react"; | ||||
| import oc from "open-color"; | ||||
|  | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { t } from "../i18n"; | ||||
|  | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import { AppState, LibraryItems, LibraryItem } from "../types"; | ||||
| import { exportToBlob } from "../packages/utils"; | ||||
| import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants"; | ||||
| import { ExportedLibraryData } from "../data/types"; | ||||
|  | ||||
| import "./PublishLibrary.scss"; | ||||
| import { ExcalidrawElement } from "../element/types"; | ||||
| import { newElement } from "../element"; | ||||
| import { mutateElement } from "../element/mutateElement"; | ||||
| import { getCommonBoundingBox } from "../element/bounds"; | ||||
| import SingleLibraryItem from "./SingleLibraryItem"; | ||||
|  | ||||
| interface PublishLibraryDataParams { | ||||
|   authorName: string; | ||||
|   githubHandle: string; | ||||
|   name: string; | ||||
|   description: string; | ||||
|   twitterHandle: string; | ||||
|   website: string; | ||||
| } | ||||
|  | ||||
| const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data"; | ||||
|  | ||||
| const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => { | ||||
|   try { | ||||
|     localStorage.setItem( | ||||
|       LOCAL_STORAGE_KEY_PUBLISH_LIBRARY, | ||||
|       JSON.stringify(data), | ||||
|     ); | ||||
|   } catch (error: any) { | ||||
|     // Unable to access window.localStorage | ||||
|     console.error(error); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const importPublishLibDataFromStorage = () => { | ||||
|   try { | ||||
|     const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY); | ||||
|     if (data) { | ||||
|       return JSON.parse(data); | ||||
|     } | ||||
|   } catch (error: any) { | ||||
|     // Unable to access localStorage | ||||
|     console.error(error); | ||||
|   } | ||||
|  | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| const PublishLibrary = ({ | ||||
|   onClose, | ||||
|   libraryItems, | ||||
|   appState, | ||||
|   onSuccess, | ||||
|   onError, | ||||
|   updateItemsInStorage, | ||||
|   onRemove, | ||||
| }: { | ||||
|   onClose: () => void; | ||||
|   libraryItems: LibraryItems; | ||||
|   appState: AppState; | ||||
|   onSuccess: (data: { | ||||
|     url: string; | ||||
|     authorName: string; | ||||
|     items: LibraryItems; | ||||
|   }) => void; | ||||
|  | ||||
|   onError: (error: Error) => void; | ||||
|   updateItemsInStorage: (items: LibraryItems) => void; | ||||
|   onRemove: (id: string) => void; | ||||
| }) => { | ||||
|   const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({ | ||||
|     authorName: "", | ||||
|     githubHandle: "", | ||||
|     name: "", | ||||
|     description: "", | ||||
|     twitterHandle: "", | ||||
|     website: "", | ||||
|   }); | ||||
|  | ||||
|   const [isSubmitting, setIsSubmitting] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const data = importPublishLibDataFromStorage(); | ||||
|     if (data) { | ||||
|       setLibraryData(data); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>( | ||||
|     libraryItems.slice(), | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setClonedLibItems(libraryItems.slice()); | ||||
|   }, [libraryItems]); | ||||
|  | ||||
|   const onInputChange = (event: any) => { | ||||
|     setLibraryData({ | ||||
|       ...libraryData, | ||||
|       [event.target.name]: event.target.value, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     setIsSubmitting(true); | ||||
|     const erroredLibItems: LibraryItem[] = []; | ||||
|     let isError = false; | ||||
|     clonedLibItems.forEach((libItem) => { | ||||
|       let error = ""; | ||||
|       if (!libItem.name) { | ||||
|         error = t("publishDialog.errors.required"); | ||||
|         isError = true; | ||||
|       } | ||||
|       erroredLibItems.push({ ...libItem, error }); | ||||
|     }); | ||||
|  | ||||
|     if (isError) { | ||||
|       setClonedLibItems(erroredLibItems); | ||||
|       setIsSubmitting(false); | ||||
|       return; | ||||
|     } | ||||
|     const elements: ExcalidrawElement[] = []; | ||||
|     const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; | ||||
|     clonedLibItems.forEach((libItem) => { | ||||
|       const boundingBox = getCommonBoundingBox(libItem.elements); | ||||
|       const width = boundingBox.maxX - boundingBox.minX + 30; | ||||
|       const height = boundingBox.maxY - boundingBox.minY + 30; | ||||
|       const offset = { | ||||
|         x: prevBoundingBox.maxX - boundingBox.minX, | ||||
|         y: prevBoundingBox.maxY - boundingBox.minY, | ||||
|       }; | ||||
|  | ||||
|       const itemsWithUpdatedCoords = libItem.elements.map((element) => { | ||||
|         element = mutateElement(element, { | ||||
|           x: element.x + offset.x + 15, | ||||
|           y: element.y + offset.y + 15, | ||||
|         }); | ||||
|         return element; | ||||
|       }); | ||||
|       const items = [ | ||||
|         ...itemsWithUpdatedCoords, | ||||
|         newElement({ | ||||
|           type: "rectangle", | ||||
|           width, | ||||
|           height, | ||||
|           x: prevBoundingBox.maxX, | ||||
|           y: prevBoundingBox.maxY, | ||||
|           strokeColor: "#ced4da", | ||||
|           backgroundColor: "transparent", | ||||
|           strokeStyle: "solid", | ||||
|           opacity: 100, | ||||
|           roughness: 0, | ||||
|           strokeSharpness: "sharp", | ||||
|           fillStyle: "solid", | ||||
|           strokeWidth: 1, | ||||
|         }), | ||||
|       ]; | ||||
|       elements.push(...items); | ||||
|       prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30; | ||||
|     }); | ||||
|     const png = await exportToBlob({ | ||||
|       elements, | ||||
|       mimeType: "image/png", | ||||
|       appState: { | ||||
|         ...appState, | ||||
|         viewBackgroundColor: oc.white, | ||||
|         exportBackground: true, | ||||
|       }, | ||||
|       files: null, | ||||
|     }); | ||||
|  | ||||
|     const libContent: ExportedLibraryData = { | ||||
|       type: EXPORT_DATA_TYPES.excalidrawLibrary, | ||||
|       version: 2, | ||||
|       source: EXPORT_SOURCE, | ||||
|       libraryItems: clonedLibItems, | ||||
|     }; | ||||
|     const content = JSON.stringify(libContent, null, 2); | ||||
|     const lib = new Blob([content], { type: "application/json" }); | ||||
|  | ||||
|     const formData = new FormData(); | ||||
|     formData.append("excalidrawLib", lib); | ||||
|     formData.append("excalidrawPng", png!); | ||||
|     formData.append("title", libraryData.name); | ||||
|     formData.append("authorName", libraryData.authorName); | ||||
|     formData.append("githubHandle", libraryData.githubHandle); | ||||
|     formData.append("name", libraryData.name); | ||||
|     formData.append("description", libraryData.description); | ||||
|     formData.append("twitterHandle", libraryData.twitterHandle); | ||||
|     formData.append("website", libraryData.website); | ||||
|  | ||||
|     fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, { | ||||
|       method: "post", | ||||
|       body: formData, | ||||
|     }) | ||||
|       .then( | ||||
|         (response) => { | ||||
|           if (response.ok) { | ||||
|             return response.json().then(({ url }) => { | ||||
|               // flush data from local storage | ||||
|               localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY); | ||||
|               onSuccess({ | ||||
|                 url, | ||||
|                 authorName: libraryData.authorName, | ||||
|                 items: clonedLibItems, | ||||
|               }); | ||||
|             }); | ||||
|           } | ||||
|           return response | ||||
|             .json() | ||||
|             .catch(() => { | ||||
|               throw new Error(response.statusText || "something went wrong"); | ||||
|             }) | ||||
|             .then((error) => { | ||||
|               throw new Error( | ||||
|                 error.message || response.statusText || "something went wrong", | ||||
|               ); | ||||
|             }); | ||||
|         }, | ||||
|         (err) => { | ||||
|           console.error(err); | ||||
|           onError(err); | ||||
|           setIsSubmitting(false); | ||||
|         }, | ||||
|       ) | ||||
|       .catch((err) => { | ||||
|         console.error(err); | ||||
|         onError(err); | ||||
|         setIsSubmitting(false); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const renderLibraryItems = () => { | ||||
|     const items: ReactNode[] = []; | ||||
|     clonedLibItems.forEach((libItem, index) => { | ||||
|       items.push( | ||||
|         <div className="single-library-item-wrapper" key={index}> | ||||
|           <SingleLibraryItem | ||||
|             libItem={libItem} | ||||
|             appState={appState} | ||||
|             index={index} | ||||
|             onChange={(val, index) => { | ||||
|               const items = clonedLibItems.slice(); | ||||
|               items[index].name = val; | ||||
|               setClonedLibItems(items); | ||||
|             }} | ||||
|             onRemove={onRemove} | ||||
|           /> | ||||
|         </div>, | ||||
|       ); | ||||
|     }); | ||||
|     return <div className="selected-library-items">{items}</div>; | ||||
|   }; | ||||
|  | ||||
|   const onDialogClose = useCallback(() => { | ||||
|     updateItemsInStorage(clonedLibItems); | ||||
|     savePublishLibDataToStorage(libraryData); | ||||
|     onClose(); | ||||
|   }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]); | ||||
|  | ||||
|   const shouldRenderForm = !!libraryItems.length; | ||||
|   return ( | ||||
|     <Dialog | ||||
|       onCloseRequest={onDialogClose} | ||||
|       title={t("publishDialog.title")} | ||||
|       className="publish-library" | ||||
|     > | ||||
|       {shouldRenderForm ? ( | ||||
|         <form onSubmit={onSubmit}> | ||||
|           <div className="publish-library-note"> | ||||
|             {t("publishDialog.noteDescription.pre")} | ||||
|             <a | ||||
|               href="https://libraries.excalidraw.com" | ||||
|               target="_blank" | ||||
|               rel="noopener noreferrer" | ||||
|             > | ||||
|               {t("publishDialog.noteDescription.link")} | ||||
|             </a>{" "} | ||||
|             {t("publishDialog.noteDescription.post")} | ||||
|           </div> | ||||
|           <span className="publish-library-note"> | ||||
|             {t("publishDialog.noteGuidelines.pre")} | ||||
|             <a | ||||
|               href="https://github.com/excalidraw/excalidraw-libraries#guidelines" | ||||
|               target="_blank" | ||||
|               rel="noopener noreferrer" | ||||
|             > | ||||
|               {t("publishDialog.noteGuidelines.link")} | ||||
|             </a> | ||||
|             {t("publishDialog.noteGuidelines.post")} | ||||
|           </span> | ||||
|  | ||||
|           <div className="publish-library-note"> | ||||
|             {t("publishDialog.noteItems")} | ||||
|           </div> | ||||
|           {renderLibraryItems()} | ||||
|           <div className="publish-library__fields"> | ||||
|             <label> | ||||
|               <div> | ||||
|                 <span>{t("publishDialog.libraryName")}</span> | ||||
|                 <span aria-hidden="true" className="required"> | ||||
|                   * | ||||
|                 </span> | ||||
|               </div> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 name="name" | ||||
|                 required | ||||
|                 value={libraryData.name} | ||||
|                 onChange={onInputChange} | ||||
|                 placeholder={t("publishDialog.placeholder.libraryName")} | ||||
|               /> | ||||
|             </label> | ||||
|             <label style={{ alignItems: "flex-start" }}> | ||||
|               <div> | ||||
|                 <span>{t("publishDialog.libraryDesc")}</span> | ||||
|                 <span aria-hidden="true" className="required"> | ||||
|                   * | ||||
|                 </span> | ||||
|               </div> | ||||
|               <textarea | ||||
|                 name="description" | ||||
|                 rows={4} | ||||
|                 required | ||||
|                 value={libraryData.description} | ||||
|                 onChange={onInputChange} | ||||
|                 placeholder={t("publishDialog.placeholder.libraryDesc")} | ||||
|               /> | ||||
|             </label> | ||||
|             <label> | ||||
|               <div> | ||||
|                 <span>{t("publishDialog.authorName")}</span> | ||||
|                 <span aria-hidden="true" className="required"> | ||||
|                   * | ||||
|                 </span> | ||||
|               </div> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 name="authorName" | ||||
|                 required | ||||
|                 value={libraryData.authorName} | ||||
|                 onChange={onInputChange} | ||||
|                 placeholder={t("publishDialog.placeholder.authorName")} | ||||
|               /> | ||||
|             </label> | ||||
|             <label> | ||||
|               <span>{t("publishDialog.githubUsername")}</span> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 name="githubHandle" | ||||
|                 value={libraryData.githubHandle} | ||||
|                 onChange={onInputChange} | ||||
|                 placeholder={t("publishDialog.placeholder.githubHandle")} | ||||
|               /> | ||||
|             </label> | ||||
|             <label> | ||||
|               <span>{t("publishDialog.twitterUsername")}</span> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 name="twitterHandle" | ||||
|                 value={libraryData.twitterHandle} | ||||
|                 onChange={onInputChange} | ||||
|                 placeholder={t("publishDialog.placeholder.twitterHandle")} | ||||
|               /> | ||||
|             </label> | ||||
|             <label> | ||||
|               <span>{t("publishDialog.website")}</span> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 name="website" | ||||
|                 pattern="https?://.+" | ||||
|                 title={t("publishDialog.errors.website")} | ||||
|                 value={libraryData.website} | ||||
|                 onChange={onInputChange} | ||||
|                 placeholder={t("publishDialog.placeholder.website")} | ||||
|               /> | ||||
|             </label> | ||||
|             <span className="publish-library-note"> | ||||
|               {t("publishDialog.noteLicense.pre")} | ||||
|               <a | ||||
|                 href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE" | ||||
|                 target="_blank" | ||||
|                 rel="noopener noreferrer" | ||||
|               > | ||||
|                 {t("publishDialog.noteLicense.link")} | ||||
|               </a> | ||||
|               {t("publishDialog.noteLicense.post")} | ||||
|             </span> | ||||
|           </div> | ||||
|           <div className="publish-library__buttons"> | ||||
|             <ToolButton | ||||
|               type="button" | ||||
|               title={t("buttons.cancel")} | ||||
|               aria-label={t("buttons.cancel")} | ||||
|               label={t("buttons.cancel")} | ||||
|               onClick={onDialogClose} | ||||
|               data-testid="cancel-clear-canvas-button" | ||||
|               className="publish-library__buttons--cancel" | ||||
|             /> | ||||
|             <ToolButton | ||||
|               type="submit" | ||||
|               title={t("buttons.submit")} | ||||
|               aria-label={t("buttons.submit")} | ||||
|               label={t("buttons.submit")} | ||||
|               className="publish-library__buttons--confirm" | ||||
|               isLoading={isSubmitting} | ||||
|             /> | ||||
|           </div> | ||||
|         </form> | ||||
|       ) : ( | ||||
|         <p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}> | ||||
|           {t("publishDialog.atleastOneLibItem")} | ||||
|         </p> | ||||
|       )} | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PublishLibrary; | ||||
							
								
								
									
										66
									
								
								src/components/SingleLibraryItem.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/components/SingleLibraryItem.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .single-library-item { | ||||
|     position: relative; | ||||
|     &__svg { | ||||
|       width: 7.5rem; | ||||
|       height: 7.5rem; | ||||
|       border: 1px solid var(--button-gray-2); | ||||
|       margin: 0.3rem; | ||||
|       svg { | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__icon { | ||||
|       background-color: $oc-white; | ||||
|       width: auto; | ||||
|       height: auto; | ||||
|       margin: 0 0.5rem; | ||||
|     } | ||||
|     .ToolIcon, | ||||
|     .ToolIcon_type_button:hover { | ||||
|       background-color: white; | ||||
|     } | ||||
|     .required, | ||||
|     .error { | ||||
|       color: $oc-red-8; | ||||
|       font-weight: bold; | ||||
|       font-size: 1rem; | ||||
|       margin: 0.2rem; | ||||
|     } | ||||
|     .error { | ||||
|       font-weight: 500; | ||||
|       margin: 0; | ||||
|       padding: 0.3em 0; | ||||
|     } | ||||
|  | ||||
|     &--remove { | ||||
|       position: absolute; | ||||
|       top: 0.2rem; | ||||
|       right: 1.3rem; | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         margin: 0; | ||||
|       } | ||||
|       .ToolIcon__icon { | ||||
|         background-color: $oc-red-6; | ||||
|         &:hover { | ||||
|           background-color: $oc-red-7; | ||||
|         } | ||||
|         &:active { | ||||
|           background-color: $oc-red-8; | ||||
|         } | ||||
|       } | ||||
|       svg { | ||||
|         color: $oc-white; | ||||
|         padding: 0.26rem; | ||||
|         border-radius: 0.3em; | ||||
|         width: 1rem; | ||||
|         height: 1rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										99
									
								
								src/components/SingleLibraryItem.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/components/SingleLibraryItem.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| import oc from "open-color"; | ||||
| import { useEffect, useRef } from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { exportToSvg } from "../packages/utils"; | ||||
| import { AppState, LibraryItem } from "../types"; | ||||
| import { close } from "./icons"; | ||||
|  | ||||
| import "./SingleLibraryItem.scss"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| const SingleLibraryItem = ({ | ||||
|   libItem, | ||||
|   appState, | ||||
|   index, | ||||
|   onChange, | ||||
|   onRemove, | ||||
| }: { | ||||
|   libItem: LibraryItem; | ||||
|   appState: AppState; | ||||
|   index: number; | ||||
|   onChange: (val: string, index: number) => void; | ||||
|   onRemove: (id: string) => void; | ||||
| }) => { | ||||
|   const svgRef = useRef<HTMLDivElement | null>(null); | ||||
|   const inputRef = useRef<HTMLInputElement | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const node = svgRef.current; | ||||
|     if (!node) { | ||||
|       return; | ||||
|     } | ||||
|     (async () => { | ||||
|       const svg = await exportToSvg({ | ||||
|         elements: libItem.elements, | ||||
|         appState: { | ||||
|           ...appState, | ||||
|           viewBackgroundColor: oc.white, | ||||
|           exportBackground: true, | ||||
|         }, | ||||
|         files: null, | ||||
|       }); | ||||
|       node.innerHTML = svg.outerHTML; | ||||
|     })(); | ||||
|   }, [libItem.elements, appState]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="single-library-item"> | ||||
|       <div ref={svgRef} className="single-library-item__svg" /> | ||||
|       <ToolButton | ||||
|         aria-label={t("buttons.remove")} | ||||
|         type="button" | ||||
|         icon={close} | ||||
|         className="single-library-item--remove" | ||||
|         onClick={onRemove.bind(null, libItem.id)} | ||||
|         title={t("buttons.remove")} | ||||
|       /> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           margin: "0.8rem 0.3rem", | ||||
|           width: "100%", | ||||
|           fontSize: "14px", | ||||
|           fontWeight: 500, | ||||
|           flexDirection: "column", | ||||
|         }} | ||||
|       > | ||||
|         <label | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             justifyContent: "space-between", | ||||
|             flexDirection: "column", | ||||
|           }} | ||||
|         > | ||||
|           <div style={{ padding: "0.5em 0" }}> | ||||
|             <span style={{ fontWeight: 500, color: oc.gray[6] }}> | ||||
|               {t("publishDialog.itemName")} | ||||
|             </span> | ||||
|             <span aria-hidden="true" className="required"> | ||||
|               * | ||||
|             </span> | ||||
|           </div> | ||||
|           <input | ||||
|             type="text" | ||||
|             ref={inputRef} | ||||
|             style={{ width: "80%", padding: "0.2rem" }} | ||||
|             defaultValue={libItem.name} | ||||
|             placeholder="Item name" | ||||
|             onChange={(event) => { | ||||
|               onChange(event.target.value, index); | ||||
|             }} | ||||
|           /> | ||||
|         </label> | ||||
|         <span className="error">{libItem.error}</span> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SingleLibraryItem; | ||||
| @@ -2,24 +2,6 @@ | ||||
|  | ||||
| .excalidraw { | ||||
|   .TextInput { | ||||
|     color: var(--text-primary-color); | ||||
|     display: inline-block; | ||||
|     border: 1.5px solid var(--button-gray-1); | ||||
|     line-height: 1; | ||||
|     padding: 0.75rem; | ||||
|     white-space: nowrap; | ||||
|     border-radius: var(--space-factor); | ||||
|     background-color: var(--input-bg-color); | ||||
|  | ||||
|     &:not(:focus) { | ||||
|       &:hover { | ||||
|         background-color: var(--input-hover-bg-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:focus { | ||||
|       outline: none; | ||||
|       box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -25,6 +25,7 @@ type ToolButtonBaseProps = { | ||||
|   visible?: boolean; | ||||
|   selected?: boolean; | ||||
|   className?: string; | ||||
|   isLoading?: boolean; | ||||
| }; | ||||
|  | ||||
| type ToolButtonProps = | ||||
| @@ -33,6 +34,11 @@ type ToolButtonProps = | ||||
|       children?: React.ReactNode; | ||||
|       onClick?(event: React.MouseEvent): void; | ||||
|     }) | ||||
|   | (ToolButtonBaseProps & { | ||||
|       type: "submit"; | ||||
|       children?: React.ReactNode; | ||||
|       onClick?(event: React.MouseEvent): void; | ||||
|     }) | ||||
|   | (ToolButtonBaseProps & { | ||||
|       type: "icon"; | ||||
|       children?: React.ReactNode; | ||||
| @@ -61,9 +67,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { | ||||
|       try { | ||||
|         setIsLoading(true); | ||||
|         await ret; | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         if (!(error instanceof AbortError)) { | ||||
|           throw error; | ||||
|         } else { | ||||
|           console.warn(error); | ||||
|         } | ||||
|       } finally { | ||||
|         if (isMountedRef.current) { | ||||
| @@ -82,7 +90,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { | ||||
|  | ||||
|   const lastPointerTypeRef = useRef<PointerType | null>(null); | ||||
|  | ||||
|   if (props.type === "button" || props.type === "icon") { | ||||
|   if ( | ||||
|     props.type === "button" || | ||||
|     props.type === "icon" || | ||||
|     props.type === "submit" | ||||
|   ) { | ||||
|     const type = (props.type === "icon" ? "button" : props.type) as | ||||
|       | "button" | ||||
|       | "submit"; | ||||
|     return ( | ||||
|       <button | ||||
|         className={clsx( | ||||
| @@ -102,10 +117,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { | ||||
|         hidden={props.hidden} | ||||
|         title={props.title} | ||||
|         aria-label={props["aria-label"]} | ||||
|         type="button" | ||||
|         type={type} | ||||
|         onClick={onClick} | ||||
|         ref={innerRef} | ||||
|         disabled={isLoading} | ||||
|         disabled={isLoading || props.isLoading} | ||||
|       > | ||||
|         {(props.icon || props.label) && ( | ||||
|           <div className="ToolIcon__icon" aria-hidden="true"> | ||||
| @@ -115,6 +130,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { | ||||
|                 {props.keyBindingLabel} | ||||
|               </span> | ||||
|             )} | ||||
|             {props.isLoading && <Spinner />} | ||||
|           </div> | ||||
|         )} | ||||
|         {props.showAriaLabel && ( | ||||
|   | ||||
| @@ -6,7 +6,6 @@ | ||||
|     display: inline-flex; | ||||
|     align-items: center; | ||||
|     position: relative; | ||||
|     font-family: Cascadia; | ||||
|     cursor: pointer; | ||||
|     -webkit-tap-highlight-color: transparent; | ||||
|     border-radius: var(--space-factor); | ||||
|   | ||||
| @@ -34,10 +34,8 @@ const updateTooltip = ( | ||||
|     width: itemWidth, | ||||
|   } = item.getBoundingClientRect(); | ||||
|  | ||||
|   const { | ||||
|     width: labelWidth, | ||||
|     height: labelHeight, | ||||
|   } = tooltip.getBoundingClientRect(); | ||||
|   const { width: labelWidth, height: labelHeight } = | ||||
|     tooltip.getBoundingClientRect(); | ||||
|  | ||||
|   const viewportWidth = window.innerWidth; | ||||
|   const viewportHeight = window.innerHeight; | ||||
|   | ||||
| @@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component< | ||||
|     for (const [key, value] of Object.entries({ ...localStorage })) { | ||||
|       try { | ||||
|         _localStorage[key] = JSON.parse(value); | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         _localStorage[key] = value; | ||||
|       } | ||||
|     } | ||||
| @@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component< | ||||
|         ) | ||||
|       ).default; | ||||
|       body = encodeURIComponent(templateStrFn(this.state.sentryEventId)); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|     } | ||||
|  | ||||
| @@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component< | ||||
|                 try { | ||||
|                   localStorage.clear(); | ||||
|                   window.location.reload(); | ||||
|                 } catch (error) { | ||||
|                 } catch (error: any) { | ||||
|                   console.error(error); | ||||
|                 } | ||||
|               }} | ||||
|   | ||||
| @@ -30,8 +30,12 @@ export const createIcon = ( | ||||
|   d: string | React.ReactNode, | ||||
|   opts: number | Opts = 512, | ||||
| ) => { | ||||
|   const { width = 512, height = width, mirror, style } = | ||||
|     typeof opts === "number" ? ({ width: opts } as Opts) : opts; | ||||
|   const { | ||||
|     width = 512, | ||||
|     height = width, | ||||
|     mirror, | ||||
|     style, | ||||
|   } = typeof opts === "number" ? ({ width: opts } as Opts) : opts; | ||||
|   return ( | ||||
|     <svg | ||||
|       aria-hidden="true" | ||||
| @@ -81,6 +85,7 @@ export const clipboard = createIcon( | ||||
|  | ||||
| export const trash = createIcon( | ||||
|   "M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z", | ||||
|  | ||||
|   { width: 448, height: 512 }, | ||||
| ); | ||||
|  | ||||
| @@ -752,6 +757,21 @@ export const ArrowheadBarIcon = React.memo( | ||||
|     ), | ||||
| ); | ||||
|  | ||||
| export const ArrowheadTriangleIcon = React.memo( | ||||
|   ({ theme, flip = false }: { theme: Theme; flip?: boolean }) => | ||||
|     createIcon( | ||||
|       <g | ||||
|         stroke={iconFillColor(theme)} | ||||
|         fill={iconFillColor(theme)} | ||||
|         transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} | ||||
|       > | ||||
|         <path d="M32 10L6 10" strokeWidth={2} /> | ||||
|         <path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" /> | ||||
|       </g>, | ||||
|       { width: 40, height: 20 }, | ||||
|     ), | ||||
| ); | ||||
|  | ||||
| export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) => | ||||
|   createIcon( | ||||
|     <path | ||||
| @@ -863,3 +883,11 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) => | ||||
|     { width: 448, height: 512 }, | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| export const publishIcon = createIcon( | ||||
|   <path | ||||
|     d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z" | ||||
|     fill="currentColor" | ||||
|   />, | ||||
|   { width: 640, height: 512 }, | ||||
| ); | ||||
|   | ||||
| @@ -162,7 +162,8 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; | ||||
| export const EXPORT_SCALES = [1, 2, 3]; | ||||
| export const DEFAULT_EXPORT_PADDING = 10; // px | ||||
|  | ||||
| export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; | ||||
| export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG = 10000; | ||||
| export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER = 1440; | ||||
|  | ||||
| export const ALLOWED_IMAGE_MIME_TYPES = [ | ||||
|   MIME_TYPES.png, | ||||
| @@ -174,3 +175,5 @@ export const ALLOWED_IMAGE_MIME_TYPES = [ | ||||
| export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; | ||||
|  | ||||
| export const SVG_NS = "http://www.w3.org/2000/svg"; | ||||
|  | ||||
| export const ENCRYPTION_KEY_BITS = 128; | ||||
|   | ||||
| @@ -517,6 +517,27 @@ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   input[type="text"], | ||||
|   textarea:not(.excalidraw-wysiwyg) { | ||||
|     color: var(--text-primary-color); | ||||
|     border: 1.5px solid var(--input-border-color); | ||||
|     padding: 0.75rem; | ||||
|     white-space: nowrap; | ||||
|     border-radius: var(--space-factor); | ||||
|     background-color: var(--input-bg-color); | ||||
|  | ||||
|     &:not(:focus) { | ||||
|       &:hover { | ||||
|         background-color: var(--input-hover-bg-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:focus { | ||||
|       outline: none; | ||||
|       box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @media print { | ||||
|     .App-bottom-bar, | ||||
|     .FixedSideContainer, | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|   --icon-green-fill-color: #{$oc-green-9}; | ||||
|   --default-bg-color: #{$oc-white}; | ||||
|   --input-bg-color: #{$oc-white}; | ||||
|   --input-border-color: #{$oc-gray-3}; | ||||
|   --input-border-color: #{$oc-gray-4}; | ||||
|   --input-hover-bg-color: #{$oc-gray-1}; | ||||
|   --input-label-color: #{$oc-gray-7}; | ||||
|   --island-bg-color: rgba(255, 255, 255, 0.96); | ||||
| @@ -64,6 +64,7 @@ | ||||
|     --input-label-color: #{$oc-gray-2}; | ||||
|     --island-bg-color: rgba(30, 30, 30, 0.98); | ||||
|     --keybinding-color: #{$oc-gray-6}; | ||||
|     --link-color: #{$oc-blue-4}; | ||||
|     --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)}; | ||||
|     --popup-bg-color: #2c2c2c; | ||||
|     --popup-secondary-bg-color: #222; | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import { CanvasError } from "../errors"; | ||||
| import { t } from "../i18n"; | ||||
| import { calculateScrollCenter } from "../scene"; | ||||
| import { AppState, DataURL } from "../types"; | ||||
| import { bytesToHexString } from "../utils"; | ||||
| import { FileSystemHandle } from "./filesystem"; | ||||
| import { isValidExcalidrawData } from "./json"; | ||||
| import { restore } from "./restore"; | ||||
| @@ -24,7 +25,7 @@ const parseFileContents = async (blob: Blob | File) => { | ||||
|       return await ( | ||||
|         await import(/* webpackChunkName: "image" */ "./image") | ||||
|       ).decodePngMetadata(blob); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       if (error.message === "INVALID") { | ||||
|         throw new DOMException( | ||||
|           t("alerts.imageDoesNotContainScene"), | ||||
| @@ -58,7 +59,7 @@ const parseFileContents = async (blob: Blob | File) => { | ||||
|         ).decodeSvgMetadata({ | ||||
|           svg: contents, | ||||
|         }); | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         if (error.message === "INVALID") { | ||||
|           throw new DOMException( | ||||
|             t("alerts.imageDoesNotContainScene"), | ||||
| @@ -156,7 +157,7 @@ export const loadFromBlob = async ( | ||||
|     ); | ||||
|  | ||||
|     return result; | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error(error.message); | ||||
|     throw new Error(t("alerts.couldNotLoadInvalidFile")); | ||||
|   } | ||||
| @@ -187,7 +188,7 @@ export const canvasToBlob = async ( | ||||
|         } | ||||
|         resolve(blob); | ||||
|       }); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       reject(error); | ||||
|     } | ||||
|   }); | ||||
| @@ -195,26 +196,18 @@ export const canvasToBlob = async ( | ||||
|  | ||||
| /** generates SHA-1 digest from supplied file (if not supported, falls back | ||||
|     to a 40-char base64 random id) */ | ||||
| export const generateIdFromFile = async (file: File) => { | ||||
|   let id: FileId; | ||||
| export const generateIdFromFile = async (file: File): Promise<FileId> => { | ||||
|   try { | ||||
|     const hashBuffer = await window.crypto.subtle.digest( | ||||
|       "SHA-1", | ||||
|       await file.arrayBuffer(), | ||||
|     ); | ||||
|     id = | ||||
|       // convert buffer to byte array | ||||
|       Array.from(new Uint8Array(hashBuffer)) | ||||
|         // convert to hex string | ||||
|         .map((byte) => byte.toString(16).padStart(2, "0")) | ||||
|         .join("") as FileId; | ||||
|   } catch (error) { | ||||
|     return bytesToHexString(new Uint8Array(hashBuffer)) as FileId; | ||||
|   } catch (error: any) { | ||||
|     console.error(error); | ||||
|     // length 40 to align with the HEX length of SHA-1 (which is 160 bit) | ||||
|     id = nanoid(40) as FileId; | ||||
|     return nanoid(40) as FileId; | ||||
|   } | ||||
|  | ||||
|   return id; | ||||
| }; | ||||
|  | ||||
| export const getDataURL = async (file: Blob | File): Promise<DataURL> => { | ||||
| @@ -244,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => { | ||||
|  | ||||
| export const resizeImageFile = async ( | ||||
|   file: File, | ||||
|   maxWidthOrHeight: number, | ||||
|   opts: { | ||||
|     /** undefined indicates auto */ | ||||
|     outputType?: typeof MIME_TYPES["jpg"]; | ||||
|     maxWidthOrHeight: number; | ||||
|   }, | ||||
| ): Promise<File> => { | ||||
|   // SVG files shouldn't a can't be resized | ||||
|   if (file.type === MIME_TYPES.svg) { | ||||
| @@ -264,6 +261,16 @@ export const resizeImageFile = async ( | ||||
|     pica: pica({ features: ["js", "wasm"] }), | ||||
|   }); | ||||
|  | ||||
|   if (opts.outputType) { | ||||
|     const { outputType } = opts; | ||||
|     reduce._create_blob = function (env) { | ||||
|       return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => { | ||||
|         env.out_blob = blob; | ||||
|         return env; | ||||
|       }); | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   const fileType = file.type; | ||||
|  | ||||
|   if (!isSupportedImageFile(file)) { | ||||
| @@ -271,9 +278,11 @@ export const resizeImageFile = async ( | ||||
|   } | ||||
|  | ||||
|   return new File( | ||||
|     [await reduce.toBlob(file, { max: maxWidthOrHeight })], | ||||
|     [await reduce.toBlob(file, { max: opts.maxWidthOrHeight })], | ||||
|     file.name, | ||||
|     { type: fileType }, | ||||
|     { | ||||
|       type: fileType, | ||||
|     }, | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -85,7 +85,7 @@ export const encode = async ({ | ||||
|   if (compress !== false) { | ||||
|     try { | ||||
|       deflated = await toByteString(deflate(text)); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error("encode: cannot deflate", error); | ||||
|     } | ||||
|   } | ||||
| @@ -367,7 +367,7 @@ export const decompressData = async <T extends Record<string, any>>( | ||||
|       /** data can be anything so the caller must decode it */ | ||||
|       data: contentsBuffer, | ||||
|     }; | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error( | ||||
|       `Error during decompressing and decrypting the file.`, | ||||
|       encodingMetadata, | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import { ENCRYPTION_KEY_BITS } from "../constants"; | ||||
|  | ||||
| export const IV_LENGTH_BYTES = 12; | ||||
|  | ||||
| export const createIV = () => { | ||||
| @@ -5,19 +7,27 @@ export const createIV = () => { | ||||
|   return window.crypto.getRandomValues(arr); | ||||
| }; | ||||
|  | ||||
| export const generateEncryptionKey = async () => { | ||||
| export const generateEncryptionKey = async < | ||||
|   T extends "string" | "cryptoKey" = "string", | ||||
| >( | ||||
|   returnAs?: T, | ||||
| ): Promise<T extends "cryptoKey" ? CryptoKey : string> => { | ||||
|   const key = await window.crypto.subtle.generateKey( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       length: 128, | ||||
|       length: ENCRYPTION_KEY_BITS, | ||||
|     }, | ||||
|     true, // extractable | ||||
|     ["encrypt", "decrypt"], | ||||
|   ); | ||||
|   return (await window.crypto.subtle.exportKey("jwk", key)).k; | ||||
|   return ( | ||||
|     returnAs === "cryptoKey" | ||||
|       ? key | ||||
|       : (await window.crypto.subtle.exportKey("jwk", key)).k | ||||
|   ) as T extends "cryptoKey" ? CryptoKey : string; | ||||
| }; | ||||
|  | ||||
| export const getImportedKey = (key: string, usage: KeyUsage) => | ||||
| export const getCryptoKey = (key: string, usage: KeyUsage) => | ||||
|   window.crypto.subtle.importKey( | ||||
|     "jwk", | ||||
|     { | ||||
| @@ -29,17 +39,18 @@ export const getImportedKey = (key: string, usage: KeyUsage) => | ||||
|     }, | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       length: 128, | ||||
|       length: ENCRYPTION_KEY_BITS, | ||||
|     }, | ||||
|     false, // extractable | ||||
|     [usage], | ||||
|   ); | ||||
|  | ||||
| export const encryptData = async ( | ||||
|   key: string, | ||||
|   key: string | CryptoKey, | ||||
|   data: Uint8Array | ArrayBuffer | Blob | File | string, | ||||
| ): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => { | ||||
|   const importedKey = await getImportedKey(key, "encrypt"); | ||||
|   const importedKey = | ||||
|     typeof key === "string" ? await getCryptoKey(key, "encrypt") : key; | ||||
|   const iv = createIV(); | ||||
|   const buffer: ArrayBuffer | Uint8Array = | ||||
|     typeof data === "string" | ||||
| @@ -50,6 +61,8 @@ export const encryptData = async ( | ||||
|       ? await data.arrayBuffer() | ||||
|       : data; | ||||
|  | ||||
|   // We use symmetric encryption. AES-GCM is the recommended algorithm and | ||||
|   // includes checks that the ciphertext has not been modified by an attacker. | ||||
|   const encryptedBuffer = await window.crypto.subtle.encrypt( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
| @@ -67,7 +80,7 @@ export const decryptData = async ( | ||||
|   encrypted: Uint8Array | ArrayBuffer, | ||||
|   privateKey: string, | ||||
| ): Promise<ArrayBuffer> => { | ||||
|   const key = await getImportedKey(privateKey, "decrypt"); | ||||
|   const key = await getCryptoKey(privateKey, "decrypt"); | ||||
|   return window.crypto.subtle.decrypt( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { | ||||
|   fileSave as _fileSave, | ||||
|   FileSystemHandle, | ||||
|   supported as nativeFileSystemSupported, | ||||
| } from "@dwelle/browser-fs-access"; | ||||
| } from "browser-fs-access"; | ||||
| import { EVENT, MIME_TYPES } from "../constants"; | ||||
| import { AbortError } from "../errors"; | ||||
| import { debounce } from "../utils"; | ||||
| @@ -22,7 +22,7 @@ const INPUT_CHANGE_INTERVAL_MS = 500; | ||||
|  | ||||
| export const fileOpen = <M extends boolean | undefined = false>(opts: { | ||||
|   extensions?: FILE_EXTENSION[]; | ||||
|   description?: string; | ||||
|   description: string; | ||||
|   multiple?: M; | ||||
| }): Promise< | ||||
|   M extends false | undefined ? FileWithHandle : FileWithHandle[] | ||||
| @@ -94,7 +94,7 @@ export const fileSave = ( | ||||
|     name: string; | ||||
|     /** file extension */ | ||||
|     extension: FILE_EXTENSION; | ||||
|     description?: string; | ||||
|     description: string; | ||||
|     /** existing FileSystemHandle */ | ||||
|     fileHandle?: FileSystemHandle | null; | ||||
|   }, | ||||
|   | ||||
| @@ -1,8 +1,11 @@ | ||||
| import decodePng from "png-chunks-extract"; | ||||
| import extractPngChunks from "png-chunks-extract"; | ||||
| import tEXt from "png-chunk-text"; | ||||
| import encodePng from "png-chunks-encode"; | ||||
| import { stringToBase64, encode, decode, base64ToString } from "./encode"; | ||||
| import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; | ||||
| import { PngChunk } from "../types"; | ||||
|  | ||||
| export { extractPngChunks }; | ||||
|  | ||||
| // ----------------------------------------------------------------------------- | ||||
| // PNG | ||||
| @@ -28,7 +31,9 @@ const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => { | ||||
| export const getTEXtChunk = async ( | ||||
|   blob: Blob, | ||||
| ): Promise<{ keyword: string; text: string } | null> => { | ||||
|   const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); | ||||
|   const chunks = extractPngChunks( | ||||
|     new Uint8Array(await blobToArrayBuffer(blob)), | ||||
|   ); | ||||
|   const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt"); | ||||
|   if (metadataChunk) { | ||||
|     return tEXt.decode(metadataChunk.data); | ||||
| @@ -36,6 +41,28 @@ export const getTEXtChunk = async ( | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| export const findPngChunk = ( | ||||
|   chunks: PngChunk[], | ||||
|   name: PngChunk["name"], | ||||
|   /** this makes the search stop before IDAT chunk (before which most | ||||
|    * metadata chunks reside). This is a perf optim. */ | ||||
|   breakBeforeIDAT = true, | ||||
| ) => { | ||||
|   let i = 0; | ||||
|   const len = chunks.length; | ||||
|   while (i <= len) { | ||||
|     const chunk = chunks[i]; | ||||
|     if (chunk.name === name) { | ||||
|       return chunk; | ||||
|     } | ||||
|     if (breakBeforeIDAT && chunk.name === "IDAT") { | ||||
|       return null; | ||||
|     } | ||||
|     i++; | ||||
|   } | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| export const encodePngMetadata = async ({ | ||||
|   blob, | ||||
|   metadata, | ||||
| @@ -43,7 +70,9 @@ export const encodePngMetadata = async ({ | ||||
|   blob: Blob; | ||||
|   metadata: string; | ||||
| }) => { | ||||
|   const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); | ||||
|   const chunks = extractPngChunks( | ||||
|     new Uint8Array(await blobToArrayBuffer(blob)), | ||||
|   ); | ||||
|  | ||||
|   const metadataChunk = tEXt.encode( | ||||
|     MIME_TYPES.excalidraw, | ||||
| @@ -76,7 +105,7 @@ export const decodePngMetadata = async (blob: Blob) => { | ||||
|         throw new Error("FAILED"); | ||||
|       } | ||||
|       return await decode(encodedData); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       throw new Error("FAILED"); | ||||
|     } | ||||
| @@ -127,7 +156,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { | ||||
|         throw new Error("FAILED"); | ||||
|       } | ||||
|       return await decode(encodedData); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       throw new Error("FAILED"); | ||||
|     } | ||||
|   | ||||
| @@ -54,6 +54,7 @@ export const exportCanvas = async ( | ||||
|       return await fileSave( | ||||
|         new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }), | ||||
|         { | ||||
|           description: "Export to SVG", | ||||
|           name, | ||||
|           extension: "svg", | ||||
|           fileHandle, | ||||
| @@ -86,6 +87,7 @@ export const exportCanvas = async ( | ||||
|     } | ||||
|  | ||||
|     return await fileSave(blob, { | ||||
|       description: "Export to PNG", | ||||
|       name, | ||||
|       extension: "png", | ||||
|       fileHandle, | ||||
| @@ -93,7 +95,7 @@ export const exportCanvas = async ( | ||||
|   } else if (type === "clipboard") { | ||||
|     try { | ||||
|       await copyBlobToClipboardAsPng(blob); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { | ||||
|         throw error; | ||||
|       } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; | ||||
| import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants"; | ||||
| import { clearElementsForDatabase, clearElementsForExport } from "../element"; | ||||
| import { ExcalidrawElement } from "../element/types"; | ||||
| import { AppState, BinaryFiles } from "../types"; | ||||
| import { AppState, BinaryFiles, LibraryItems } from "../types"; | ||||
| import { isImageFileHandle, loadFromBlob } from "./blob"; | ||||
|  | ||||
| import { | ||||
| @@ -114,17 +114,16 @@ export const isValidLibrary = (json: any) => { | ||||
|     typeof json === "object" && | ||||
|     json && | ||||
|     json.type === EXPORT_DATA_TYPES.excalidrawLibrary && | ||||
|     json.version === 1 | ||||
|     (json.version === 1 || json.version === 2) | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const saveLibraryAsJSON = async (library: Library) => { | ||||
|   const libraryItems = await library.loadLibrary(); | ||||
| export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => { | ||||
|   const data: ExportedLibraryData = { | ||||
|     type: EXPORT_DATA_TYPES.excalidrawLibrary, | ||||
|     version: 1, | ||||
|     version: 2, | ||||
|     source: EXPORT_SOURCE, | ||||
|     library: libraryItems, | ||||
|     libraryItems, | ||||
|   }; | ||||
|   const serialized = JSON.stringify(data, null, 2); | ||||
|   await fileSave( | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { loadLibraryFromBlob } from "./blob"; | ||||
| import { LibraryItems, LibraryItem } from "../types"; | ||||
| import { restoreElements } from "./restore"; | ||||
| import { restoreElements, restoreLibraryItems } from "./restore"; | ||||
| import { getNonDeletedElements } from "../element"; | ||||
| import type App from "../components/App"; | ||||
|  | ||||
| @@ -18,14 +18,16 @@ class Library { | ||||
|   }; | ||||
|  | ||||
|   restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { | ||||
|     const elements = getNonDeletedElements(restoreElements(libraryItem, null)); | ||||
|     return elements.length ? elements : null; | ||||
|     const elements = getNonDeletedElements( | ||||
|       restoreElements(libraryItem.elements, null), | ||||
|     ); | ||||
|     return elements.length ? { ...libraryItem, elements } : null; | ||||
|   }; | ||||
|  | ||||
|   /** imports library (currently merges, removing duplicates) */ | ||||
|   async importLibrary(blob: Blob) { | ||||
|   async importLibrary(blob: Blob, defaultStatus = "unpublished") { | ||||
|     const libraryFile = await loadLibraryFromBlob(blob); | ||||
|     if (!libraryFile || !libraryFile.library) { | ||||
|     if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @@ -37,17 +39,17 @@ class Library { | ||||
|       targetLibraryItem: LibraryItem, | ||||
|     ) => { | ||||
|       return !existingLibraryItems.find((libraryItem) => { | ||||
|         if (libraryItem.length !== targetLibraryItem.length) { | ||||
|         if (libraryItem.elements.length !== targetLibraryItem.elements.length) { | ||||
|           return false; | ||||
|         } | ||||
|  | ||||
|         // detect z-index difference by checking the excalidraw elements | ||||
|         // are in order | ||||
|         return libraryItem.every((libItemExcalidrawItem, idx) => { | ||||
|         return libraryItem.elements.every((libItemExcalidrawItem, idx) => { | ||||
|           return ( | ||||
|             libItemExcalidrawItem.id === targetLibraryItem[idx].id && | ||||
|             libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id && | ||||
|             libItemExcalidrawItem.versionNonce === | ||||
|               targetLibraryItem[idx].versionNonce | ||||
|               targetLibraryItem.elements[idx].versionNonce | ||||
|           ); | ||||
|         }); | ||||
|       }); | ||||
| @@ -55,15 +57,20 @@ class Library { | ||||
|  | ||||
|     const existingLibraryItems = await this.loadLibrary(); | ||||
|  | ||||
|     const filtered = libraryFile.library!.reduce((acc, libraryItem) => { | ||||
|       const restoredItem = this.restoreLibraryItem(libraryItem); | ||||
|     const library = libraryFile.libraryItems || libraryFile.library || []; | ||||
|     const restoredLibItems = restoreLibraryItems( | ||||
|       library, | ||||
|       defaultStatus as "published" | "unpublished", | ||||
|     ); | ||||
|     const filteredItems = []; | ||||
|     for (const item of restoredLibItems) { | ||||
|       const restoredItem = this.restoreLibraryItem(item as LibraryItem); | ||||
|       if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) { | ||||
|         acc.push(restoredItem); | ||||
|         filteredItems.push(restoredItem); | ||||
|       } | ||||
|       return acc; | ||||
|     }, [] as Mutable<LibraryItems>); | ||||
|     } | ||||
|  | ||||
|     await this.saveLibrary([...existingLibraryItems, ...filtered]); | ||||
|     await this.saveLibrary([...filteredItems, ...existingLibraryItems]); | ||||
|   } | ||||
|  | ||||
|   loadLibrary = (): Promise<LibraryItems> => { | ||||
| @@ -90,7 +97,7 @@ class Library { | ||||
|         this.libraryCache = JSON.parse(JSON.stringify(items)); | ||||
|  | ||||
|         resolve(items); | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         console.error(error); | ||||
|         resolve([]); | ||||
|       } | ||||
| @@ -105,7 +112,7 @@ class Library { | ||||
|       // immediately | ||||
|       this.libraryCache = JSON.parse(serializedItems); | ||||
|       await this.app.props.onLibraryChange?.(items); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       this.libraryCache = prevLibraryItems; | ||||
|       throw error; | ||||
|     } | ||||
|   | ||||
| @@ -3,7 +3,12 @@ import { | ||||
|   ExcalidrawSelectionElement, | ||||
|   FontFamilyValues, | ||||
| } from "../element/types"; | ||||
| import { AppState, BinaryFiles, NormalizedZoomValue } from "../types"; | ||||
| import { | ||||
|   AppState, | ||||
|   BinaryFiles, | ||||
|   LibraryItem, | ||||
|   NormalizedZoomValue, | ||||
| } from "../types"; | ||||
| import { ImportedDataState } from "./types"; | ||||
| import { | ||||
|   getElementMap, | ||||
| @@ -59,7 +64,7 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { | ||||
|  | ||||
| const restoreElementWithProperties = < | ||||
|   T extends ExcalidrawElement, | ||||
|   K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>> | ||||
|   K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>, | ||||
| >( | ||||
|   element: Required<T>, | ||||
|   extra: Pick< | ||||
| @@ -98,11 +103,11 @@ const restoreElementWithProperties = < | ||||
|     boundElementIds: element.boundElementIds ?? [], | ||||
|   }; | ||||
|  | ||||
|   return ({ | ||||
|   return { | ||||
|     ...base, | ||||
|     ...getNormalizedDimensions(base), | ||||
|     ...extra, | ||||
|   } as unknown) as T; | ||||
|   } as unknown as T; | ||||
| }; | ||||
|  | ||||
| const restoreElement = ( | ||||
| @@ -113,10 +118,9 @@ const restoreElement = ( | ||||
|       let fontSize = element.fontSize; | ||||
|       let fontFamily = element.fontFamily; | ||||
|       if ("font" in element) { | ||||
|         const [fontPx, _fontFamily]: [ | ||||
|           string, | ||||
|           string, | ||||
|         ] = (element as any).font.split(" "); | ||||
|         const [fontPx, _fontFamily]: [string, string] = ( | ||||
|           element as any | ||||
|         ).font.split(" "); | ||||
|         fontSize = parseInt(fontPx, 10); | ||||
|         fontFamily = getFontFamilyByName(_fontFamily); | ||||
|       } | ||||
| @@ -274,3 +278,30 @@ export const restore = ( | ||||
|     files: data?.files || {}, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const restoreLibraryItems = ( | ||||
|   libraryItems: NonOptional<ImportedDataState["libraryItems"]>, | ||||
|   defaultStatus: LibraryItem["status"], | ||||
| ) => { | ||||
|   const restoredItems: LibraryItem[] = []; | ||||
|   for (const item of libraryItems) { | ||||
|     // migrate older libraries | ||||
|     if (Array.isArray(item)) { | ||||
|       restoredItems.push({ | ||||
|         status: defaultStatus, | ||||
|         elements: item, | ||||
|         id: randomId(), | ||||
|         created: Date.now(), | ||||
|       }); | ||||
|     } else { | ||||
|       const _item = item as MarkOptional<LibraryItem, "id" | "status">; | ||||
|       restoredItems.push({ | ||||
|         ..._item, | ||||
|         id: _item.id || randomId(), | ||||
|         status: _item.status || defaultStatus, | ||||
|         created: _item.created || Date.now(), | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|   return restoredItems; | ||||
| }; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { ExcalidrawElement } from "../element/types"; | ||||
| import { AppState, BinaryFiles, LibraryItems } from "../types"; | ||||
| import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types"; | ||||
| import type { cleanAppStateForExport } from "../appState"; | ||||
|  | ||||
| export interface ExportedDataState { | ||||
| @@ -18,15 +18,18 @@ export interface ImportedDataState { | ||||
|   elements?: readonly ExcalidrawElement[] | null; | ||||
|   appState?: Readonly<Partial<AppState>> | null; | ||||
|   scrollToContent?: boolean; | ||||
|   libraryItems?: LibraryItems; | ||||
|   libraryItems?: LibraryItems | LibraryItems_v1; | ||||
|   files?: BinaryFiles; | ||||
| } | ||||
|  | ||||
| export interface ExportedLibraryData { | ||||
|   type: string; | ||||
|   version: number; | ||||
|   version: 2; | ||||
|   source: string; | ||||
|   library: LibraryItems; | ||||
|   libraryItems: LibraryItems; | ||||
| } | ||||
|  | ||||
| export interface ImportedLibraryData extends Partial<ExportedLibraryData> {} | ||||
| export interface ImportedLibraryData extends Partial<ExportedLibraryData> { | ||||
|   /** @deprecated v1 */ | ||||
|   library?: LibraryItems; | ||||
| } | ||||
|   | ||||
| @@ -137,14 +137,13 @@ export const bindOrUnbindSelectedElements = ( | ||||
| const maybeBindBindableElement = ( | ||||
|   bindableElement: NonDeleted<ExcalidrawBindableElement>, | ||||
| ): void => { | ||||
|   getElligibleElementsForBindableElementAndWhere( | ||||
|     bindableElement, | ||||
|   ).forEach(([linearElement, where]) => | ||||
|     bindOrUnbindLinearElement( | ||||
|       linearElement, | ||||
|       where === "end" ? "keep" : bindableElement, | ||||
|       where === "start" ? "keep" : bindableElement, | ||||
|     ), | ||||
|   getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( | ||||
|     ([linearElement, where]) => | ||||
|       bindOrUnbindLinearElement( | ||||
|         linearElement, | ||||
|         where === "end" ? "keep" : bindableElement, | ||||
|         where === "start" ? "keep" : bindableElement, | ||||
|       ), | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| @@ -293,9 +292,11 @@ export const updateBoundElements = ( | ||||
|   const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( | ||||
|     simultaneouslyUpdated, | ||||
|   ); | ||||
|   (Scene.getScene(changedElement)!.getNonDeletedElements( | ||||
|     boundElementIds, | ||||
|   ) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => { | ||||
|   ( | ||||
|     Scene.getScene(changedElement)!.getNonDeletedElements( | ||||
|       boundElementIds, | ||||
|     ) as NonDeleted<ExcalidrawLinearElement>[] | ||||
|   ).forEach((linearElement) => { | ||||
|     const bindableElement = changedElement as ExcalidrawBindableElement; | ||||
|     // In case the boundElementIds are stale | ||||
|     if (!doesNeedUpdate(linearElement, bindableElement)) { | ||||
| @@ -580,9 +581,11 @@ export const fixBindingsAfterDuplication = ( | ||||
|   }); | ||||
|  | ||||
|   // Update the linear elements | ||||
|   (sceneElements.filter(({ id }) => | ||||
|     allBoundElementIds.has(id), | ||||
|   ) as ExcalidrawLinearElement[]).forEach((element) => { | ||||
|   ( | ||||
|     sceneElements.filter(({ id }) => | ||||
|       allBoundElementIds.has(id), | ||||
|     ) as ExcalidrawLinearElement[] | ||||
|   ).forEach((element) => { | ||||
|     const { startBinding, endBinding } = element; | ||||
|     mutateElement(element, { | ||||
|       startBinding: newBindingAfterDuplication( | ||||
| @@ -642,17 +645,17 @@ export const fixBindingsAfterDeletion = ( | ||||
|       }); | ||||
|     } | ||||
|   }); | ||||
|   (sceneElements.filter(({ id }) => | ||||
|     boundElementIds.has(id), | ||||
|   ) as ExcalidrawLinearElement[]).forEach( | ||||
|     (element: ExcalidrawLinearElement) => { | ||||
|       const { startBinding, endBinding } = element; | ||||
|       mutateElement(element, { | ||||
|         startBinding: newBindingAfterDeletion(startBinding, deletedElementIds), | ||||
|         endBinding: newBindingAfterDeletion(endBinding, deletedElementIds), | ||||
|       }); | ||||
|     }, | ||||
|   ); | ||||
|   ( | ||||
|     sceneElements.filter(({ id }) => | ||||
|       boundElementIds.has(id), | ||||
|     ) as ExcalidrawLinearElement[] | ||||
|   ).forEach((element: ExcalidrawLinearElement) => { | ||||
|     const { startBinding, endBinding } = element; | ||||
|     mutateElement(element, { | ||||
|       startBinding: newBindingAfterDeletion(startBinding, deletedElementIds), | ||||
|       endBinding: newBindingAfterDeletion(endBinding, deletedElementIds), | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const newBindingAfterDeletion = ( | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { | ||||
|   ExcalidrawLinearElement, | ||||
|   Arrowhead, | ||||
|   ExcalidrawFreeDrawElement, | ||||
|   NonDeleted, | ||||
| } from "./types"; | ||||
| import { distance2d, rotate } from "../math"; | ||||
| import rough from "roughjs/bin/rough"; | ||||
| @@ -78,7 +79,7 @@ const getMinMaxXYFromCurvePathOps = ( | ||||
|       // move, bcurveTo, lineTo, and curveTo | ||||
|       if (op === "move") { | ||||
|         // change starting point | ||||
|         currentP = (data as unknown) as Point; | ||||
|         currentP = data as unknown as Point; | ||||
|         // move operation does not draw anything; so, it always | ||||
|         // returns false | ||||
|       } else if (op === "bcurveTo") { | ||||
| @@ -227,7 +228,7 @@ export const getArrowheadPoints = ( | ||||
|   const prevOp = ops[index - 1]; | ||||
|   let p0: Point = [0, 0]; | ||||
|   if (prevOp.op === "move") { | ||||
|     p0 = (prevOp.data as unknown) as Point; | ||||
|     p0 = prevOp.data as unknown as Point; | ||||
|   } else if (prevOp.op === "bcurveTo") { | ||||
|     p0 = [prevOp.data[4], prevOp.data[5]]; | ||||
|   } | ||||
| @@ -258,6 +259,7 @@ export const getArrowheadPoints = ( | ||||
|     arrow: 30, | ||||
|     bar: 15, | ||||
|     dot: 15, | ||||
|     triangle: 15, | ||||
|   }[arrowhead]; // pixels (will differ for each arrowhead) | ||||
|  | ||||
|   let length = 0; | ||||
| @@ -294,6 +296,7 @@ export const getArrowheadPoints = ( | ||||
|   const angle = { | ||||
|     arrow: 20, | ||||
|     bar: 90, | ||||
|     triangle: 25, | ||||
|   }[arrowhead]; // degrees | ||||
|  | ||||
|   // Return points | ||||
| @@ -511,3 +514,17 @@ export const getClosestElementBounds = ( | ||||
|  | ||||
|   return getElementBounds(closestElement); | ||||
| }; | ||||
|  | ||||
| export interface Box { | ||||
|   minX: number; | ||||
|   minY: number; | ||||
|   maxX: number; | ||||
|   maxY: number; | ||||
| } | ||||
|  | ||||
| export const getCommonBoundingBox = ( | ||||
|   elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], | ||||
| ): Box => { | ||||
|   const [minX, minY, maxX, maxY] = getCommonBounds(elements); | ||||
|   return { minX, minY, maxX, maxY }; | ||||
| }; | ||||
|   | ||||
| @@ -866,7 +866,7 @@ const hitTestRoughShape = ( | ||||
|     // move, bcurveTo, lineTo, and curveTo | ||||
|     if (op === "move") { | ||||
|       // change starting point | ||||
|       currentP = (data as unknown) as Point; | ||||
|       currentP = data as unknown as Point; | ||||
|       // move operation does not draw anything; so, it always | ||||
|       // returns false | ||||
|     } else if (op === "bcurveTo") { | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
| // ----------------------------------------------------------------------------- | ||||
|  | ||||
| import { MIME_TYPES, SVG_NS } from "../constants"; | ||||
| import { getDataURL } from "../data/blob"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppClassProperties, DataURL, BinaryFiles } from "../types"; | ||||
| import { isInitializedImageElement } from "./typeChecks"; | ||||
| @@ -63,7 +64,7 @@ export const updateImageCache = async ({ | ||||
|               const image = await imagePromise; | ||||
|  | ||||
|               imageCache.set(fileId, { ...data, image }); | ||||
|             } catch (error) { | ||||
|             } catch (error: any) { | ||||
|               erroredFiles.set(fileId, true); | ||||
|             } | ||||
|           })(), | ||||
| @@ -109,3 +110,81 @@ export const normalizeSVG = async (SVGString: string) => { | ||||
|     return svg.outerHTML; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * To improve perf, uses `createImageBitmap` is available. But there are | ||||
|  * quality issues across browsers, so don't use this API where quality matters. | ||||
|  */ | ||||
| export const speedyImageToCanvas = async (imageFile: Blob | File) => { | ||||
|   let imageSrc: HTMLImageElement | ImageBitmap; | ||||
|   if ( | ||||
|     typeof ImageBitmap !== "undefined" && | ||||
|     ImageBitmap.prototype && | ||||
|     ImageBitmap.prototype.close && | ||||
|     window.createImageBitmap | ||||
|   ) { | ||||
|     imageSrc = await window.createImageBitmap(imageFile); | ||||
|   } else { | ||||
|     imageSrc = await loadHTMLImageElement(await getDataURL(imageFile)); | ||||
|   } | ||||
|   const { width, height } = imageSrc; | ||||
|  | ||||
|   const canvas = document.createElement("canvas"); | ||||
|   canvas.height = height; | ||||
|   canvas.width = width; | ||||
|   const context = canvas.getContext("2d")!; | ||||
|   context.drawImage(imageSrc, 0, 0, width, height); | ||||
|  | ||||
|   if (typeof ImageBitmap !== "undefined" && imageSrc instanceof ImageBitmap) { | ||||
|     imageSrc.close(); | ||||
|   } | ||||
|  | ||||
|   return { canvas, context, width, height }; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Does its best at figuring out if an image (PNG) has any (semi)transparent | ||||
|  * pixels. If not PNG, always returns false. | ||||
|  */ | ||||
| export const hasTransparentPixels = async (imageFile: Blob | File) => { | ||||
|   if (imageFile.type !== MIME_TYPES.png) { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   const { findPngChunk, extractPngChunks } = await import("../data/image"); | ||||
|  | ||||
|   const buffer = await imageFile.arrayBuffer(); | ||||
|   const chunks = extractPngChunks(new Uint8Array(buffer)); | ||||
|  | ||||
|   // early exit if tRNS not found and IHDR states no support for alpha | ||||
|   // ----------------------------------------------------------------------- | ||||
|  | ||||
|   const IHDR = findPngChunk(chunks, "IHDR"); | ||||
|  | ||||
|   if ( | ||||
|     IHDR && | ||||
|     IHDR.data[9] !== 4 && | ||||
|     IHDR.data[9] !== 6 && | ||||
|     !findPngChunk(chunks, "tRNS") | ||||
|   ) { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   // otherwise loop through pixels to check if there's any actually | ||||
|   // (semi)transparent pixel | ||||
|   // ----------------------------------------------------------------------- | ||||
|  | ||||
|   const { width, height, context } = await speedyImageToCanvas(imageFile); | ||||
|   { | ||||
|     const { data } = context.getImageData(0, 0, width, height); | ||||
|     const len = data.byteLength; | ||||
|     let i = 3; | ||||
|     while (i <= len) { | ||||
|       if (data[i] !== 255) { | ||||
|         return true; | ||||
|       } | ||||
|       i += 4; | ||||
|     } | ||||
|   } | ||||
|   return false; | ||||
| }; | ||||
|   | ||||
| @@ -150,9 +150,8 @@ export class LinearElementEditor { | ||||
|           ) | ||||
|         : null; | ||||
|       binding = { | ||||
|         [activePointIndex === 0 | ||||
|           ? "startBindingElement" | ||||
|           : "endBindingElement"]: bindingElement, | ||||
|         [activePointIndex === 0 ? "startBindingElement" : "endBindingElement"]: | ||||
|           bindingElement, | ||||
|       }; | ||||
|     } | ||||
|     return { | ||||
| @@ -236,10 +235,8 @@ export class LinearElementEditor { | ||||
|       // from the end points of the `linearElement` - this is to allow disabling | ||||
|       // binding (which needs to happen at the point the user finishes moving | ||||
|       // the point). | ||||
|       const { | ||||
|         startBindingElement, | ||||
|         endBindingElement, | ||||
|       } = appState.editingLinearElement; | ||||
|       const { startBindingElement, endBindingElement } = | ||||
|         appState.editingLinearElement; | ||||
|       if (isBindingEnabled(appState) && isBindingElement(element)) { | ||||
|         bindOrUnbindLinearElement( | ||||
|           element, | ||||
|   | ||||
| @@ -464,16 +464,12 @@ export const resizeSingleElement = ( | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const [ | ||||
|     newBoundsX1, | ||||
|     newBoundsY1, | ||||
|     newBoundsX2, | ||||
|     newBoundsY2, | ||||
|   ] = getResizedElementAbsoluteCoords( | ||||
|     stateAtResizeStart, | ||||
|     eleNewWidth, | ||||
|     eleNewHeight, | ||||
|   ); | ||||
|   const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = | ||||
|     getResizedElementAbsoluteCoords( | ||||
|       stateAtResizeStart, | ||||
|       eleNewWidth, | ||||
|       eleNewHeight, | ||||
|     ); | ||||
|   const newBoundsWidth = newBoundsX2 - newBoundsX1; | ||||
|   const newBoundsHeight = newBoundsY2 - newBoundsY1; | ||||
|  | ||||
|   | ||||
| @@ -36,10 +36,8 @@ export const resizeTest = ( | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   const { | ||||
|     rotation: rotationTransformHandle, | ||||
|     ...transformHandles | ||||
|   } = getTransformHandles(element, zoom, pointerType); | ||||
|   const { rotation: rotationTransformHandle, ...transformHandles } = | ||||
|     getTransformHandles(element, zoom, pointerType); | ||||
|  | ||||
|   if ( | ||||
|     rotationTransformHandle && | ||||
| @@ -49,9 +47,8 @@ export const resizeTest = ( | ||||
|   } | ||||
|  | ||||
|   const filter = Object.keys(transformHandles).filter((key) => { | ||||
|     const transformHandle = transformHandles[ | ||||
|       key as Exclude<TransformHandleType, "rotation"> | ||||
|     ]!; | ||||
|     const transformHandle = | ||||
|       transformHandles[key as Exclude<TransformHandleType, "rotation">]!; | ||||
|     if (!transformHandle) { | ||||
|       return false; | ||||
|     } | ||||
| @@ -105,9 +102,8 @@ export const getTransformHandleTypeFromCoords = ( | ||||
|   ); | ||||
|  | ||||
|   const found = Object.keys(transformHandles).find((key) => { | ||||
|     const transformHandle = transformHandles[ | ||||
|       key as Exclude<TransformHandleType, "rotation"> | ||||
|     ]!; | ||||
|     const transformHandle = | ||||
|       transformHandles[key as Exclude<TransformHandleType, "rotation">]!; | ||||
|     return ( | ||||
|       transformHandle && | ||||
|       isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY) | ||||
|   | ||||
| @@ -108,6 +108,7 @@ export const textWysiwyg = ({ | ||||
|   editable.dataset.type = "wysiwyg"; | ||||
|   // prevent line wrapping on Safari | ||||
|   editable.wrap = "off"; | ||||
|   editable.classList.add("excalidraw-wysiwyg"); | ||||
|  | ||||
|   Object.assign(editable.style, { | ||||
|     position: "absolute", | ||||
|   | ||||
| @@ -17,9 +17,9 @@ export type TransformHandleDirection = | ||||
| export type TransformHandleType = TransformHandleDirection | "rotation"; | ||||
|  | ||||
| export type TransformHandle = [number, number, number, number]; | ||||
| export type TransformHandles = Partial< | ||||
|   { [T in TransformHandleType]: TransformHandle } | ||||
| >; | ||||
| export type TransformHandles = Partial<{ | ||||
|   [T in TransformHandleType]: TransformHandle; | ||||
| }>; | ||||
| export type MaybeTransformHandleType = TransformHandleType | false; | ||||
|  | ||||
| const transformHandleSizes: { [k in PointerType]: number } = { | ||||
|   | ||||
| @@ -129,7 +129,7 @@ export type PointBinding = { | ||||
|   gap: number; | ||||
| }; | ||||
|  | ||||
| export type Arrowhead = "arrow" | "bar" | "dot"; | ||||
| export type Arrowhead = "arrow" | "bar" | "dot" | "triangle"; | ||||
|  | ||||
| export type ExcalidrawLinearElement = _ExcalidrawElementBase & | ||||
|   Readonly<{ | ||||
|   | ||||
| @@ -23,3 +23,5 @@ export const FIREBASE_STORAGE_PREFIXES = { | ||||
|   shareLinkFiles: `/files/shareLinks`, | ||||
|   collabFiles: `/files/rooms`, | ||||
| }; | ||||
|  | ||||
| export const ROOM_ID_BYTES = 10; | ||||
|   | ||||
| @@ -8,10 +8,7 @@ import { | ||||
|   ExcalidrawElement, | ||||
|   InitializedExcalidrawImageElement, | ||||
| } from "../../element/types"; | ||||
| import { | ||||
|   getElementMap, | ||||
|   getSceneVersion, | ||||
| } from "../../packages/excalidraw/index"; | ||||
| import { getSceneVersion } from "../../packages/excalidraw/index"; | ||||
| import { Collaborator, Gesture } from "../../types"; | ||||
| import { | ||||
|   preventUnload, | ||||
| @@ -27,7 +24,6 @@ import { | ||||
|   SYNC_FULL_SCENE_INTERVAL_MS, | ||||
| } from "../app_constants"; | ||||
| import { | ||||
|   decryptAESGEM, | ||||
|   generateCollaborationLinkData, | ||||
|   getCollaborationLink, | ||||
|   SocketUpdateDataSource, | ||||
| @@ -63,7 +59,12 @@ import { | ||||
|   isImageElement, | ||||
|   isInitializedImageElement, | ||||
| } from "../../element/typeChecks"; | ||||
| import { mutateElement } from "../../element/mutateElement"; | ||||
| import { newElementWith } from "../../element/mutateElement"; | ||||
| import { | ||||
|   ReconciledElements, | ||||
|   reconcileElements as _reconcileElements, | ||||
| } from "./reconciliation"; | ||||
| import { decryptData } from "../../data/encryption"; | ||||
|  | ||||
| interface CollabState { | ||||
|   modalIsShown: boolean; | ||||
| @@ -87,10 +88,6 @@ export interface CollabAPI { | ||||
|   fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; | ||||
| } | ||||
|  | ||||
| type ReconciledElements = readonly ExcalidrawElement[] & { | ||||
|   _brand: "reconciledElements"; | ||||
| }; | ||||
|  | ||||
| interface Props { | ||||
|   excalidrawAPI: ExcalidrawImperativeAPI; | ||||
|   onRoomClose?: () => void; | ||||
| @@ -227,13 +224,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|   }); | ||||
|  | ||||
|   saveCollabRoomToFirebase = async ( | ||||
|     syncableElements: ExcalidrawElement[] = this.getSyncableElements( | ||||
|     syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements( | ||||
|       this.excalidrawAPI.getSceneElementsIncludingDeleted(), | ||||
|     ), | ||||
|   ) => { | ||||
|     try { | ||||
|       await saveToFirebase(this.portal, syncableElements); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|     } | ||||
|   }; | ||||
| @@ -244,6 +241,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|   }; | ||||
|  | ||||
|   closePortal = () => { | ||||
|     this.queueBroadcastAllElements.cancel(); | ||||
|     this.loadImageFiles.cancel(); | ||||
|  | ||||
|     this.saveCollabRoomToFirebase(); | ||||
|     if (window.confirm(t("alerts.collabStopOverridePrompt"))) { | ||||
|       window.history.pushState({}, APP_NAME, window.location.origin); | ||||
| @@ -256,7 +256,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|         .getSceneElementsIncludingDeleted() | ||||
|         .map((element) => { | ||||
|           if (isImageElement(element) && element.status === "saved") { | ||||
|             return mutateElement(element, { status: "pending" }, false); | ||||
|             return newElementWith(element, { status: "pending" }); | ||||
|           } | ||||
|           return element; | ||||
|         }); | ||||
| @@ -301,6 +301,27 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|     return await this.fileManager.getFiles(unfetchedImages); | ||||
|   }; | ||||
|  | ||||
|   private decryptPayload = async ( | ||||
|     iv: Uint8Array, | ||||
|     encryptedData: ArrayBuffer, | ||||
|     decryptionKey: string, | ||||
|   ) => { | ||||
|     try { | ||||
|       const decrypted = await decryptData(iv, encryptedData, decryptionKey); | ||||
|  | ||||
|       const decodedData = new TextDecoder("utf-8").decode( | ||||
|         new Uint8Array(decrypted), | ||||
|       ); | ||||
|       return JSON.parse(decodedData); | ||||
|     } catch (error) { | ||||
|       window.alert(t("alerts.decryptFailed")); | ||||
|       console.error(error); | ||||
|       return { | ||||
|         type: "INVALID_RESPONSE", | ||||
|       }; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private initializeSocketClient = async ( | ||||
|     existingRoomLinkData: null | { roomId: string; roomKey: string }, | ||||
|   ): Promise<ImportedDataState | null> => { | ||||
| @@ -347,18 +368,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|             scrollToContent: true, | ||||
|           }); | ||||
|         } | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         // log the error and move on. other peers will sync us the scene. | ||||
|         console.error(error); | ||||
|       } | ||||
|     } else { | ||||
|       const elements = this.excalidrawAPI.getSceneElements().map((element) => { | ||||
|         if (isImageElement(element) && element.status === "saved") { | ||||
|           return mutateElement( | ||||
|             element, | ||||
|             { status: "pending" }, | ||||
|             /* informMutation */ false, | ||||
|           ); | ||||
|           return newElementWith(element, { status: "pending" }); | ||||
|         } | ||||
|         return element; | ||||
|       }); | ||||
| @@ -392,10 +409,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|         if (!this.portal.roomKey) { | ||||
|           return; | ||||
|         } | ||||
|         const decryptedData = await decryptAESGEM( | ||||
|  | ||||
|         const decryptedData = await this.decryptPayload( | ||||
|           iv, | ||||
|           encryptedData, | ||||
|           this.portal.roomKey, | ||||
|           iv, | ||||
|         ); | ||||
|  | ||||
|         switch (decryptedData.type) { | ||||
| @@ -423,12 +441,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|             ); | ||||
|             break; | ||||
|           case "MOUSE_LOCATION": { | ||||
|             const { | ||||
|               pointer, | ||||
|               button, | ||||
|               username, | ||||
|               selectedElementIds, | ||||
|             } = decryptedData.payload; | ||||
|             const { pointer, button, username, selectedElementIds } = | ||||
|               decryptedData.payload; | ||||
|             const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = | ||||
|               decryptedData.payload.socketId || | ||||
|               // @ts-ignore legacy, see #2094 (#2097) | ||||
| @@ -484,74 +498,33 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|   }; | ||||
|  | ||||
|   private reconcileElements = ( | ||||
|     elements: readonly ExcalidrawElement[], | ||||
|     remoteElements: readonly ExcalidrawElement[], | ||||
|   ): ReconciledElements => { | ||||
|     const currentElements = this.getSceneElementsIncludingDeleted(); | ||||
|     // create a map of ids so we don't have to iterate | ||||
|     // over the array more than once. | ||||
|     const localElementMap = getElementMap(currentElements); | ||||
|  | ||||
|     const localElements = this.getSceneElementsIncludingDeleted(); | ||||
|     const appState = this.excalidrawAPI.getAppState(); | ||||
|  | ||||
|     // Reconcile | ||||
|     const newElements: readonly ExcalidrawElement[] = elements | ||||
|       .reduce((elements, element) => { | ||||
|         // if the remote element references one that's currently | ||||
|         // edited on local, skip it (it'll be added in the next step) | ||||
|         if ( | ||||
|           element.id === appState.editingElement?.id || | ||||
|           element.id === appState.resizingElement?.id || | ||||
|           element.id === appState.draggingElement?.id | ||||
|         ) { | ||||
|           return elements; | ||||
|         } | ||||
|  | ||||
|         if ( | ||||
|           localElementMap.hasOwnProperty(element.id) && | ||||
|           localElementMap[element.id].version > element.version | ||||
|         ) { | ||||
|           elements.push(localElementMap[element.id]); | ||||
|           delete localElementMap[element.id]; | ||||
|         } else if ( | ||||
|           localElementMap.hasOwnProperty(element.id) && | ||||
|           localElementMap[element.id].version === element.version && | ||||
|           localElementMap[element.id].versionNonce !== element.versionNonce | ||||
|         ) { | ||||
|           // resolve conflicting edits deterministically by taking the one with the lowest versionNonce | ||||
|           if (localElementMap[element.id].versionNonce < element.versionNonce) { | ||||
|             elements.push(localElementMap[element.id]); | ||||
|           } else { | ||||
|             // it should be highly unlikely that the two versionNonces are the same. if we are | ||||
|             // really worried about this, we can replace the versionNonce with the socket id. | ||||
|             elements.push(element); | ||||
|           } | ||||
|           delete localElementMap[element.id]; | ||||
|         } else { | ||||
|           elements.push(element); | ||||
|           delete localElementMap[element.id]; | ||||
|         } | ||||
|  | ||||
|         return elements; | ||||
|       }, [] as Mutable<typeof elements>) | ||||
|       // add local elements that weren't deleted or on remote | ||||
|       .concat(...Object.values(localElementMap)); | ||||
|     const reconciledElements = _reconcileElements( | ||||
|       localElements, | ||||
|       remoteElements, | ||||
|       appState, | ||||
|     ); | ||||
|  | ||||
|     // Avoid broadcasting to the rest of the collaborators the scene | ||||
|     // we just received! | ||||
|     // Note: this needs to be set before updating the scene as it | ||||
|     // synchronously calls render. | ||||
|     this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements)); | ||||
|     this.setLastBroadcastedOrReceivedSceneVersion( | ||||
|       getSceneVersion(reconciledElements), | ||||
|     ); | ||||
|  | ||||
|     return newElements as ReconciledElements; | ||||
|     return reconciledElements; | ||||
|   }; | ||||
|  | ||||
|   private loadImageFiles = throttle(async () => { | ||||
|     const { | ||||
|       loadedFiles, | ||||
|       erroredFiles, | ||||
|     } = await this.fetchImageFilesFromFirebase({ | ||||
|       elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(), | ||||
|     }); | ||||
|     const { loadedFiles, erroredFiles } = | ||||
|       await this.fetchImageFilesFromFirebase({ | ||||
|         elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(), | ||||
|       }); | ||||
|  | ||||
|     this.excalidrawAPI.addFiles(loadedFiles); | ||||
|  | ||||
| @@ -634,9 +607,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|  | ||||
|   setCollaborators(sockets: string[]) { | ||||
|     this.setState((state) => { | ||||
|       const collaborators: InstanceType< | ||||
|         typeof CollabWrapper | ||||
|       >["collaborators"] = new Map(); | ||||
|       const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] = | ||||
|         new Map(); | ||||
|       for (const socketId of sockets) { | ||||
|         if (this.collaborators.has(socketId)) { | ||||
|           collaborators.set(socketId, this.collaborators.get(socketId)!); | ||||
| @@ -681,11 +653,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|       getSceneVersion(elements) > | ||||
|       this.getLastBroadcastedOrReceivedSceneVersion() | ||||
|     ) { | ||||
|       this.portal.broadcastScene( | ||||
|         SCENE.UPDATE, | ||||
|         this.getSyncableElements(elements), | ||||
|         false, | ||||
|       ); | ||||
|       this.portal.broadcastScene(SCENE.UPDATE, elements, false); | ||||
|       this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); | ||||
|       this.queueBroadcastAllElements(); | ||||
|     } | ||||
| @@ -694,9 +662,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|   queueBroadcastAllElements = throttle(() => { | ||||
|     this.portal.broadcastScene( | ||||
|       SCENE.UPDATE, | ||||
|       this.getSyncableElements( | ||||
|         this.excalidrawAPI.getSceneElementsIncludingDeleted(), | ||||
|       ), | ||||
|       this.excalidrawAPI.getSceneElementsIncludingDeleted(), | ||||
|       true, | ||||
|     ); | ||||
|     const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion(); | ||||
| @@ -722,8 +688,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   isSyncableElement = (element: ExcalidrawElement) => { | ||||
|     return element.isDeleted || !isInvisiblySmallElement(element); | ||||
|   }; | ||||
|  | ||||
|   getSyncableElements = (elements: readonly ExcalidrawElement[]) => | ||||
|     elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el)); | ||||
|     elements.filter((element) => this.isSyncableElement(element)); | ||||
|  | ||||
|   /** PRIVATE. Use `this.getContextValue()` instead. */ | ||||
|   private contextValue: CollabAPI | null = null; | ||||
| @@ -740,7 +710,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> { | ||||
|     this.contextValue.initializeSocketClient = this.initializeSocketClient; | ||||
|     this.contextValue.onCollabButtonClick = this.onCollabButtonClick; | ||||
|     this.contextValue.broadcastElements = this.broadcastElements; | ||||
|     this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase; | ||||
|     this.contextValue.fetchImageFilesFromFirebase = | ||||
|       this.fetchImageFilesFromFirebase; | ||||
|     return this.contextValue; | ||||
|   }; | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,4 @@ | ||||
| import { | ||||
|   encryptAESGEM, | ||||
|   SocketUpdateData, | ||||
|   SocketUpdateDataSource, | ||||
| } from "../data"; | ||||
| import { SocketUpdateData, SocketUpdateDataSource } from "../data"; | ||||
|  | ||||
| import CollabWrapper from "./CollabWrapper"; | ||||
|  | ||||
| @@ -11,7 +7,9 @@ import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants"; | ||||
| import { UserIdleState } from "../../types"; | ||||
| import { trackEvent } from "../../analytics"; | ||||
| import { throttle } from "lodash"; | ||||
| import { mutateElement } from "../../element/mutateElement"; | ||||
| import { newElementWith } from "../../element/mutateElement"; | ||||
| import { BroadcastedExcalidrawElement } from "./reconciliation"; | ||||
| import { encryptData } from "../../data/encryption"; | ||||
|  | ||||
| class Portal { | ||||
|   collab: CollabWrapper; | ||||
| @@ -40,9 +38,7 @@ class Portal { | ||||
|     this.socket.on("new-user", async (_socketId: string) => { | ||||
|       this.broadcastScene( | ||||
|         SCENE.INIT, | ||||
|         this.collab.getSyncableElements( | ||||
|           this.collab.getSceneElementsIncludingDeleted(), | ||||
|         ), | ||||
|         this.collab.getSceneElementsIncludingDeleted(), | ||||
|         /* syncAll */ true, | ||||
|       ); | ||||
|     }); | ||||
| @@ -55,6 +51,7 @@ class Portal { | ||||
|     if (!this.socket) { | ||||
|       return; | ||||
|     } | ||||
|     this.queueFileUpload.flush(); | ||||
|     this.socket.close(); | ||||
|     this.socket = null; | ||||
|     this.roomId = null; | ||||
| @@ -79,12 +76,13 @@ class Portal { | ||||
|     if (this.isOpen()) { | ||||
|       const json = JSON.stringify(data); | ||||
|       const encoded = new TextEncoder().encode(json); | ||||
|       const encrypted = await encryptAESGEM(encoded, this.roomKey!); | ||||
|       this.socket!.emit( | ||||
|       const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded); | ||||
|  | ||||
|       this.socket?.emit( | ||||
|         volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER, | ||||
|         this.roomId, | ||||
|         encrypted.data, | ||||
|         encrypted.iv, | ||||
|         encryptedBuffer, | ||||
|         iv, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| @@ -95,12 +93,14 @@ class Portal { | ||||
|         elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(), | ||||
|         files: this.collab.excalidrawAPI.getFiles(), | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       this.collab.excalidrawAPI.updateScene({ | ||||
|         appState: { | ||||
|           errorMessage: error.message, | ||||
|         }, | ||||
|       }); | ||||
|     } catch (error: any) { | ||||
|       if (error.name !== "AbortError") { | ||||
|         this.collab.excalidrawAPI.updateScene({ | ||||
|           appState: { | ||||
|             errorMessage: error.message, | ||||
|           }, | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     this.collab.excalidrawAPI.updateScene({ | ||||
| @@ -111,11 +111,7 @@ class Portal { | ||||
|             // this will signal collaborators to pull image data from server | ||||
|             // (using mutation instead of newElementWith otherwise it'd break | ||||
|             // in-progress dragging) | ||||
|             return mutateElement( | ||||
|               element, | ||||
|               { status: "saved" }, | ||||
|               /* informMutation */ false, | ||||
|             ); | ||||
|             return newElementWith(element, { status: "saved" }); | ||||
|           } | ||||
|           return element; | ||||
|         }), | ||||
| @@ -124,24 +120,35 @@ class Portal { | ||||
|  | ||||
|   broadcastScene = async ( | ||||
|     sceneType: SCENE.INIT | SCENE.UPDATE, | ||||
|     syncableElements: ExcalidrawElement[], | ||||
|     allElements: readonly ExcalidrawElement[], | ||||
|     syncAll: boolean, | ||||
|   ) => { | ||||
|     if (sceneType === SCENE.INIT && !syncAll) { | ||||
|       throw new Error("syncAll must be true when sending SCENE.INIT"); | ||||
|     } | ||||
|  | ||||
|     if (!syncAll) { | ||||
|       // sync out only the elements we think we need to to save bandwidth. | ||||
|       // periodically we'll resync the whole thing to make sure no one diverges | ||||
|       // due to a dropped message (server goes down etc). | ||||
|       syncableElements = syncableElements.filter( | ||||
|         (syncableElement) => | ||||
|           !this.broadcastedElementVersions.has(syncableElement.id) || | ||||
|           syncableElement.version > | ||||
|             this.broadcastedElementVersions.get(syncableElement.id)!, | ||||
|       ); | ||||
|     } | ||||
|     // sync out only the elements we think we need to to save bandwidth. | ||||
|     // periodically we'll resync the whole thing to make sure no one diverges | ||||
|     // due to a dropped message (server goes down etc). | ||||
|     const syncableElements = allElements.reduce( | ||||
|       (acc, element: BroadcastedExcalidrawElement, idx, elements) => { | ||||
|         if ( | ||||
|           (syncAll || | ||||
|             !this.broadcastedElementVersions.has(element.id) || | ||||
|             element.version > | ||||
|               this.broadcastedElementVersions.get(element.id)!) && | ||||
|           this.collab.isSyncableElement(element) | ||||
|         ) { | ||||
|           acc.push({ | ||||
|             ...element, | ||||
|             // z-index info for the reconciler | ||||
|             parent: idx === 0 ? "^" : elements[idx - 1]?.id, | ||||
|           }); | ||||
|         } | ||||
|         return acc; | ||||
|       }, | ||||
|       [] as BroadcastedExcalidrawElement[], | ||||
|     ); | ||||
|  | ||||
|     const data: SocketUpdateDataSource[typeof sceneType] = { | ||||
|       type: sceneType, | ||||
| @@ -201,8 +208,8 @@ class Portal { | ||||
|           socketId: this.socket.id, | ||||
|           pointer: payload.pointer, | ||||
|           button: payload.button || "up", | ||||
|           selectedElementIds: this.collab.excalidrawAPI.getAppState() | ||||
|             .selectedElementIds, | ||||
|           selectedElementIds: | ||||
|             this.collab.excalidrawAPI.getAppState().selectedElementIds, | ||||
|           username: this.collab.state.username, | ||||
|         }, | ||||
|       }; | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|     margin: 1.5em 0; | ||||
|   } | ||||
|  | ||||
|   .RoomDialog-link { | ||||
|   input.RoomDialog-link { | ||||
|     color: var(--text-primary-color); | ||||
|     min-width: 0; | ||||
|     flex: 1 1 auto; | ||||
| @@ -14,8 +14,6 @@ | ||||
|     display: inline-block; | ||||
|     cursor: pointer; | ||||
|     border: none; | ||||
|     height: 2.5rem; | ||||
|     line-height: 2.5rem; | ||||
|     padding: 0 0.5rem; | ||||
|     white-space: nowrap; | ||||
|     border-radius: var(--space-factor); | ||||
| @@ -55,10 +53,7 @@ | ||||
|       margin-top: 0.5em; | ||||
|       margin-inline-start: 0; | ||||
|     } | ||||
|     height: 2.5rem; | ||||
|     font-size: 1em; | ||||
|     line-height: 1.5; | ||||
|     padding: 0 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .RoomDialog-sessionStartButtonContainer { | ||||
|   | ||||
| @@ -53,7 +53,7 @@ const RoomDialog = ({ | ||||
|   const copyRoomLink = async () => { | ||||
|     try { | ||||
|       await copyTextToSystemClipboard(activeRoomLink); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       setErrorMessage(error.message); | ||||
|     } | ||||
|     if (roomLinkInput.current) { | ||||
| @@ -68,7 +68,7 @@ const RoomDialog = ({ | ||||
|         text: t("roomDialog.shareTitle"), | ||||
|         url: activeRoomLink, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       // Just ignore. | ||||
|     } | ||||
|   }; | ||||
| @@ -124,6 +124,7 @@ const RoomDialog = ({ | ||||
|                 /> | ||||
|               </Stack.Row> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 value={activeRoomLink} | ||||
|                 readOnly={true} | ||||
|                 className="RoomDialog-link" | ||||
| @@ -136,6 +137,7 @@ const RoomDialog = ({ | ||||
|                 {t("labels.yourName")} | ||||
|               </label> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 id="username" | ||||
|                 value={username || ""} | ||||
|                 className="RoomDialog-username TextInput" | ||||
|   | ||||
							
								
								
									
										161
									
								
								src/excalidraw-app/collab/reconciliation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								src/excalidraw-app/collab/reconciliation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | ||||
| import { ExcalidrawElement } from "../../element/types"; | ||||
| import { AppState } from "../../types"; | ||||
|  | ||||
| export type ReconciledElements = readonly ExcalidrawElement[] & { | ||||
|   _brand: "reconciledElements"; | ||||
| }; | ||||
|  | ||||
| export type BroadcastedExcalidrawElement = ExcalidrawElement & { | ||||
|   parent?: string; | ||||
| }; | ||||
|  | ||||
| const shouldDiscardRemoteElement = ( | ||||
|   localAppState: AppState, | ||||
|   local: ExcalidrawElement | undefined, | ||||
|   remote: BroadcastedExcalidrawElement, | ||||
| ): boolean => { | ||||
|   if ( | ||||
|     local && | ||||
|     // local element is being edited | ||||
|     (local.id === localAppState.editingElement?.id || | ||||
|       local.id === localAppState.resizingElement?.id || | ||||
|       local.id === localAppState.draggingElement?.id || | ||||
|       // local element is newer | ||||
|       local.version > remote.version || | ||||
|       // resolve conflicting edits deterministically by taking the one with | ||||
|       // the lowest versionNonce | ||||
|       (local.version === remote.version && | ||||
|         local.versionNonce < remote.versionNonce)) | ||||
|   ) { | ||||
|     return true; | ||||
|   } | ||||
|   return false; | ||||
| }; | ||||
|  | ||||
| const getElementsMapWithIndex = <T extends ExcalidrawElement>( | ||||
|   elements: readonly T[], | ||||
| ) => | ||||
|   elements.reduce( | ||||
|     ( | ||||
|       acc: { | ||||
|         [key: string]: [element: T, index: number] | undefined; | ||||
|       }, | ||||
|       element: T, | ||||
|       idx, | ||||
|     ) => { | ||||
|       acc[element.id] = [element, idx]; | ||||
|       return acc; | ||||
|     }, | ||||
|     {}, | ||||
|   ); | ||||
|  | ||||
| export const reconcileElements = ( | ||||
|   localElements: readonly ExcalidrawElement[], | ||||
|   remoteElements: readonly BroadcastedExcalidrawElement[], | ||||
|   localAppState: AppState, | ||||
| ): ReconciledElements => { | ||||
|   const localElementsData = | ||||
|     getElementsMapWithIndex<ExcalidrawElement>(localElements); | ||||
|  | ||||
|   const reconciledElements: ExcalidrawElement[] = localElements.slice(); | ||||
|  | ||||
|   const duplicates = new WeakMap<ExcalidrawElement, true>(); | ||||
|  | ||||
|   let cursor = 0; | ||||
|   let offset = 0; | ||||
|  | ||||
|   let remoteElementIdx = -1; | ||||
|   for (const remoteElement of remoteElements) { | ||||
|     remoteElementIdx++; | ||||
|  | ||||
|     const local = localElementsData[remoteElement.id]; | ||||
|  | ||||
|     if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { | ||||
|       if (remoteElement.parent) { | ||||
|         delete remoteElement.parent; | ||||
|       } | ||||
|  | ||||
|       continue; | ||||
|     } | ||||
|  | ||||
|     if (local) { | ||||
|       // mark for removal since it'll be replaced with the remote element | ||||
|       duplicates.set(local[0], true); | ||||
|     } | ||||
|  | ||||
|     // parent may not be defined in case the remote client is running an older | ||||
|     // excalidraw version | ||||
|     const parent = | ||||
|       remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null; | ||||
|  | ||||
|     if (parent != null) { | ||||
|       delete remoteElement.parent; | ||||
|  | ||||
|       // ^ indicates the element is the first in elements array | ||||
|       if (parent === "^") { | ||||
|         offset++; | ||||
|         if (cursor === 0) { | ||||
|           reconciledElements.unshift(remoteElement); | ||||
|           localElementsData[remoteElement.id] = [ | ||||
|             remoteElement, | ||||
|             cursor - offset, | ||||
|           ]; | ||||
|         } else { | ||||
|           reconciledElements.splice(cursor + 1, 0, remoteElement); | ||||
|           localElementsData[remoteElement.id] = [ | ||||
|             remoteElement, | ||||
|             cursor + 1 - offset, | ||||
|           ]; | ||||
|           cursor++; | ||||
|         } | ||||
|       } else { | ||||
|         let idx = localElementsData[parent] | ||||
|           ? localElementsData[parent]![1] | ||||
|           : null; | ||||
|         if (idx != null) { | ||||
|           idx += offset; | ||||
|         } | ||||
|         if (idx != null && idx >= cursor) { | ||||
|           reconciledElements.splice(idx + 1, 0, remoteElement); | ||||
|           offset++; | ||||
|           localElementsData[remoteElement.id] = [ | ||||
|             remoteElement, | ||||
|             idx + 1 - offset, | ||||
|           ]; | ||||
|           cursor = idx + 1; | ||||
|         } else if (idx != null) { | ||||
|           reconciledElements.splice(cursor + 1, 0, remoteElement); | ||||
|           offset++; | ||||
|           localElementsData[remoteElement.id] = [ | ||||
|             remoteElement, | ||||
|             cursor + 1 - offset, | ||||
|           ]; | ||||
|           cursor++; | ||||
|         } else { | ||||
|           reconciledElements.push(remoteElement); | ||||
|           localElementsData[remoteElement.id] = [ | ||||
|             remoteElement, | ||||
|             reconciledElements.length - 1 - offset, | ||||
|           ]; | ||||
|         } | ||||
|       } | ||||
|       // no parent z-index information, local element exists → replace in place | ||||
|     } else if (local) { | ||||
|       reconciledElements[local[1]] = remoteElement; | ||||
|       localElementsData[remoteElement.id] = [remoteElement, local[1]]; | ||||
|       // otherwise push to the end | ||||
|     } else { | ||||
|       reconciledElements.push(remoteElement); | ||||
|       localElementsData[remoteElement.id] = [ | ||||
|         remoteElement, | ||||
|         reconciledElements.length - 1 - offset, | ||||
|       ]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const ret: readonly ExcalidrawElement[] = reconciledElements.filter( | ||||
|     (element) => !duplicates.has(element), | ||||
|   ); | ||||
|  | ||||
|   return ret as ReconciledElements; | ||||
| }; | ||||
| @@ -93,9 +93,11 @@ export const ExportToExcalidrawPlus: React.FC<{ | ||||
|         onClick={async () => { | ||||
|           try { | ||||
|             await exportToExcalidrawPlus(elements, appState, files); | ||||
|           } catch (error) { | ||||
|           } catch (error: any) { | ||||
|             console.error(error); | ||||
|             onError(new Error(t("exportDialog.excalidrawplus_exportError"))); | ||||
|             if (error.name !== "AbortError") { | ||||
|               onError(new Error(t("exportDialog.excalidrawplus_exportError"))); | ||||
|             } | ||||
|           } | ||||
|         }} | ||||
|       /> | ||||
|   | ||||
| @@ -14,9 +14,8 @@ export const GitHubCorner = React.memo( | ||||
|       className="rtl-mirror" | ||||
|       style={{ | ||||
|         marginTop: "calc(var(--space-factor) * -1)", | ||||
|         [dir === "rtl" | ||||
|           ? "marginLeft" | ||||
|           : "marginRight"]: "calc(var(--space-factor) * -1)", | ||||
|         [dir === "rtl" ? "marginLeft" : "marginRight"]: | ||||
|           "calc(var(--space-factor) * -1)", | ||||
|       }} | ||||
|     > | ||||
|       <a | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { compressData } from "../../data/encode"; | ||||
| import { mutateElement } from "../../element/mutateElement"; | ||||
| import { newElementWith } from "../../element/mutateElement"; | ||||
| import { isInitializedImageElement } from "../../element/typeChecks"; | ||||
| import { | ||||
|   ExcalidrawElement, | ||||
| @@ -31,15 +31,11 @@ export class FileManager { | ||||
|     getFiles, | ||||
|     saveFiles, | ||||
|   }: { | ||||
|     getFiles: ( | ||||
|       fileIds: FileId[], | ||||
|     ) => Promise<{ | ||||
|     getFiles: (fileIds: FileId[]) => Promise<{ | ||||
|       loadedFiles: BinaryFileData[]; | ||||
|       erroredFiles: Map<FileId, true>; | ||||
|     }>; | ||||
|     saveFiles: (data: { | ||||
|       addedFiles: Map<FileId, BinaryFileData>; | ||||
|     }) => Promise<{ | ||||
|     saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{ | ||||
|       savedFiles: Map<FileId, true>; | ||||
|       erroredFiles: Map<FileId, true>; | ||||
|     }>; | ||||
| @@ -203,11 +199,7 @@ export const encodeFilesForUpload = async ({ | ||||
|     }); | ||||
|  | ||||
|     if (buffer.byteLength > maxBytes) { | ||||
|       throw new Error( | ||||
|         t("errors.fileTooBig", { | ||||
|           maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`, | ||||
|         }), | ||||
|       ); | ||||
|       throw new Error(t("errors.fileTooBig")); | ||||
|     } | ||||
|  | ||||
|     processedFiles.push({ | ||||
| @@ -235,13 +227,9 @@ export const updateStaleImageStatuses = (params: { | ||||
|           isInitializedImageElement(element) && | ||||
|           params.erroredFiles.has(element.fileId) | ||||
|         ) { | ||||
|           return mutateElement( | ||||
|             element, | ||||
|             { | ||||
|               status: "error", | ||||
|             }, | ||||
|             false, | ||||
|           ); | ||||
|           return newElementWith(element, { | ||||
|             status: "error", | ||||
|           }); | ||||
|         } | ||||
|         return element; | ||||
|       }), | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import { restoreElements } from "../../data/restore"; | ||||
| import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types"; | ||||
| import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; | ||||
| import { decompressData } from "../../data/encode"; | ||||
| import { getImportedKey, createIV } from "../../data/encryption"; | ||||
| import { encryptData, decryptData } from "../../data/encryption"; | ||||
| import { MIME_TYPES } from "../../constants"; | ||||
|  | ||||
| // private | ||||
| @@ -13,9 +13,8 @@ import { MIME_TYPES } from "../../constants"; | ||||
|  | ||||
| const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG); | ||||
|  | ||||
| let firebasePromise: Promise< | ||||
|   typeof import("firebase/app").default | ||||
| > | null = null; | ||||
| let firebasePromise: Promise<typeof import("firebase/app").default> | null = | ||||
|   null; | ||||
| let firestorePromise: Promise<any> | null | true = null; | ||||
| let firebaseStoragePromise: Promise<any> | null | true = null; | ||||
|  | ||||
| @@ -29,7 +28,7 @@ const _loadFirebase = async () => { | ||||
|   if (!isFirebaseInitialized) { | ||||
|     try { | ||||
|       firebase.initializeApp(FIREBASE_CONFIG); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       // trying initialize again throws. Usually this is harmless, and happens | ||||
|       // mainly in dev (HMR) | ||||
|       if (error.code === "app/duplicate-app") { | ||||
| @@ -93,20 +92,11 @@ const encryptElements = async ( | ||||
|   key: string, | ||||
|   elements: readonly ExcalidrawElement[], | ||||
| ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => { | ||||
|   const importedKey = await getImportedKey(key, "encrypt"); | ||||
|   const iv = createIV(); | ||||
|   const json = JSON.stringify(elements); | ||||
|   const encoded = new TextEncoder().encode(json); | ||||
|   const ciphertext = await window.crypto.subtle.encrypt( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       iv, | ||||
|     }, | ||||
|     importedKey, | ||||
|     encoded, | ||||
|   ); | ||||
|   const { encryptedBuffer, iv } = await encryptData(key, encoded); | ||||
|  | ||||
|   return { ciphertext, iv }; | ||||
|   return { ciphertext: encryptedBuffer, iv }; | ||||
| }; | ||||
|  | ||||
| const decryptElements = async ( | ||||
| @@ -114,16 +104,7 @@ const decryptElements = async ( | ||||
|   iv: Uint8Array, | ||||
|   ciphertext: ArrayBuffer | Uint8Array, | ||||
| ): Promise<readonly ExcalidrawElement[]> => { | ||||
|   const importedKey = await getImportedKey(key, "decrypt"); | ||||
|   const decrypted = await window.crypto.subtle.decrypt( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       iv, | ||||
|     }, | ||||
|     importedKey, | ||||
|     ciphertext, | ||||
|   ); | ||||
|  | ||||
|   const decrypted = await decryptData(iv, ciphertext, key); | ||||
|   const decodedData = new TextDecoder("utf-8").decode( | ||||
|     new Uint8Array(decrypted), | ||||
|   ); | ||||
| @@ -173,7 +154,7 @@ export const saveFilesToFirebase = async ({ | ||||
|             }, | ||||
|           ); | ||||
|         savedFiles.set(id, true); | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         erroredFiles.set(id, true); | ||||
|       } | ||||
|     }), | ||||
| @@ -294,8 +275,10 @@ export const loadFilesFromFirebase = async ( | ||||
|             dataURL, | ||||
|             created: metadata?.created || Date.now(), | ||||
|           }); | ||||
|         } else { | ||||
|           erroredFiles.set(id, true); | ||||
|         } | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         erroredFiles.set(id, true); | ||||
|         console.error(error); | ||||
|       } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { | ||||
|   createIV, | ||||
|   decryptData, | ||||
|   encryptData, | ||||
|   generateEncryptionKey, | ||||
|   getImportedKey, | ||||
|   IV_LENGTH_BYTES, | ||||
| } from "../../data/encryption"; | ||||
| import { serializeAsJSON } from "../../data/json"; | ||||
| @@ -16,20 +16,18 @@ import { | ||||
|   BinaryFiles, | ||||
|   UserIdleState, | ||||
| } from "../../types"; | ||||
| import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; | ||||
| import { bytesToHexString } from "../../utils"; | ||||
| import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants"; | ||||
| import { encodeFilesForUpload } from "./FileManager"; | ||||
| import { saveFilesToFirebase } from "./firebase"; | ||||
|  | ||||
| const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2); | ||||
|  | ||||
| const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL; | ||||
| const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL; | ||||
| const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL; | ||||
|  | ||||
| const generateRandomID = async () => { | ||||
|   const arr = new Uint8Array(10); | ||||
|   window.crypto.getRandomValues(arr); | ||||
|   return Array.from(arr, byteToHex).join(""); | ||||
| const generateRoomId = async () => { | ||||
|   const buffer = new Uint8Array(ROOM_ID_BYTES); | ||||
|   window.crypto.getRandomValues(buffer); | ||||
|   return bytesToHexString(buffer); | ||||
| }; | ||||
|  | ||||
| export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL; | ||||
| @@ -78,57 +76,10 @@ export type SocketUpdateDataIncoming = | ||||
|       type: "INVALID_RESPONSE"; | ||||
|     }; | ||||
|  | ||||
| export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & { | ||||
|   _brand: "socketUpdateData"; | ||||
| }; | ||||
|  | ||||
| export const encryptAESGEM = async ( | ||||
|   data: Uint8Array, | ||||
|   key: string, | ||||
| ): Promise<EncryptedData> => { | ||||
|   const importedKey = await getImportedKey(key, "encrypt"); | ||||
|   const iv = createIV(); | ||||
|   return { | ||||
|     data: await window.crypto.subtle.encrypt( | ||||
|       { | ||||
|         name: "AES-GCM", | ||||
|         iv, | ||||
|       }, | ||||
|       importedKey, | ||||
|       data, | ||||
|     ), | ||||
|     iv, | ||||
| export type SocketUpdateData = | ||||
|   SocketUpdateDataSource[keyof SocketUpdateDataSource] & { | ||||
|     _brand: "socketUpdateData"; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const decryptAESGEM = async ( | ||||
|   data: ArrayBuffer, | ||||
|   key: string, | ||||
|   iv: Uint8Array, | ||||
| ): Promise<SocketUpdateDataIncoming> => { | ||||
|   try { | ||||
|     const importedKey = await getImportedKey(key, "decrypt"); | ||||
|     const decrypted = await window.crypto.subtle.decrypt( | ||||
|       { | ||||
|         name: "AES-GCM", | ||||
|         iv, | ||||
|       }, | ||||
|       importedKey, | ||||
|       data, | ||||
|     ); | ||||
|  | ||||
|     const decodedData = new TextDecoder("utf-8").decode( | ||||
|       new Uint8Array(decrypted), | ||||
|     ); | ||||
|     return JSON.parse(decodedData); | ||||
|   } catch (error) { | ||||
|     window.alert(t("alerts.decryptFailed")); | ||||
|     console.error(error); | ||||
|   } | ||||
|   return { | ||||
|     type: "INVALID_RESPONSE", | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const getCollaborationLinkData = (link: string) => { | ||||
|   const hash = new URL(link).hash; | ||||
| @@ -141,7 +92,7 @@ export const getCollaborationLinkData = (link: string) => { | ||||
| }; | ||||
|  | ||||
| export const generateCollaborationLinkData = async () => { | ||||
|   const roomId = await generateRandomID(); | ||||
|   const roomId = await generateRoomId(); | ||||
|   const roomKey = await generateEncryptionKey(); | ||||
|  | ||||
|   if (!roomKey) { | ||||
| @@ -158,66 +109,42 @@ export const getCollaborationLink = (data: { | ||||
|   return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`; | ||||
| }; | ||||
|  | ||||
| export const decryptImported = async ( | ||||
|   iv: ArrayBuffer | Uint8Array, | ||||
|   encrypted: ArrayBuffer, | ||||
|   privateKey: string, | ||||
| ): Promise<ArrayBuffer> => { | ||||
|   const key = await getImportedKey(privateKey, "decrypt"); | ||||
|   return window.crypto.subtle.decrypt( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       iv, | ||||
|     }, | ||||
|     key, | ||||
|     encrypted, | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const importFromBackend = async ( | ||||
|   id: string | null, | ||||
|   privateKey?: string | null, | ||||
|   id: string, | ||||
|   privateKey: string, | ||||
| ): Promise<ImportedDataState> => { | ||||
|   try { | ||||
|     const response = await fetch( | ||||
|       privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`, | ||||
|     ); | ||||
|     const response = await fetch(`${BACKEND_V2_GET}${id}`); | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       window.alert(t("alerts.importBackendFailed")); | ||||
|       return {}; | ||||
|     } | ||||
|     let data: ImportedDataState; | ||||
|     if (privateKey) { | ||||
|       const buffer = await response.arrayBuffer(); | ||||
|     const buffer = await response.arrayBuffer(); | ||||
|  | ||||
|       let decrypted: ArrayBuffer; | ||||
|       try { | ||||
|         // Buffer should contain both the IV (fixed length) and encrypted data | ||||
|         const iv = buffer.slice(0, IV_LENGTH_BYTES); | ||||
|         const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength); | ||||
|         decrypted = await decryptImported(iv, encrypted, privateKey); | ||||
|       } catch (error) { | ||||
|         // Fixed IV (old format, backward compatibility) | ||||
|         const fixedIv = new Uint8Array(IV_LENGTH_BYTES); | ||||
|         decrypted = await decryptImported(fixedIv, buffer, privateKey); | ||||
|       } | ||||
|  | ||||
|       // We need to convert the decrypted array buffer to a string | ||||
|       const string = new window.TextDecoder("utf-8").decode( | ||||
|         new Uint8Array(decrypted), | ||||
|       ); | ||||
|       data = JSON.parse(string); | ||||
|     } else { | ||||
|       // Legacy format | ||||
|       data = await response.json(); | ||||
|     let decrypted: ArrayBuffer; | ||||
|     try { | ||||
|       // Buffer should contain both the IV (fixed length) and encrypted data | ||||
|       const iv = buffer.slice(0, IV_LENGTH_BYTES); | ||||
|       const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength); | ||||
|       decrypted = await decryptData(new Uint8Array(iv), encrypted, privateKey); | ||||
|     } catch (error: any) { | ||||
|       // Fixed IV (old format, backward compatibility) | ||||
|       const fixedIv = new Uint8Array(IV_LENGTH_BYTES); | ||||
|       decrypted = await decryptData(fixedIv, buffer, privateKey); | ||||
|     } | ||||
|  | ||||
|     // We need to convert the decrypted array buffer to a string | ||||
|     const string = new window.TextDecoder("utf-8").decode( | ||||
|       new Uint8Array(decrypted), | ||||
|     ); | ||||
|     const data: ImportedDataState = JSON.parse(string); | ||||
|  | ||||
|     return { | ||||
|       elements: data.elements || null, | ||||
|       appState: data.appState || null, | ||||
|     }; | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     window.alert(t("alerts.importBackendFailed")); | ||||
|     console.error(error); | ||||
|     return {}; | ||||
| @@ -233,7 +160,7 @@ export const loadScene = async ( | ||||
|   localDataState: ImportedDataState | undefined | null, | ||||
| ) => { | ||||
|   let data; | ||||
|   if (id != null) { | ||||
|   if (id != null && privateKey != null) { | ||||
|     // the private key is used to decrypt the content from the server, take | ||||
|     // extra care not to leak it | ||||
|     data = restore( | ||||
| @@ -264,29 +191,12 @@ export const exportToBackend = async ( | ||||
|   const json = serializeAsJSON(elements, appState, files, "database"); | ||||
|   const encoded = new TextEncoder().encode(json); | ||||
|  | ||||
|   const cryptoKey = await window.crypto.subtle.generateKey( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       length: 128, | ||||
|     }, | ||||
|     true, // extractable | ||||
|     ["encrypt", "decrypt"], | ||||
|   ); | ||||
|   const cryptoKey = await generateEncryptionKey("cryptoKey"); | ||||
|  | ||||
|   const iv = createIV(); | ||||
|   // We use symmetric encryption. AES-GCM is the recommended algorithm and | ||||
|   // includes checks that the ciphertext has not been modified by an attacker. | ||||
|   const encrypted = await window.crypto.subtle.encrypt( | ||||
|     { | ||||
|       name: "AES-GCM", | ||||
|       iv, | ||||
|     }, | ||||
|     cryptoKey, | ||||
|     encoded, | ||||
|   ); | ||||
|   const { encryptedBuffer, iv } = await encryptData(cryptoKey, encoded); | ||||
|  | ||||
|   // Concatenate IV with encrypted data (IV does not have to be secret). | ||||
|   const payloadBlob = new Blob([iv.buffer, encrypted]); | ||||
|   const payloadBlob = new Blob([iv.buffer, encryptedBuffer]); | ||||
|   const payload = await new Response(payloadBlob).arrayBuffer(); | ||||
|  | ||||
|   // We use jwk encoding to be able to extract just the base64 encoded key. | ||||
| @@ -332,7 +242,7 @@ export const exportToBackend = async ( | ||||
|     } else { | ||||
|       window.alert(t("alerts.couldNotCreateShareableLink")); | ||||
|     } | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error(error); | ||||
|     window.alert(t("alerts.couldNotCreateShareableLink")); | ||||
|   } | ||||
|   | ||||
| @@ -20,7 +20,7 @@ export const saveUsernameToLocalStorage = (username: string) => { | ||||
|       STORAGE_KEYS.LOCAL_STORAGE_COLLAB, | ||||
|       JSON.stringify({ username }), | ||||
|     ); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     // Unable to access window.localStorage | ||||
|     console.error(error); | ||||
|   } | ||||
| @@ -32,7 +32,7 @@ export const importUsernameFromLocalStorage = (): string | null => { | ||||
|     if (data) { | ||||
|       return JSON.parse(data).username; | ||||
|     } | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     // Unable to access localStorage | ||||
|     console.error(error); | ||||
|   } | ||||
| @@ -53,7 +53,7 @@ export const saveToLocalStorage = ( | ||||
|       STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, | ||||
|       JSON.stringify(clearAppStateForLocalStorage(appState)), | ||||
|     ); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     // Unable to access window.localStorage | ||||
|     console.error(error); | ||||
|   } | ||||
| @@ -66,7 +66,7 @@ export const importFromLocalStorage = () => { | ||||
|   try { | ||||
|     savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); | ||||
|     savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     // Unable to access localStorage | ||||
|     console.error(error); | ||||
|   } | ||||
| @@ -75,7 +75,7 @@ export const importFromLocalStorage = () => { | ||||
|   if (savedElements) { | ||||
|     try { | ||||
|       elements = clearElementsForLocalStorage(JSON.parse(savedElements)); | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       // Do nothing because elements array is already empty | ||||
|     } | ||||
| @@ -90,7 +90,7 @@ export const importFromLocalStorage = () => { | ||||
|           JSON.parse(savedState) as Partial<AppState>, | ||||
|         ), | ||||
|       }; | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       // Do nothing because appState is already null | ||||
|     } | ||||
| @@ -103,7 +103,7 @@ export const getElementsStorageSize = () => { | ||||
|     const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); | ||||
|     const elementsSize = elements?.length || 0; | ||||
|     return elementsSize; | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error(error); | ||||
|     return 0; | ||||
|   } | ||||
| @@ -122,7 +122,7 @@ export const getTotalStorageSize = () => { | ||||
|     const librarySize = library?.length || 0; | ||||
|  | ||||
|     return appStateSize + collabSize + librarySize + getElementsStorageSize(); | ||||
|   } catch (error) { | ||||
|   } catch (error: any) { | ||||
|     console.error(error); | ||||
|     return 0; | ||||
|   } | ||||
|   | ||||
| @@ -64,7 +64,7 @@ import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus"; | ||||
|  | ||||
| import { getMany, set, del, keys, createStore } from "idb-keyval"; | ||||
| import { FileManager, updateStaleImageStatuses } from "./data/FileManager"; | ||||
| import { mutateElement } from "../element/mutateElement"; | ||||
| import { newElementWith } from "../element/mutateElement"; | ||||
| import { isInitializedImageElement } from "../element/typeChecks"; | ||||
| import { loadFilesFromFirebase } from "./data/firebase"; | ||||
|  | ||||
| @@ -109,7 +109,7 @@ const localFileStorage = new FileManager({ | ||||
|         try { | ||||
|           await set(id, fileData, filesStore); | ||||
|           savedFiles.set(id, true); | ||||
|         } catch (error) { | ||||
|         } catch (error: any) { | ||||
|           console.error(error); | ||||
|           erroredFiles.set(id, true); | ||||
|         } | ||||
| @@ -163,7 +163,7 @@ const initializeScene = async (opts: { | ||||
|   const searchParams = new URLSearchParams(window.location.search); | ||||
|   const id = searchParams.get("id"); | ||||
|   const jsonBackendMatch = window.location.hash.match( | ||||
|     /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, | ||||
|     /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/, | ||||
|   ); | ||||
|   const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); | ||||
|  | ||||
| @@ -184,10 +184,7 @@ const initializeScene = async (opts: { | ||||
|       // otherwise, prompt whether user wants to override current scene | ||||
|       window.confirm(t("alerts.loadSceneOverridePrompt")) | ||||
|     ) { | ||||
|       // Backwards compatibility with legacy url format | ||||
|       if (id) { | ||||
|         scene = await loadScene(id, null, localDataState); | ||||
|       } else if (jsonBackendMatch) { | ||||
|       if (jsonBackendMatch) { | ||||
|         scene = await loadScene( | ||||
|           jsonBackendMatch[1], | ||||
|           jsonBackendMatch[2], | ||||
| @@ -228,7 +225,7 @@ const initializeScene = async (opts: { | ||||
|       ) { | ||||
|         return { scene: data, isExternalScene }; | ||||
|       } | ||||
|     } catch (error) { | ||||
|     } catch (error: any) { | ||||
|       return { | ||||
|         scene: { | ||||
|           appState: { | ||||
| @@ -276,7 +273,10 @@ const PlusLinkJSX = ( | ||||
|  | ||||
| const ExcalidrawWrapper = () => { | ||||
|   const [errorMessage, setErrorMessage] = useState(""); | ||||
|   const currentLangCode = languageDetector.detect() || defaultLang.code; | ||||
|   let currentLangCode = languageDetector.detect() || defaultLang.code; | ||||
|   if (Array.isArray(currentLangCode)) { | ||||
|     currentLangCode = currentLangCode[0]; | ||||
|   } | ||||
|   const [langCode, setLangCode] = useState(currentLangCode); | ||||
|  | ||||
|   // initial state | ||||
| @@ -286,7 +286,8 @@ const ExcalidrawWrapper = () => { | ||||
|     promise: ResolvablePromise<ImportedDataState | null>; | ||||
|   }>({ promise: null! }); | ||||
|   if (!initialStatePromiseRef.current.promise) { | ||||
|     initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>(); | ||||
|     initialStatePromiseRef.current.promise = | ||||
|       resolvablePromise<ImportedDataState | null>(); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -296,10 +297,8 @@ const ExcalidrawWrapper = () => { | ||||
|     }, VERSION_TIMEOUT); | ||||
|   }, []); | ||||
|  | ||||
|   const [ | ||||
|     excalidrawAPI, | ||||
|     excalidrawRefCallback, | ||||
|   ] = useCallbackRefState<ExcalidrawImperativeAPI>(); | ||||
|   const [excalidrawAPI, excalidrawRefCallback] = | ||||
|     useCallbackRefState<ExcalidrawImperativeAPI>(); | ||||
|  | ||||
|   const collabAPI = useContext(CollabContext)?.api; | ||||
|  | ||||
| @@ -378,8 +377,8 @@ const ExcalidrawWrapper = () => { | ||||
|           JSON.parse( | ||||
|             localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, | ||||
|           ) || []; | ||||
|       } catch (e) { | ||||
|         console.error(e); | ||||
|       } catch (error: any) { | ||||
|         console.error(error); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
| @@ -460,16 +459,17 @@ const ExcalidrawWrapper = () => { | ||||
|         if (excalidrawAPI) { | ||||
|           let didChange = false; | ||||
|  | ||||
|           let pendingImageElement = appState.pendingImageElement; | ||||
|           const elements = excalidrawAPI | ||||
|             .getSceneElementsIncludingDeleted() | ||||
|             .map((element) => { | ||||
|               if (localFileStorage.shouldUpdateImageElementStatus(element)) { | ||||
|                 didChange = true; | ||||
|                 return mutateElement( | ||||
|                   element, | ||||
|                   { status: "saved" }, | ||||
|                   /* informMutation */ false, | ||||
|                 ); | ||||
|                 const newEl = newElementWith(element, { status: "saved" }); | ||||
|                 if (pendingImageElement === element) { | ||||
|                   pendingImageElement = newEl; | ||||
|                 } | ||||
|                 return newEl; | ||||
|               } | ||||
|               return element; | ||||
|             }); | ||||
| @@ -477,6 +477,9 @@ const ExcalidrawWrapper = () => { | ||||
|           if (didChange) { | ||||
|             excalidrawAPI.updateScene({ | ||||
|               elements, | ||||
|               appState: { | ||||
|                 pendingImageElement, | ||||
|               }, | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
| @@ -505,7 +508,7 @@ const ExcalidrawWrapper = () => { | ||||
|           }, | ||||
|           files, | ||||
|         ); | ||||
|       } catch (error) { | ||||
|       } catch (error: any) { | ||||
|         if (error.name !== "AbortError") { | ||||
|           const { width, height } = canvas; | ||||
|           console.error(error, { width, height }); | ||||
|   | ||||
| @@ -67,7 +67,7 @@ export const nvector = (value: number = 0, index: number = 0): NVector => { | ||||
|   if (value !== 0) { | ||||
|     result[index] = value; | ||||
|   } | ||||
|   return (result as unknown) as NVector; | ||||
|   return result as unknown as NVector; | ||||
| }; | ||||
|  | ||||
| const STRING_EPSILON = 0.000001; | ||||
|   | ||||
| @@ -36,7 +36,7 @@ export const orthogonalThrough = (against: Point, intersection: Point): Line => | ||||
| export const parallel = (line: Line, distance: number): Line => { | ||||
|   const result = line.slice(); | ||||
|   result[1] -= distance; | ||||
|   return (result as unknown) as Line; | ||||
|   return result as unknown as Line; | ||||
| }; | ||||
|  | ||||
| export const parallelThrough = (line: Line, point: Point): Line => | ||||
|   | ||||
							
								
								
									
										27
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,3 +1,5 @@ | ||||
| // import type {PngChunk} from "./types"; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||
| interface Document { | ||||
|   fonts?: { | ||||
| @@ -19,7 +21,6 @@ interface Window { | ||||
| // https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts | ||||
| declare namespace NodeJS { | ||||
|   interface ProcessEnv { | ||||
|     readonly REACT_APP_BACKEND_V1_GET_URL: string; | ||||
|     readonly REACT_APP_BACKEND_V2_GET_URL: string; | ||||
|     readonly REACT_APP_BACKEND_V2_POST_URL: string; | ||||
|     readonly REACT_APP_SOCKET_SERVER_URL: string; | ||||
| @@ -49,13 +50,12 @@ type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> & | ||||
|  | ||||
| type MarkNonNullable<T, K extends keyof T> = { | ||||
|   [P in K]-?: P extends K ? NonNullable<T[P]> : T[P]; | ||||
| } & | ||||
|   { [P in keyof T]: T[P] }; | ||||
| } & { [P in keyof T]: T[P] }; | ||||
|  | ||||
| type NonOptional<T> = Exclude<T, undefined>; | ||||
|  | ||||
| // PNG encoding/decoding | ||||
| // ----------------------------------------------------------------------------- | ||||
| type TEXtChunk = { name: "tEXt"; data: Uint8Array }; | ||||
|  | ||||
| declare module "png-chunk-text" { | ||||
|   function encode( | ||||
|     name: string, | ||||
| @@ -64,11 +64,11 @@ declare module "png-chunk-text" { | ||||
|   function decode(data: Uint8Array): { keyword: string; text: string }; | ||||
| } | ||||
| declare module "png-chunks-encode" { | ||||
|   function encode(chunks: TEXtChunk[]): Uint8Array; | ||||
|   function encode(chunks: import("./types").PngChunk[]): Uint8Array; | ||||
|   export = encode; | ||||
| } | ||||
| declare module "png-chunks-extract" { | ||||
|   function extract(buffer: Uint8Array): TEXtChunk[]; | ||||
|   function extract(buffer: Uint8Array): import("./types").PngChunk[]; | ||||
|   export = extract; | ||||
| } | ||||
| // ----------------------------------------------------------------------------- | ||||
| @@ -102,19 +102,26 @@ declare module "*.scss"; | ||||
| // (due to TS structural typing) | ||||
| // https://github.com/microsoft/TypeScript/issues/31311#issuecomment-490690695 | ||||
| interface ArrayBuffer { | ||||
|   private _brand?: "ArrayBuffer"; | ||||
|   _brand?: "ArrayBuffer"; | ||||
| } | ||||
| interface Uint8Array { | ||||
|   private _brand?: "Uint8Array"; | ||||
|   _brand?: "Uint8Array"; | ||||
| } | ||||
| // --------------------------------------------------------------------------— | ||||
|  | ||||
| // https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848 | ||||
| declare module "image-blob-reduce" { | ||||
|   import { PicaResizeOptions } from "pica"; | ||||
|   import { PicaResizeOptions, Pica } from "pica"; | ||||
|   namespace ImageBlobReduce { | ||||
|     interface ImageBlobReduce { | ||||
|       toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>; | ||||
|       _create_blob( | ||||
|         this: { pica: Pica }, | ||||
|         env: { | ||||
|           out_canvas: HTMLCanvasElement; | ||||
|           out_blob: Blob; | ||||
|         }, | ||||
|       ): Promise<any>; | ||||
|     } | ||||
|  | ||||
|     interface ImageBlobReduceStatic { | ||||
|   | ||||
| @@ -105,7 +105,10 @@ const findPartsForData = (data: any, parts: string[]) => { | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const t = (path: string, replacement?: { [key: string]: string }) => { | ||||
| export const t = ( | ||||
|   path: string, | ||||
|   replacement?: { [key: string]: string | number }, | ||||
| ) => { | ||||
|   if (currentLang.code.startsWith(TEST_LANG_CODE)) { | ||||
|     const name = replacement | ||||
|       ? `${path}(${JSON.stringify(replacement).slice(1, -1)})` | ||||
| @@ -123,7 +126,7 @@ export const t = (path: string, replacement?: { [key: string]: string }) => { | ||||
|  | ||||
|   if (replacement) { | ||||
|     for (const key in replacement) { | ||||
|       translation = translation.replace(`{{${key}}}`, replacement[key]); | ||||
|       translation = translation.replace(`{{${key}}}`, String(replacement[key])); | ||||
|     } | ||||
|   } | ||||
|   return translation; | ||||
|   | ||||
| @@ -20,10 +20,6 @@ | ||||
|     "background": "الخلفية", | ||||
|     "fill": "التعبئة", | ||||
|     "strokeWidth": "سُمك الخط", | ||||
|     "strokeShape": "شكل الخط", | ||||
|     "strokeShape_gel": "قلم جل", | ||||
|     "strokeShape_fountain": "قلم رش", | ||||
|     "strokeShape_brush": "فرشاه", | ||||
|     "strokeStyle": "نمط الخط", | ||||
|     "strokeStyle_solid": "كامل", | ||||
|     "strokeStyle_dashed": "متقطع", | ||||
| @@ -39,6 +35,7 @@ | ||||
|     "arrowhead_arrow": "سهم", | ||||
|     "arrowhead_bar": "شريط", | ||||
|     "arrowhead_dot": "نقطة", | ||||
|     "arrowhead_triangle": "مثلث", | ||||
|     "fontSize": "حجم الخط", | ||||
|     "fontFamily": "نوع الخط", | ||||
|     "onlySelected": "المحدد فقط", | ||||
| @@ -136,7 +133,9 @@ | ||||
|     "darkMode": "الوضع المظلم", | ||||
|     "lightMode": "الوضع المضيء", | ||||
|     "zenMode": "وضع التأمل", | ||||
|     "exitZenMode": "إلغاء الوضع الليلى" | ||||
|     "exitZenMode": "إلغاء الوضع الليلى", | ||||
|     "cancel": "إلغاء", | ||||
|     "clear": "مسح" | ||||
|   }, | ||||
|   "alerts": { | ||||
|     "clearReset": "هذا سيُزيل كامل اللوحة. هل أنت متأكد؟", | ||||
| @@ -154,14 +153,22 @@ | ||||
|     "errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة", | ||||
|     "errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة", | ||||
|     "confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟", | ||||
|     "imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟", | ||||
|     "imageDoesNotContainScene": "", | ||||
|     "cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة", | ||||
|     "invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.", | ||||
|     "resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟", | ||||
|     "invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل." | ||||
|   }, | ||||
|   "errors": { | ||||
|     "unsupportedFileType": "نوع الملف غير مدعوم.", | ||||
|     "imageInsertError": "تعذر إدراج الصورة. حاول مرة أخرى لاحقاً...", | ||||
|     "fileTooBig": "الملف كبير جداً. الحد الأقصى المسموح به للحجم هو {{maxSize}}.", | ||||
|     "svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.", | ||||
|     "invalidSVGString": "" | ||||
|   }, | ||||
|   "toolBar": { | ||||
|     "selection": "تحديد", | ||||
|     "image": "إدراج صورة", | ||||
|     "rectangle": "مستطيل", | ||||
|     "diamond": "مضلع", | ||||
|     "ellipse": "دائرة", | ||||
| @@ -178,6 +185,7 @@ | ||||
|     "shapes": "الأشكال" | ||||
|   }, | ||||
|   "hints": { | ||||
|     "canvasPanning": "", | ||||
|     "linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد", | ||||
|     "freeDraw": "انقر واسحب، افرج عند الانتهاء", | ||||
|     "text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار", | ||||
| @@ -186,10 +194,12 @@ | ||||
|     "linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء", | ||||
|     "lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT", | ||||
|     "resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز", | ||||
|     "resizeImage": "يمكنك تغيير الحجم بحرية بالضغط بأستمرار على SHIFT،\nاضغط بأستمرار على ALT أيضا لتغيير الحجم من المركز", | ||||
|     "rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران", | ||||
|     "lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط", | ||||
|     "lineEditor_pointSelected": "اضغط على حذف لإزالة النقطة، Ctrl Or Cmd+D للتكرار، أو اسحب للانتقال", | ||||
|     "lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة" | ||||
|     "lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة", | ||||
|     "placeImage": "" | ||||
|   }, | ||||
|   "canvasError": { | ||||
|     "cannotShowPreview": "تعذر عرض المعاينة", | ||||
| @@ -256,6 +266,9 @@ | ||||
|     "zoomToFit": "تكبير للملائمة", | ||||
|     "zoomToSelection": "تكبير للعنصر المحدد" | ||||
|   }, | ||||
|   "clearCanvasDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "encrypted": { | ||||
|     "tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.", | ||||
|     "link": "مشاركة المدونة في التشفير من النهاية إلى النهاية في Excalidraw" | ||||
|   | ||||
| @@ -20,10 +20,6 @@ | ||||
|     "background": "Фон", | ||||
|     "fill": "Наситеност", | ||||
|     "strokeWidth": "Ширина на щриха", | ||||
|     "strokeShape": "", | ||||
|     "strokeShape_gel": "", | ||||
|     "strokeShape_fountain": "", | ||||
|     "strokeShape_brush": "", | ||||
|     "strokeStyle": "Стил на линия", | ||||
|     "strokeStyle_solid": "Плътен", | ||||
|     "strokeStyle_dashed": "Пунктир", | ||||
| @@ -39,6 +35,7 @@ | ||||
|     "arrowhead_arrow": "Стрелка", | ||||
|     "arrowhead_bar": "Връх на стрелката", | ||||
|     "arrowhead_dot": "Точка", | ||||
|     "arrowhead_triangle": "", | ||||
|     "fontSize": "Размер на шрифта", | ||||
|     "fontFamily": "Семейство шрифтове", | ||||
|     "onlySelected": "Само избраното", | ||||
| @@ -136,7 +133,9 @@ | ||||
|     "darkMode": "Тъмен режим", | ||||
|     "lightMode": "Светъл режим", | ||||
|     "zenMode": "Режим Zen", | ||||
|     "exitZenMode": "Спиране на Zen режим" | ||||
|     "exitZenMode": "Спиране на Zen режим", | ||||
|     "cancel": "", | ||||
|     "clear": "" | ||||
|   }, | ||||
|   "alerts": { | ||||
|     "clearReset": "Това ще изчисти цялото платно. Сигурни ли сте?", | ||||
| @@ -154,14 +153,22 @@ | ||||
|     "errorAddingToLibrary": "", | ||||
|     "errorRemovingFromLibrary": "", | ||||
|     "confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?", | ||||
|     "imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?", | ||||
|     "imageDoesNotContainScene": "", | ||||
|     "cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл", | ||||
|     "invalidSceneUrl": "", | ||||
|     "resetLibrary": "", | ||||
|     "invalidEncryptionKey": "" | ||||
|   }, | ||||
|   "errors": { | ||||
|     "unsupportedFileType": "", | ||||
|     "imageInsertError": "", | ||||
|     "fileTooBig": "", | ||||
|     "svgImageInsertError": "", | ||||
|     "invalidSVGString": "" | ||||
|   }, | ||||
|   "toolBar": { | ||||
|     "selection": "Селекция", | ||||
|     "image": "", | ||||
|     "rectangle": "Правоъгълник", | ||||
|     "diamond": "Диамант", | ||||
|     "ellipse": "Елипс", | ||||
| @@ -178,6 +185,7 @@ | ||||
|     "shapes": "Фигури" | ||||
|   }, | ||||
|   "hints": { | ||||
|     "canvasPanning": "", | ||||
|     "linearElement": "Кликнете, за да стартирате няколко точки, плъзнете за една линия", | ||||
|     "freeDraw": "Натиснете и влачете, пуснете като сте готови", | ||||
|     "text": "Подсказка: Можете също да добавите текст като натиснете някъде два път с инструмента за селекция", | ||||
| @@ -186,10 +194,12 @@ | ||||
|     "linearElementMulti": "Кликнете върху последната точка или натиснете Escape или Enter, за да завършите", | ||||
|     "lockAngle": "Можете да ограничите ъгъла, като задържите SHIFT", | ||||
|     "resize": "Може да ограничите при преоразмеряване като задържите SHIFT,\nзадръжте ALT за преоразмерите през центъра", | ||||
|     "resizeImage": "", | ||||
|     "rotate": "Можете да ограничите ъглите, като държите SHIFT, докато се въртите", | ||||
|     "lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките", | ||||
|     "lineEditor_pointSelected": "Натиснете Delete за да изтриете точка, CtrlOrCmd+D за дуплициране, или извлачете за да преместите", | ||||
|     "lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки" | ||||
|     "lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки", | ||||
|     "placeImage": "" | ||||
|   }, | ||||
|   "canvasError": { | ||||
|     "cannotShowPreview": "Невъзможност за показване на preview", | ||||
| @@ -256,6 +266,9 @@ | ||||
|     "zoomToFit": "Приближи докато се виждат всички елементи", | ||||
|     "zoomToSelection": "Приближи селекцията" | ||||
|   }, | ||||
|   "clearCanvasDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "encrypted": { | ||||
|     "tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.", | ||||
|     "link": "" | ||||
|   | ||||
							
								
								
									
										347
									
								
								src/locales/bn-BD.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										347
									
								
								src/locales/bn-BD.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,347 @@ | ||||
| { | ||||
|   "labels": { | ||||
|     "paste": "", | ||||
|     "pasteCharts": "", | ||||
|     "selectAll": "", | ||||
|     "multiSelect": "", | ||||
|     "moveCanvas": "", | ||||
|     "cut": "", | ||||
|     "copy": "", | ||||
|     "copyAsPng": "", | ||||
|     "copyAsSvg": "", | ||||
|     "bringForward": "", | ||||
|     "sendToBack": "", | ||||
|     "bringToFront": "", | ||||
|     "sendBackward": "", | ||||
|     "delete": "", | ||||
|     "copyStyles": "", | ||||
|     "pasteStyles": "", | ||||
|     "stroke": "", | ||||
|     "background": "", | ||||
|     "fill": "", | ||||
|     "strokeWidth": "", | ||||
|     "strokeStyle": "", | ||||
|     "strokeStyle_solid": "", | ||||
|     "strokeStyle_dashed": "", | ||||
|     "strokeStyle_dotted": "", | ||||
|     "sloppiness": "", | ||||
|     "opacity": "", | ||||
|     "textAlign": "", | ||||
|     "edges": "", | ||||
|     "sharp": "", | ||||
|     "round": "", | ||||
|     "arrowheads": "", | ||||
|     "arrowhead_none": "", | ||||
|     "arrowhead_arrow": "", | ||||
|     "arrowhead_bar": "", | ||||
|     "arrowhead_dot": "", | ||||
|     "arrowhead_triangle": "", | ||||
|     "fontSize": "", | ||||
|     "fontFamily": "", | ||||
|     "onlySelected": "", | ||||
|     "withBackground": "", | ||||
|     "exportEmbedScene": "", | ||||
|     "exportEmbedScene_details": "", | ||||
|     "addWatermark": "", | ||||
|     "handDrawn": "", | ||||
|     "normal": "", | ||||
|     "code": "", | ||||
|     "small": "", | ||||
|     "medium": "", | ||||
|     "large": "", | ||||
|     "veryLarge": "", | ||||
|     "solid": "", | ||||
|     "hachure": "", | ||||
|     "crossHatch": "", | ||||
|     "thin": "", | ||||
|     "bold": "", | ||||
|     "left": "", | ||||
|     "center": "", | ||||
|     "right": "", | ||||
|     "extraBold": "", | ||||
|     "architect": "", | ||||
|     "artist": "", | ||||
|     "cartoonist": "", | ||||
|     "fileTitle": "", | ||||
|     "colorPicker": "", | ||||
|     "canvasBackground": "", | ||||
|     "drawingCanvas": "", | ||||
|     "layers": "", | ||||
|     "actions": "", | ||||
|     "language": "", | ||||
|     "liveCollaboration": "", | ||||
|     "duplicateSelection": "", | ||||
|     "untitled": "", | ||||
|     "name": "", | ||||
|     "yourName": "", | ||||
|     "madeWithExcalidraw": "", | ||||
|     "group": "", | ||||
|     "ungroup": "", | ||||
|     "collaborators": "", | ||||
|     "showGrid": "", | ||||
|     "addToLibrary": "", | ||||
|     "removeFromLibrary": "", | ||||
|     "libraryLoadingMessage": "", | ||||
|     "libraries": "", | ||||
|     "loadingScene": "", | ||||
|     "align": "", | ||||
|     "alignTop": "", | ||||
|     "alignBottom": "", | ||||
|     "alignLeft": "", | ||||
|     "alignRight": "", | ||||
|     "centerVertically": "", | ||||
|     "centerHorizontally": "", | ||||
|     "distributeHorizontally": "", | ||||
|     "distributeVertically": "", | ||||
|     "flipHorizontal": "", | ||||
|     "flipVertical": "", | ||||
|     "viewMode": "", | ||||
|     "toggleExportColorScheme": "", | ||||
|     "share": "", | ||||
|     "showStroke": "", | ||||
|     "showBackground": "", | ||||
|     "toggleTheme": "" | ||||
|   }, | ||||
|   "buttons": { | ||||
|     "clearReset": "", | ||||
|     "exportJSON": "", | ||||
|     "exportImage": "", | ||||
|     "export": "", | ||||
|     "exportToPng": "", | ||||
|     "exportToSvg": "", | ||||
|     "copyToClipboard": "", | ||||
|     "copyPngToClipboard": "", | ||||
|     "scale": "", | ||||
|     "save": "", | ||||
|     "saveAs": "", | ||||
|     "load": "", | ||||
|     "getShareableLink": "", | ||||
|     "close": "", | ||||
|     "selectLanguage": "", | ||||
|     "scrollBackToContent": "", | ||||
|     "zoomIn": "", | ||||
|     "zoomOut": "", | ||||
|     "resetZoom": "", | ||||
|     "menu": "", | ||||
|     "done": "", | ||||
|     "edit": "", | ||||
|     "undo": "", | ||||
|     "redo": "", | ||||
|     "resetLibrary": "", | ||||
|     "createNewRoom": "", | ||||
|     "fullScreen": "", | ||||
|     "darkMode": "", | ||||
|     "lightMode": "", | ||||
|     "zenMode": "", | ||||
|     "exitZenMode": "", | ||||
|     "cancel": "", | ||||
|     "clear": "" | ||||
|   }, | ||||
|   "alerts": { | ||||
|     "clearReset": "", | ||||
|     "couldNotCreateShareableLink": "", | ||||
|     "couldNotCreateShareableLinkTooBig": "", | ||||
|     "couldNotLoadInvalidFile": "", | ||||
|     "importBackendFailed": "", | ||||
|     "cannotExportEmptyCanvas": "", | ||||
|     "couldNotCopyToClipboard": "", | ||||
|     "decryptFailed": "", | ||||
|     "uploadedSecurly": "", | ||||
|     "loadSceneOverridePrompt": "", | ||||
|     "collabStopOverridePrompt": "", | ||||
|     "errorLoadingLibrary": "", | ||||
|     "errorAddingToLibrary": "", | ||||
|     "errorRemovingFromLibrary": "", | ||||
|     "confirmAddLibrary": "", | ||||
|     "imageDoesNotContainScene": "", | ||||
|     "cannotRestoreFromImage": "", | ||||
|     "invalidSceneUrl": "", | ||||
|     "resetLibrary": "", | ||||
|     "invalidEncryptionKey": "" | ||||
|   }, | ||||
|   "errors": { | ||||
|     "unsupportedFileType": "", | ||||
|     "imageInsertError": "", | ||||
|     "fileTooBig": "", | ||||
|     "svgImageInsertError": "", | ||||
|     "invalidSVGString": "" | ||||
|   }, | ||||
|   "toolBar": { | ||||
|     "selection": "", | ||||
|     "image": "", | ||||
|     "rectangle": "", | ||||
|     "diamond": "", | ||||
|     "ellipse": "", | ||||
|     "arrow": "", | ||||
|     "line": "", | ||||
|     "freedraw": "", | ||||
|     "text": "", | ||||
|     "library": "", | ||||
|     "lock": "" | ||||
|   }, | ||||
|   "headings": { | ||||
|     "canvasActions": "", | ||||
|     "selectedShapeActions": "", | ||||
|     "shapes": "" | ||||
|   }, | ||||
|   "hints": { | ||||
|     "canvasPanning": "", | ||||
|     "linearElement": "", | ||||
|     "freeDraw": "", | ||||
|     "text": "", | ||||
|     "text_selected": "", | ||||
|     "text_editing": "", | ||||
|     "linearElementMulti": "", | ||||
|     "lockAngle": "", | ||||
|     "resize": "", | ||||
|     "resizeImage": "", | ||||
|     "rotate": "", | ||||
|     "lineEditor_info": "", | ||||
|     "lineEditor_pointSelected": "", | ||||
|     "lineEditor_nothingSelected": "", | ||||
|     "placeImage": "" | ||||
|   }, | ||||
|   "canvasError": { | ||||
|     "cannotShowPreview": "", | ||||
|     "canvasTooBig": "", | ||||
|     "canvasTooBigTip": "" | ||||
|   }, | ||||
|   "errorSplash": { | ||||
|     "headingMain_pre": "", | ||||
|     "headingMain_button": "", | ||||
|     "clearCanvasMessage": "", | ||||
|     "clearCanvasMessage_button": "", | ||||
|     "clearCanvasCaveat": "", | ||||
|     "trackedToSentry_pre": "", | ||||
|     "trackedToSentry_post": "", | ||||
|     "openIssueMessage_pre": "", | ||||
|     "openIssueMessage_button": "", | ||||
|     "openIssueMessage_post": "", | ||||
|     "sceneContent": "" | ||||
|   }, | ||||
|   "roomDialog": { | ||||
|     "desc_intro": "", | ||||
|     "desc_privacy": "", | ||||
|     "button_startSession": "", | ||||
|     "button_stopSession": "", | ||||
|     "desc_inProgressIntro": "", | ||||
|     "desc_shareLink": "", | ||||
|     "desc_exitSession": "", | ||||
|     "shareTitle": "" | ||||
|   }, | ||||
|   "errorDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "exportDialog": { | ||||
|     "disk_title": "", | ||||
|     "disk_details": "", | ||||
|     "disk_button": "", | ||||
|     "link_title": "", | ||||
|     "link_details": "", | ||||
|     "link_button": "", | ||||
|     "excalidrawplus_description": "", | ||||
|     "excalidrawplus_button": "", | ||||
|     "excalidrawplus_exportError": "" | ||||
|   }, | ||||
|   "helpDialog": { | ||||
|     "blog": "", | ||||
|     "click": "", | ||||
|     "curvedArrow": "", | ||||
|     "curvedLine": "", | ||||
|     "documentation": "", | ||||
|     "doubleClick": "", | ||||
|     "drag": "", | ||||
|     "editor": "", | ||||
|     "editSelectedShape": "", | ||||
|     "github": "", | ||||
|     "howto": "", | ||||
|     "or": "", | ||||
|     "preventBinding": "", | ||||
|     "shapes": "", | ||||
|     "shortcuts": "", | ||||
|     "textFinish": "", | ||||
|     "textNewLine": "", | ||||
|     "title": "", | ||||
|     "view": "", | ||||
|     "zoomToFit": "", | ||||
|     "zoomToSelection": "" | ||||
|   }, | ||||
|   "clearCanvasDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "encrypted": { | ||||
|     "tooltip": "", | ||||
|     "link": "" | ||||
|   }, | ||||
|   "stats": { | ||||
|     "angle": "", | ||||
|     "element": "", | ||||
|     "elements": "", | ||||
|     "height": "", | ||||
|     "scene": "", | ||||
|     "selected": "", | ||||
|     "storage": "", | ||||
|     "title": "", | ||||
|     "total": "", | ||||
|     "version": "", | ||||
|     "versionCopy": "", | ||||
|     "versionNotAvailable": "", | ||||
|     "width": "" | ||||
|   }, | ||||
|   "toast": { | ||||
|     "copyStyles": "", | ||||
|     "copyToClipboard": "", | ||||
|     "copyToClipboardAsPng": "", | ||||
|     "fileSaved": "", | ||||
|     "fileSavedToFilename": "", | ||||
|     "canvas": "", | ||||
|     "selection": "" | ||||
|   }, | ||||
|   "colors": { | ||||
|     "ffffff": "", | ||||
|     "f8f9fa": "", | ||||
|     "f1f3f5": "", | ||||
|     "fff5f5": "", | ||||
|     "fff0f6": "", | ||||
|     "f8f0fc": "", | ||||
|     "f3f0ff": "", | ||||
|     "edf2ff": "", | ||||
|     "e7f5ff": "", | ||||
|     "e3fafc": "", | ||||
|     "e6fcf5": "", | ||||
|     "ebfbee": "", | ||||
|     "f4fce3": "", | ||||
|     "fff9db": "", | ||||
|     "fff4e6": "", | ||||
|     "transparent": "", | ||||
|     "ced4da": "", | ||||
|     "868e96": "", | ||||
|     "fa5252": "", | ||||
|     "e64980": "", | ||||
|     "be4bdb": "", | ||||
|     "7950f2": "", | ||||
|     "4c6ef5": "", | ||||
|     "228be6": "", | ||||
|     "15aabf": "", | ||||
|     "12b886": "", | ||||
|     "40c057": "", | ||||
|     "82c91e": "", | ||||
|     "fab005": "", | ||||
|     "fd7e14": "", | ||||
|     "000000": "", | ||||
|     "343a40": "", | ||||
|     "495057": "", | ||||
|     "c92a2a": "", | ||||
|     "a61e4d": "", | ||||
|     "862e9c": "", | ||||
|     "5f3dc4": "", | ||||
|     "364fc7": "", | ||||
|     "1864ab": "", | ||||
|     "0b7285": "", | ||||
|     "087f5b": "", | ||||
|     "2b8a3e": "", | ||||
|     "5c940d": "", | ||||
|     "e67700": "", | ||||
|     "d9480f": "" | ||||
|   } | ||||
| } | ||||
| @@ -20,10 +20,6 @@ | ||||
|     "background": "Color del fons", | ||||
|     "fill": "Estil del fons", | ||||
|     "strokeWidth": "Amplada del traç", | ||||
|     "strokeShape": "Estil del traç", | ||||
|     "strokeShape_gel": "Bolígraf de gel", | ||||
|     "strokeShape_fountain": "Bolígraf de font", | ||||
|     "strokeShape_brush": "Bolígraf de raspall", | ||||
|     "strokeStyle": "Estil del traç", | ||||
|     "strokeStyle_solid": "Sòlid", | ||||
|     "strokeStyle_dashed": "Guions", | ||||
| @@ -39,6 +35,7 @@ | ||||
|     "arrowhead_arrow": "Fletxa", | ||||
|     "arrowhead_bar": "Barra", | ||||
|     "arrowhead_dot": "Punt", | ||||
|     "arrowhead_triangle": "", | ||||
|     "fontSize": "Mida de lletra", | ||||
|     "fontFamily": "Tipus de lletra", | ||||
|     "onlySelected": "Només seleccionats", | ||||
| @@ -136,7 +133,9 @@ | ||||
|     "darkMode": "Mode fosc", | ||||
|     "lightMode": "Mode clar", | ||||
|     "zenMode": "Mode zen", | ||||
|     "exitZenMode": "Surt de mode zen" | ||||
|     "exitZenMode": "Surt de mode zen", | ||||
|     "cancel": "", | ||||
|     "clear": "" | ||||
|   }, | ||||
|   "alerts": { | ||||
|     "clearReset": "S'esborrarà tot el llenç. N'esteu segur?", | ||||
| @@ -154,14 +153,22 @@ | ||||
|     "errorAddingToLibrary": "No s'ha pogut afegir l'element a la biblioteca", | ||||
|     "errorRemovingFromLibrary": "No s'ha pogut eliminar l'element de la biblioteca", | ||||
|     "confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?", | ||||
|     "imageDoesNotContainScene": "En aquest moment no s’admet la importació d’imatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada d’escena. Ho has activat durant l'exportació?", | ||||
|     "imageDoesNotContainScene": "", | ||||
|     "cannotRestoreFromImage": "L’escena no s’ha pogut restaurar des d’aquest fitxer d’imatge", | ||||
|     "invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.", | ||||
|     "resetLibrary": "Això buidarà la biblioteca. N'esteu segur?", | ||||
|     "invalidEncryptionKey": "" | ||||
|   }, | ||||
|   "errors": { | ||||
|     "unsupportedFileType": "", | ||||
|     "imageInsertError": "", | ||||
|     "fileTooBig": "", | ||||
|     "svgImageInsertError": "", | ||||
|     "invalidSVGString": "" | ||||
|   }, | ||||
|   "toolBar": { | ||||
|     "selection": "Selecció", | ||||
|     "image": "", | ||||
|     "rectangle": "Rectangle", | ||||
|     "diamond": "Rombe", | ||||
|     "ellipse": "El·lipse", | ||||
| @@ -178,6 +185,7 @@ | ||||
|     "shapes": "Formes" | ||||
|   }, | ||||
|   "hints": { | ||||
|     "canvasPanning": "", | ||||
|     "linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia", | ||||
|     "freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar", | ||||
|     "text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció", | ||||
| @@ -186,10 +194,12 @@ | ||||
|     "linearElementMulti": "Feu clic a l'ultim punt, o pitgeu Esc o Retorn per a finalitzar", | ||||
|     "lockAngle": "Per restringir els angles, mantenir premut el majúscul (SHIFT)", | ||||
|     "resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT", | ||||
|     "resizeImage": "", | ||||
|     "rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)", | ||||
|     "lineEditor_info": "Fes doble clic o premi Enter per editar punts", | ||||
|     "lineEditor_pointSelected": "Premeu Suprimir per a eliminar el punt, CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l", | ||||
|     "lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous" | ||||
|     "lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous", | ||||
|     "placeImage": "" | ||||
|   }, | ||||
|   "canvasError": { | ||||
|     "cannotShowPreview": "No es pot mostrar la previsualització", | ||||
| @@ -256,6 +266,9 @@ | ||||
|     "zoomToFit": "Zoom per veure tots els elements", | ||||
|     "zoomToSelection": "Zoom per veure la selecció" | ||||
|   }, | ||||
|   "clearCanvasDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "encrypted": { | ||||
|     "tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors d’Excalidraw no els veuran mai.", | ||||
|     "link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw" | ||||
| @@ -285,50 +298,50 @@ | ||||
|     "selection": "la selecció" | ||||
|   }, | ||||
|   "colors": { | ||||
|     "ffffff": "", | ||||
|     "f8f9fa": "", | ||||
|     "f1f3f5": "", | ||||
|     "fff5f5": "", | ||||
|     "fff0f6": "", | ||||
|     "ffffff": "Blanc", | ||||
|     "f8f9fa": "Gris 0", | ||||
|     "f1f3f5": "Gris 1", | ||||
|     "fff5f5": "Vermell 0", | ||||
|     "fff0f6": "Rosa 0", | ||||
|     "f8f0fc": "", | ||||
|     "f3f0ff": "", | ||||
|     "edf2ff": "", | ||||
|     "e7f5ff": "", | ||||
|     "e7f5ff": "Blau 0", | ||||
|     "e3fafc": "", | ||||
|     "e6fcf5": "", | ||||
|     "ebfbee": "", | ||||
|     "ebfbee": "Verd 0", | ||||
|     "f4fce3": "", | ||||
|     "fff9db": "", | ||||
|     "fff9db": "Groc 0", | ||||
|     "fff4e6": "", | ||||
|     "transparent": "", | ||||
|     "ced4da": "", | ||||
|     "868e96": "", | ||||
|     "fa5252": "", | ||||
|     "e64980": "", | ||||
|     "transparent": "Transparent", | ||||
|     "ced4da": "Gris 4", | ||||
|     "868e96": "Gris 6", | ||||
|     "fa5252": "Vermell 6", | ||||
|     "e64980": "Rosa 6", | ||||
|     "be4bdb": "", | ||||
|     "7950f2": "", | ||||
|     "4c6ef5": "", | ||||
|     "228be6": "", | ||||
|     "228be6": "Blau 6", | ||||
|     "15aabf": "", | ||||
|     "12b886": "", | ||||
|     "40c057": "", | ||||
|     "40c057": "Verd 6", | ||||
|     "82c91e": "", | ||||
|     "fab005": "", | ||||
|     "fd7e14": "", | ||||
|     "000000": "", | ||||
|     "343a40": "", | ||||
|     "495057": "", | ||||
|     "c92a2a": "", | ||||
|     "a61e4d": "", | ||||
|     "fab005": "Groc 6", | ||||
|     "fd7e14": "Taronja 6", | ||||
|     "000000": "Negre", | ||||
|     "343a40": "Gris 8", | ||||
|     "495057": "Gris 7", | ||||
|     "c92a2a": "Vermell 9", | ||||
|     "a61e4d": "Rosa 9", | ||||
|     "862e9c": "", | ||||
|     "5f3dc4": "", | ||||
|     "364fc7": "", | ||||
|     "1864ab": "", | ||||
|     "1864ab": "Blau 9", | ||||
|     "0b7285": "", | ||||
|     "087f5b": "", | ||||
|     "2b8a3e": "", | ||||
|     "2b8a3e": "Verd 9", | ||||
|     "5c940d": "", | ||||
|     "e67700": "", | ||||
|     "d9480f": "" | ||||
|     "e67700": "Groc 9", | ||||
|     "d9480f": "Taronja 9" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -20,10 +20,6 @@ | ||||
|     "background": "Pozadí", | ||||
|     "fill": "Výplň", | ||||
|     "strokeWidth": "Šířka obrysu", | ||||
|     "strokeShape": "Tvar tahu", | ||||
|     "strokeShape_gel": "Gelové pero", | ||||
|     "strokeShape_fountain": "Plnicí pero", | ||||
|     "strokeShape_brush": "Fixa", | ||||
|     "strokeStyle": "Styl tahu", | ||||
|     "strokeStyle_solid": "Plný", | ||||
|     "strokeStyle_dashed": "Čárkovaný", | ||||
| @@ -39,6 +35,7 @@ | ||||
|     "arrowhead_arrow": "Šipka", | ||||
|     "arrowhead_bar": "Kóta", | ||||
|     "arrowhead_dot": "Tečka", | ||||
|     "arrowhead_triangle": "", | ||||
|     "fontSize": "Velikost písma", | ||||
|     "fontFamily": "Písmo", | ||||
|     "onlySelected": "Pouze vybrané", | ||||
| @@ -136,7 +133,9 @@ | ||||
|     "darkMode": "Tmavý režim", | ||||
|     "lightMode": "Světlý režim", | ||||
|     "zenMode": "Zen mód", | ||||
|     "exitZenMode": "Opustit zen mód" | ||||
|     "exitZenMode": "Opustit zen mód", | ||||
|     "cancel": "", | ||||
|     "clear": "" | ||||
|   }, | ||||
|   "alerts": { | ||||
|     "clearReset": "", | ||||
| @@ -160,8 +159,16 @@ | ||||
|     "resetLibrary": "", | ||||
|     "invalidEncryptionKey": "" | ||||
|   }, | ||||
|   "errors": { | ||||
|     "unsupportedFileType": "", | ||||
|     "imageInsertError": "", | ||||
|     "fileTooBig": "", | ||||
|     "svgImageInsertError": "", | ||||
|     "invalidSVGString": "" | ||||
|   }, | ||||
|   "toolBar": { | ||||
|     "selection": "Výběr", | ||||
|     "image": "", | ||||
|     "rectangle": "Obdélník", | ||||
|     "diamond": "Diamant", | ||||
|     "ellipse": "Elipsa", | ||||
| @@ -178,6 +185,7 @@ | ||||
|     "shapes": "Tvary" | ||||
|   }, | ||||
|   "hints": { | ||||
|     "canvasPanning": "", | ||||
|     "linearElement": "", | ||||
|     "freeDraw": "", | ||||
|     "text": "", | ||||
| @@ -186,10 +194,12 @@ | ||||
|     "linearElementMulti": "", | ||||
|     "lockAngle": "", | ||||
|     "resize": "", | ||||
|     "resizeImage": "", | ||||
|     "rotate": "", | ||||
|     "lineEditor_info": "", | ||||
|     "lineEditor_pointSelected": "", | ||||
|     "lineEditor_nothingSelected": "" | ||||
|     "lineEditor_nothingSelected": "", | ||||
|     "placeImage": "" | ||||
|   }, | ||||
|   "canvasError": { | ||||
|     "cannotShowPreview": "", | ||||
| @@ -256,6 +266,9 @@ | ||||
|     "zoomToFit": "", | ||||
|     "zoomToSelection": "" | ||||
|   }, | ||||
|   "clearCanvasDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "encrypted": { | ||||
|     "tooltip": "", | ||||
|     "link": "" | ||||
|   | ||||
| @@ -20,10 +20,6 @@ | ||||
|     "background": "Baggrund", | ||||
|     "fill": "", | ||||
|     "strokeWidth": "Linjebredde", | ||||
|     "strokeShape": "Linjeform", | ||||
|     "strokeShape_gel": "", | ||||
|     "strokeShape_fountain": "", | ||||
|     "strokeShape_brush": "", | ||||
|     "strokeStyle": "", | ||||
|     "strokeStyle_solid": "", | ||||
|     "strokeStyle_dashed": "", | ||||
| @@ -39,6 +35,7 @@ | ||||
|     "arrowhead_arrow": "Pil", | ||||
|     "arrowhead_bar": "", | ||||
|     "arrowhead_dot": "", | ||||
|     "arrowhead_triangle": "", | ||||
|     "fontSize": "", | ||||
|     "fontFamily": "", | ||||
|     "onlySelected": "", | ||||
| @@ -136,7 +133,9 @@ | ||||
|     "darkMode": "Mørk tilstand", | ||||
|     "lightMode": "Lys baggrund", | ||||
|     "zenMode": "", | ||||
|     "exitZenMode": "" | ||||
|     "exitZenMode": "", | ||||
|     "cancel": "", | ||||
|     "clear": "" | ||||
|   }, | ||||
|   "alerts": { | ||||
|     "clearReset": "", | ||||
| @@ -160,8 +159,16 @@ | ||||
|     "resetLibrary": "", | ||||
|     "invalidEncryptionKey": "" | ||||
|   }, | ||||
|   "errors": { | ||||
|     "unsupportedFileType": "", | ||||
|     "imageInsertError": "", | ||||
|     "fileTooBig": "", | ||||
|     "svgImageInsertError": "", | ||||
|     "invalidSVGString": "" | ||||
|   }, | ||||
|   "toolBar": { | ||||
|     "selection": "", | ||||
|     "image": "", | ||||
|     "rectangle": "", | ||||
|     "diamond": "", | ||||
|     "ellipse": "", | ||||
| @@ -178,6 +185,7 @@ | ||||
|     "shapes": "" | ||||
|   }, | ||||
|   "hints": { | ||||
|     "canvasPanning": "", | ||||
|     "linearElement": "", | ||||
|     "freeDraw": "Klik og træk, slip når du er færdig", | ||||
|     "text": "", | ||||
| @@ -186,10 +194,12 @@ | ||||
|     "linearElementMulti": "", | ||||
|     "lockAngle": "", | ||||
|     "resize": "", | ||||
|     "resizeImage": "", | ||||
|     "rotate": "", | ||||
|     "lineEditor_info": "", | ||||
|     "lineEditor_pointSelected": "", | ||||
|     "lineEditor_nothingSelected": "" | ||||
|     "lineEditor_nothingSelected": "", | ||||
|     "placeImage": "" | ||||
|   }, | ||||
|   "canvasError": { | ||||
|     "cannotShowPreview": "", | ||||
| @@ -256,6 +266,9 @@ | ||||
|     "zoomToFit": "", | ||||
|     "zoomToSelection": "" | ||||
|   }, | ||||
|   "clearCanvasDialog": { | ||||
|     "title": "" | ||||
|   }, | ||||
|   "encrypted": { | ||||
|     "tooltip": "", | ||||
|     "link": "" | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user