Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c83a322b6 | |||
| 8b5657e1ce | |||
| 8b2b03347c | |||
| c2a8712593 | |||
| ff1d7728a0 | |||
| 98b5c37e45 | |||
| 7db63bd397 | |||
| 390da3fd0f | |||
| 104664cb9e | |||
| c822055ec8 | |||
| e15d73d94c | |||
| 80ee097b85 | |||
| 10048b877b | |||
| 5dd5862bb9 | |||
| 79989fedda | |||
| cecabc2196 | |||
| ed8fb40b63 | |||
| 6e391728fe | |||
| dfbfbc3f11 | |||
| 9b8ee3cacf | |||
| 4ea73d5d5b | |||
| 618f204ddd | |||
| 720588130c | |||
| f354788cd0 | |||
| 1c7ee09010 | |||
| ca15b0a008 | |||
| 650930c5ce | |||
| 79c0d59244 | |||
| cd50b5f7e9 | |||
| c0434957ff | |||
| 66aeaeb38d | |||
| 7f545e74ab | |||
| a776955579 | |||
| afa7932c9b | |||
| 1ee8d7d082 | |||
| 06db702b5d | |||
| b53d1f6f3e | |||
| ca1f3aa094 | |||
| 8ff159e76e | |||
| f9d2d537a2 | |||
| dac970c640 | |||
| 78bb3b3d84 | |||
| 7d9d7ad297 | |||
| de20a5e3ba | |||
| 289f72e45d | |||
| 6dd0e6a4c5 | |||
| 96b31ecbce | |||
| a132f154cb | |||
| 23acd8f6d1 | |||
| a60709f5ea | |||
| 896c476716 | |||
| 133ba19919 | |||
| a2136bfe9d | |||
| 6fbd64fdaa | |||
| cc4b0c2932 | |||
| b6ef953dc9 | |||
| 620b662085 | |||
| 1c11df011a | |||
| 59e9651547 | |||
| 1c48d122e0 | |||
| e4d02fb275 | |||
| 34a382ace9 | |||
| e60e48e67d | |||
| 84d1d9993c | |||
| 3ff9744b39 | |||
| b9abcc825a | |||
| 9679eaf74c | |||
| 284747d742 | |||
| 876f85fd7a | |||
| efc2bbed21 | |||
| 61d193b87b | |||
| 3989d6a989 | |||
| f6559b65ef | |||
| bc6b066c07 | |||
| 6370d517a2 | |||
| b8a37c42e4 | |||
| 76763b80a9 | |||
| d2a2c9d6b5 | |||
| 3a72f347d2 | |||
| c1d9456235 | |||
| c4f8b98208 | |||
| b6eb57d3f1 | |||
| 473b8ca0ca | |||
| 45206c4ef1 | |||
| 56b4a29aaa | |||
| bb4dda64b5 | |||
| 39e53b4ae7 | |||
| 6143d5195a | |||
| f59e608f18 | |||
| 6b24592e4a | |||
| 7b442997dc | |||
| 4bfc5bbcaa | |||
| 2b29b9a96d | |||
| cc201a6d80 | |||
| 5be58b59e0 | |||
| f1eb969565 | |||
| 8d4f455cd3 | |||
| 60262cb4cc | |||
| 7501c24f22 | |||
| 00d81aa982 | |||
| 67fe156d06 | |||
| ef433233d1 | |||
| 1c7056bdaa | |||
| 277ffaacb9 | |||
| 2a3e242cfd | |||
| b1c6051d6b | |||
| 8df9742463 | |||
| 9fdc382d71 | |||
| f70d11c2d1 | |||
| 05e54d6785 | |||
| 795a6e4546 | |||
| a01a4ad739 | |||
| e09b96ac6f | |||
| d48fb17718 | |||
| ede3c4af82 | |||
| 8bcfd97fc5 | |||
| 5721c6dfb5 | |||
| 9b1f77c3be | |||
| 3369035f40 | |||
| dbc7a8599b | |||
| 09f649daf7 | |||
| d357664850 | |||
| f973fdfa89 | |||
| c15bc50f17 | |||
| c2d0107cc5 | |||
| c43fac31a1 | |||
| 9dfaf1752b | |||
| d9a1eb2f01 | |||
| f1e17a320f | |||
| 75ecd818b3 | |||
| a7abc71f6a | |||
| 6d0f0c8f21 | |||
| 790e6da500 | |||
| 8df1a11535 | |||
| b61ee56dc8 | |||
| c61f95a327 | |||
| d6d629f416 | |||
| 65dec605f2 | |||
| cacec0b5c4 | |||
| 87a302d7e9 | |||
| 899b36c206 | |||
| 534cbef982 | |||
| b7f118404e | |||
| aab5067718 | |||
| b679da02ee | |||
| ec652820ea | |||
| 5d941ed107 | |||
| adc478ca34 | |||
| f1202adb15 | |||
| fd439cf38a | |||
| 83c63be846 | |||
| b59d49dd7f | |||
| 0116b70edf | |||
| 3f390d4858 | |||
| fdde73bff4 | |||
| 90a416e265 | |||
| a828b2e5de | |||
| 7c51d3c24c | |||
| 4d2d6f181a | |||
| 071416f6ef | |||
| d675b07089 | |||
| 3975fd592a | |||
| 34a9a4dac6 | |||
| 78e419b790 | |||
| 8d8769ba4e | |||
| d89fb3371b | |||
| 8410972cff | |||
| 2c8d041987 | |||
| 94519c8250 | |||
| add8a1b1a7 | |||
| 516e7656f3 | |||
| d7cdee37bf | |||
| 5c5b8c517f |
@@ -1,5 +0,0 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
|
||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
|
REACT_APP_SOCKET_SERVER_URL=http://localhost: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
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off"
|
"import/no-anonymous-default-export": "off",
|
||||||
|
"no-restricted-globals": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ updates:
|
|||||||
- lipis
|
- lipis
|
||||||
assignees:
|
assignees:
|
||||||
- lipis
|
- lipis
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/excalidraw/
|
directory: /src/packages/excalidraw/
|
||||||
@@ -21,6 +22,7 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/utils/
|
directory: /src/packages/utils/
|
||||||
@@ -32,3 +34,4 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: docker/build-push-action@v1
|
- uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
yarn lint-staged
|
||||||
+30
-28
@@ -19,28 +19,28 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dwelle/browser-fs-access": "0.21.3",
|
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.11.10",
|
"@testing-library/jest-dom": "5.15.1",
|
||||||
"@testing-library/react": "11.2.6",
|
"@testing-library/react": "12.1.2",
|
||||||
"@tldraw/vec": "0.0.106",
|
"@tldraw/vec": "1.1.5",
|
||||||
"@types/jest": "26.0.22",
|
"@types/jest": "27.0.3",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/react": "17.0.3",
|
"@types/react": "17.0.37",
|
||||||
"@types/react-dom": "17.0.3",
|
"@types/react-dom": "17.0.11",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
|
"browser-fs-access": "0.23.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"fake-indexeddb": "3.1.3",
|
"fake-indexeddb": "3.1.7",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.0",
|
"i18next-browser-languagedetector": "6.1.2",
|
||||||
"idb-keyval": "5.1.3",
|
"idb-keyval": "6.0.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.22",
|
"nanoid": "3.1.30",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.15",
|
"perfect-freehand": "1.0.16",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
@@ -49,39 +49,40 @@
|
|||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.4.1",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.32.10",
|
"sass": "1.43.5",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.2.4"
|
"typescript": "4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
|
"@types/chai": "4.2.22",
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.2",
|
||||||
"@types/resize-observer-browser": "0.1.5",
|
"@types/resize-observer-browser": "0.1.6",
|
||||||
|
"chai": "4.3.4",
|
||||||
|
"dotenv": "10.0.0",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.9.0",
|
"firebase-tools": "9.23.0",
|
||||||
"husky": "4.3.8",
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "10.5.4",
|
"lint-staged": "12.1.2",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.5.0",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@typescript-eslint/typescript-estree": "5.3.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"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
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
"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" />
|
<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 -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capture_links": "new-client",
|
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const publish = () => {
|
|||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
const { readdirSync, writeFileSync } = require("fs");
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||||
|
|
||||||
const flatten = (object) =>
|
const flatten = (object = {}, result = {}, extraKey = "") => {
|
||||||
Object.keys(object).reduce(
|
for (const key in object) {
|
||||||
(initial, current) => ({ ...initial, ...object[current] }),
|
if (typeof object[key] !== "object") {
|
||||||
{},
|
result[extraKey + key] = object[key];
|
||||||
);
|
} else {
|
||||||
|
flatten(object[key], result, `${extraKey}${key}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const locales = files.filter(
|
const locales = files.filter(
|
||||||
(file) => file !== "README.md" && file !== "percentages.json",
|
(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 allKeys = Object.keys(data);
|
||||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||||
|
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
|
||||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
percentages[currentLocale.replace(".json", "")] = percentage;
|
||||||
|
|
||||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ const THRESSHOLD = 85;
|
|||||||
const crowdinMap = {
|
const crowdinMap = {
|
||||||
"ar-SA": "en-ar",
|
"ar-SA": "en-ar",
|
||||||
"bg-BG": "en-bg",
|
"bg-BG": "en-bg",
|
||||||
|
"bn-BD": "en-bn",
|
||||||
"ca-ES": "en-ca",
|
"ca-ES": "en-ca",
|
||||||
|
"da-DK": "en-da",
|
||||||
"de-DE": "en-de",
|
"de-DE": "en-de",
|
||||||
"el-GR": "en-el",
|
"el-GR": "en-el",
|
||||||
"es-ES": "en-es",
|
"es-ES": "en-es",
|
||||||
@@ -31,11 +33,14 @@ const crowdinMap = {
|
|||||||
"pt-PT": "en-pt",
|
"pt-PT": "en-pt",
|
||||||
"ro-RO": "en-ro",
|
"ro-RO": "en-ro",
|
||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
|
"si-LK": "en-silk",
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
|
"ta-IN": "en-ta",
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
"uk-UA": "en-uk",
|
"uk-UA": "en-uk",
|
||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-HK": "en-zhhk",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "en-cs",
|
"cs-CZ": "en-cs",
|
||||||
@@ -45,7 +50,10 @@ const crowdinMap = {
|
|||||||
const flags = {
|
const flags = {
|
||||||
"ar-SA": "🇸🇦",
|
"ar-SA": "🇸🇦",
|
||||||
"bg-BG": "🇧🇬",
|
"bg-BG": "🇧🇬",
|
||||||
|
"bn-BD": "🇧🇩",
|
||||||
"ca-ES": "🏳",
|
"ca-ES": "🏳",
|
||||||
|
"cs-CZ": "🇨🇿",
|
||||||
|
"da-DK": "🇩🇰",
|
||||||
"de-DE": "🇩🇪",
|
"de-DE": "🇩🇪",
|
||||||
"el-GR": "🇬🇷",
|
"el-GR": "🇬🇷",
|
||||||
"es-ES": "🇪🇸",
|
"es-ES": "🇪🇸",
|
||||||
@@ -59,7 +67,9 @@ const flags = {
|
|||||||
"it-IT": "🇮🇹",
|
"it-IT": "🇮🇹",
|
||||||
"ja-JP": "🇯🇵",
|
"ja-JP": "🇯🇵",
|
||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
|
"kk-KZ": "🇰🇿",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
|
"lv-LV": "🇱🇻",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
"nl-NL": "🇳🇱",
|
"nl-NL": "🇳🇱",
|
||||||
@@ -71,21 +81,24 @@ const flags = {
|
|||||||
"pt-PT": "🇵🇹",
|
"pt-PT": "🇵🇹",
|
||||||
"ro-RO": "🇷🇴",
|
"ro-RO": "🇷🇴",
|
||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
|
"si-LK": "🇱🇰",
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
|
"ta-IN": "🇮🇳",
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
"uk-UA": "🇺🇦",
|
"uk-UA": "🇺🇦",
|
||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-HK": "🇭🇰",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
"lv-LV": "🇱🇻",
|
|
||||||
"cs-CZ": "🇨🇿",
|
|
||||||
"kk-KZ": "🇰🇿",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
"ar-SA": "العربية",
|
"ar-SA": "العربية",
|
||||||
"bg-BG": "Български",
|
"bg-BG": "Български",
|
||||||
|
"bn-BD": "Bengali",
|
||||||
"ca-ES": "Català",
|
"ca-ES": "Català",
|
||||||
|
"cs-CZ": "Česky",
|
||||||
|
"da-DK": "Dansk",
|
||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
@@ -99,7 +112,9 @@ const languages = {
|
|||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
"kab-KAB": "Taqbaylit",
|
"kab-KAB": "Taqbaylit",
|
||||||
|
"kk-KZ": "Қазақ тілі",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
|
"lv-LV": "Latviešu",
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
"nl-NL": "Nederlands",
|
"nl-NL": "Nederlands",
|
||||||
@@ -111,15 +126,15 @@ const languages = {
|
|||||||
"pt-PT": "Português",
|
"pt-PT": "Português",
|
||||||
"ro-RO": "Română",
|
"ro-RO": "Română",
|
||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
|
"si-LK": "සිංහල",
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
|
"ta-IN": "Tamil",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
|
"zh-HK": "繁體中文 (香港)",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
"lv-LV": "Latviešu",
|
|
||||||
"cs-CZ": "Česky",
|
|
||||||
"kk-KZ": "Қазақ тілі",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@ const release = async (nextVersion) => {
|
|||||||
);
|
);
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
console.log("Done!");
|
console.log("Done!");
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => {
|
|||||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||||
);
|
);
|
||||||
return stdout;
|
return stdout;
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@@ -9,15 +11,49 @@ export const actionAddToLibrary = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
if (selectedElements.some((element) => element.type === "image")) {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.library.loadLibrary().then((items) => {
|
return app.library
|
||||||
app.library.saveLibrary([
|
.loadLibrary()
|
||||||
...items,
|
.then((items) => {
|
||||||
selectedElements.map(deepCopyElement),
|
return app.library.saveLibrary([
|
||||||
]);
|
{
|
||||||
});
|
id: randomId(),
|
||||||
return false;
|
status: "unpublished",
|
||||||
|
elements: selectedElements.map(deepCopyElement),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
toastMessage: t("toast.addedToLibrary"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -34,9 +34,11 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
const updatedElements = alignElements(selectedElements, alignment);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const actionResetZoom = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<Tooltip label={t("buttons.resetZoom")}>
|
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="reset-zoom-button"
|
className="reset-zoom-button"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -56,7 +57,7 @@ export const actionCopyAsSvg = register({
|
|||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -81,6 +82,7 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -106,7 +108,7 @@ export const actionCopyAsPng = register({
|
|||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { newElementWith } from "../element/mutateElement";
|
|||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -21,6 +22,12 @@ const deleteSelectedElements = (
|
|||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isBoundToContainer(el) &&
|
||||||
|
appState.selectedElementIds[el.containerId]
|
||||||
|
) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
@@ -55,7 +62,7 @@ export const actionDeleteSelected = register({
|
|||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
activePointIndex,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
@@ -65,8 +72,7 @@ export const actionDeleteSelected = register({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// case: no point selected → delete whole element
|
// case: no point selected → delete whole element
|
||||||
activePointIndex == null ||
|
selectedPointsIndices == null ||
|
||||||
activePointIndex === -1 ||
|
|
||||||
// case: deleting last remaining point
|
// case: deleting last remaining point
|
||||||
element.points.length < 2
|
element.points.length < 2
|
||||||
) {
|
) {
|
||||||
@@ -86,15 +92,17 @@ export const actionDeleteSelected = register({
|
|||||||
// We cannot do this inside `movePoint` because it is also called
|
// We cannot do this inside `movePoint` because it is also called
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||||
const binding = {
|
const binding = {
|
||||||
startBindingElement:
|
startBindingElement: selectedPointsIndices?.includes(0)
|
||||||
activePointIndex === 0 ? null : startBindingElement,
|
? null
|
||||||
endBindingElement:
|
: startBindingElement,
|
||||||
activePointIndex === element.points.length - 1
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
? null
|
element.points.length - 1,
|
||||||
: endBindingElement,
|
)
|
||||||
|
? null
|
||||||
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -103,17 +111,17 @@ export const actionDeleteSelected = register({
|
|||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
selectedPointsIndices:
|
||||||
|
selectedPointsIndices?.[0] > 0
|
||||||
|
? [selectedPointsIndices[0] - 1]
|
||||||
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
let {
|
deleteSelectedElements(elements, appState);
|
||||||
elements: nextElements,
|
|
||||||
appState: nextAppState,
|
|
||||||
} = deleteSelectedElements(elements, appState);
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {
|
|||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES } from "../keys";
|
import { CODES } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -30,9 +30,11 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
const updatedElements = distributeElements(selectedElements, distribution);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import { KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
getSelectedGroupForElement,
|
getSelectedGroupForElement,
|
||||||
@@ -18,41 +17,23 @@ import { AppState } from "../types";
|
|||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import { ActionResult } from "./types";
|
import { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
|
import { bindTextToShapeAfterDuplication } from "../element/textElement";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate point if selected while editing multi-point element
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element || activePointIndex === null) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { points } = element;
|
|
||||||
const selectedPoint = points[activePointIndex];
|
|
||||||
const nextPoint = points[activePointIndex + 1];
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
...points.slice(0, activePointIndex + 1),
|
|
||||||
nextPoint
|
|
||||||
? [
|
|
||||||
(selectedPoint[0] + nextPoint[0]) / 2,
|
|
||||||
(selectedPoint[1] + nextPoint[1]) / 2,
|
|
||||||
]
|
|
||||||
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
|
||||||
...points.slice(activePointIndex + 1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
activePointIndex: activePointIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements,
|
elements,
|
||||||
|
appState: ret.appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,9 +87,12 @@ const duplicateElements = (
|
|||||||
const finalElements: ExcalidrawElement[] = [];
|
const finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
const selectedElementIds = arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, true),
|
||||||
|
);
|
||||||
while (index < elements.length) {
|
while (index < elements.length) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (selectedElementIds.get(element.id)) {
|
||||||
if (element.groupIds.length) {
|
if (element.groupIds.length) {
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
// if group selected, duplicate it atomically
|
// if group selected, duplicate it atomically
|
||||||
@@ -130,7 +114,11 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
bindTextToShapeAfterDuplication(
|
||||||
|
finalElements,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +128,9 @@ const duplicateElements = (
|
|||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce((acc, element) => {
|
||||||
acc[element.id] = true;
|
if (!isBoundToContainer(element)) {
|
||||||
|
acc[element.id] = true;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -151,9 +151,11 @@ export const actionSaveToActiveFile = register({
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -181,9 +183,11 @@ export const actionSaveFileToDisk = register({
|
|||||||
app.files,
|
app.files,
|
||||||
);
|
);
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -219,8 +223,9 @@ export const actionLoadScene = register({
|
|||||||
files,
|
files,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
|
console.warn(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,16 +14,63 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement } from "../element/typeChecks";
|
||||||
|
import { ExcalidrawImageElement } from "../element/types";
|
||||||
|
import { imageFromImageData } from "../element/image";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
perform: (
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
_,
|
||||||
|
{ canvas, focusContainer, imageCache, addFiles },
|
||||||
|
) => {
|
||||||
|
if (appState.editingImageElement) {
|
||||||
|
const { elementId, imageData } = appState.editingImageElement;
|
||||||
|
const editingImageElement = elements.find((el) => el.id === elementId) as
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| undefined;
|
||||||
|
if (editingImageElement?.fileId) {
|
||||||
|
const cachedImageData = imageCache.get(editingImageElement.fileId);
|
||||||
|
if (cachedImageData) {
|
||||||
|
const { image, dataURL } = imageFromImageData(imageData);
|
||||||
|
|
||||||
|
imageCache.set(editingImageElement.fileId, {
|
||||||
|
...cachedImageData,
|
||||||
|
image,
|
||||||
|
});
|
||||||
|
|
||||||
|
addFiles([
|
||||||
|
{
|
||||||
|
id: editingImageElement.fileId,
|
||||||
|
dataURL,
|
||||||
|
mimeType: cachedImageData.mimeType,
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
elementId,
|
appState.editingLinearElement;
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -165,6 +212,7 @@ export const actionFinalize = register({
|
|||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.editingLinearElement !== null ||
|
||||||
|
appState.editingImageElement !== null ||
|
||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||||
@@ -9,6 +9,7 @@ import { getTransformHandles } from "../element/transformHandles";
|
|||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { updateBoundElements } from "../element/binding";
|
import { updateBoundElements } from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -83,9 +84,11 @@ const flipSelectedElements = (
|
|||||||
flipDirection,
|
flipDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
@@ -142,10 +145,9 @@ const flipElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element)) {
|
if (isLinearElement(element)) {
|
||||||
for (let i = 1; i < element.points.length; i++) {
|
for (let index = 1; index < element.points.length; index++) {
|
||||||
LinearElementEditor.movePoint(element, i, [
|
LinearElementEditor.movePoints(element, [
|
||||||
-element.points[i][0],
|
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||||
element.points[i][1],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@@ -44,6 +44,7 @@ const enableActionGroup = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
@@ -56,6 +57,7 @@ export const actionGroup = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
@@ -83,8 +85,9 @@ export const actionGroup = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newGroupId = randomId();
|
const newGroupId = randomId();
|
||||||
|
const selectElementIds = arrayToMap(selectedElements);
|
||||||
const updatedElements = elements.map((element) => {
|
const updatedElements = elements.map((element) => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!selectElementIds.get(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
@@ -99,9 +102,8 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
const lastGroupElementIndex =
|
||||||
lastElementInGroup,
|
updatedElements.lastIndexOf(lastElementInGroup);
|
||||||
);
|
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = updatedElements
|
const elementsBeforeGroup = updatedElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import History, { HistoryEntry } from "../history";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isWindows, KEYS } from "../keys";
|
import { isWindows, KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
@@ -27,17 +27,17 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = arrayToMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = arrayToMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
const deletedElements = prevElements.filter(
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||||
);
|
);
|
||||||
const elements = nextElements
|
const elements = nextElements
|
||||||
.map((nextElement) =>
|
.map((nextElement) =>
|
||||||
newElementWith(
|
newElementWith(
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
prevElementMap.get(nextElement.id) || nextElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { backgroundIcon } from "../components/icons";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { isInitializedImageElement } from "../element/typeChecks";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
|
||||||
|
export const actionEditImageAlpha = register({
|
||||||
|
name: "editImageAlpha",
|
||||||
|
perform: async (elements, appState, _, app) => {
|
||||||
|
if (appState.editingImageElement) {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
const selectedElement = selectedElements[0];
|
||||||
|
if (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isInitializedImageElement(selectedElement)
|
||||||
|
) {
|
||||||
|
const imgData = app.imageCache.get(selectedElement.fileId);
|
||||||
|
if (!imgData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await imgData.image;
|
||||||
|
const { width, height } = image;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.width = width;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
|
||||||
|
const imageData = context.getImageData(0, 0, width, height);
|
||||||
|
|
||||||
|
Scene.mapElementToScene(selectedElement.id, app.scene);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: {
|
||||||
|
editorType: "alpha",
|
||||||
|
elementId: selectedElement.id,
|
||||||
|
origImageData: imageData,
|
||||||
|
imageData,
|
||||||
|
pointerDownState: { screenX: 0, screenY: 0, sampledPixel: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={backgroundIcon}
|
||||||
|
label="Edit Image Alpha"
|
||||||
|
className={appState.editingImageElement ? "active" : ""}
|
||||||
|
title={"Edit image alpha"}
|
||||||
|
aria-label={"Edit image alpha"}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
|
ArrowheadTriangleIcon,
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
@@ -738,6 +739,14 @@ export const actionChangeArrowhead = register({
|
|||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
@@ -780,6 +789,14 @@ export const actionChangeArrowhead = register({
|
|||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
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>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
@@ -15,7 +15,10 @@ export const actionSelectAll = register({
|
|||||||
...appState,
|
...appState,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
if (!element.isDeleted) {
|
if (
|
||||||
|
!element.isDeleted &&
|
||||||
|
!(isTextElement(element) && element.containerId)
|
||||||
|
) {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -80,3 +80,4 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
|
|||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
export { actionEditImageAlpha } from "./actionImageEditing";
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ export type ActionName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme";
|
| "toggleTheme"
|
||||||
|
| "editImageAlpha";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
+1
-13
@@ -1,13 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { getCommonBounds } from "./element";
|
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||||
|
|
||||||
interface Box {
|
|
||||||
minX: number;
|
|
||||||
minY: number;
|
|
||||||
maxX: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -88,8 +81,3 @@ const calculateTranslation = (
|
|||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
||||||
return { minX, minY, maxX, maxY };
|
|
||||||
};
|
|
||||||
|
|||||||
+6
-5
@@ -41,6 +41,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
editingImageElement: null,
|
||||||
elementLocked: false,
|
elementLocked: false,
|
||||||
elementType: "selection",
|
elementType: "selection",
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -96,10 +97,9 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
/** server (shareLink/collab/...) */
|
/** server (shareLink/collab/...) */
|
||||||
server: boolean;
|
server: boolean;
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>
|
T extends Record<keyof AppState, Values>,
|
||||||
>(
|
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config)({
|
||||||
) => config)({
|
|
||||||
theme: { browser: true, export: false, server: false },
|
theme: { browser: true, export: false, server: false },
|
||||||
collaborators: { browser: false, export: false, server: false },
|
collaborators: { browser: false, export: false, server: false },
|
||||||
currentChartType: { browser: true, export: false, server: false },
|
currentChartType: { browser: true, export: false, server: false },
|
||||||
@@ -126,6 +126,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
editingElement: { browser: false, export: false, server: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
|
editingImageElement: { browser: false, export: false, server: false },
|
||||||
elementLocked: { browser: true, export: false, server: false },
|
elementLocked: { browser: true, export: false, server: false },
|
||||||
elementType: { browser: true, export: false, server: false },
|
elementType: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false, server: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
@@ -172,7 +173,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
ExportType extends "export" | "browser" | "server"
|
ExportType extends "export" | "browser" | "server",
|
||||||
>(
|
>(
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
|
|||||||
+6
-5
@@ -58,7 +58,8 @@ export const copyToClipboard = async (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
// select binded text elements when copying
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: selectedElements,
|
elements: selectedElements,
|
||||||
@@ -74,7 +75,7 @@ export const copyToClipboard = async (
|
|||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
return JSON.parse(CLIPBOARD);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -179,7 +180,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.setSelectionRange(0, textarea.value.length);
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
|
||||||
success = document.execCommand("copy");
|
success = document.execCommand("copy");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
|||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
|
import { isImageElement } from "../element/typeChecks";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -105,6 +106,13 @@ export const SelectedShapeActions = ({
|
|||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div className="buttonList">
|
||||||
|
{targetElements.some((element) => isImageElement(element)) &&
|
||||||
|
renderAction("editImageAlpha")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
+431
-168
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -3,15 +3,22 @@ import OpenColor from "open-color";
|
|||||||
import "./Card.scss";
|
import "./Card.scss";
|
||||||
|
|
||||||
export const Card: React.FC<{
|
export const Card: React.FC<{
|
||||||
color: keyof OpenColor;
|
color: keyof OpenColor | "primary";
|
||||||
}> = ({ children, color }) => {
|
}> = ({ children, color }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Card"
|
className="Card"
|
||||||
style={{
|
style={{
|
||||||
["--card-color" as any]: OpenColor[color][7],
|
["--card-color" as any]:
|
||||||
["--card-color-darker" as any]: OpenColor[color][8],
|
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
|
||||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
["--card-color-darker" as any]:
|
||||||
|
color === "primary"
|
||||||
|
? "var(--color-primary-darker)"
|
||||||
|
: OpenColor[color][8],
|
||||||
|
["--card-color-darkest" as any]:
|
||||||
|
color === "primary"
|
||||||
|
? "var(--color-primary-darkest)"
|
||||||
|
: OpenColor[color][9],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import "./CheckboxItem.scss";
|
|||||||
|
|
||||||
export const CheckboxItem: React.FC<{
|
export const CheckboxItem: React.FC<{
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||||
}> = ({ children, checked, onChange }) => {
|
className?: string;
|
||||||
|
}> = ({ children, checked, onChange, className }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("Checkbox", { "is-checked": checked })}
|
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onChange(!checked);
|
onChange(!checked, event);
|
||||||
((event.currentTarget as HTMLDivElement).querySelector(
|
(
|
||||||
".Checkbox-box",
|
(event.currentTarget as HTMLDivElement).querySelector(
|
||||||
) as HTMLButtonElement).focus();
|
".Checkbox-box",
|
||||||
|
) as HTMLButtonElement
|
||||||
|
).focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { trash } from "./icons";
|
import { trash } from "./icons";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
import "./ClearCanvas.scss";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
@@ -26,39 +25,16 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{showDialog && (
|
{showDialog && (
|
||||||
<Dialog
|
<ConfirmDialog
|
||||||
onCloseRequest={toggleDialog}
|
onConfirm={() => {
|
||||||
|
onConfirm();
|
||||||
|
toggleDialog();
|
||||||
|
}}
|
||||||
|
onCancel={toggleDialog}
|
||||||
title={t("clearCanvasDialog.title")}
|
title={t("clearCanvasDialog.title")}
|
||||||
className="clear-canvas"
|
|
||||||
small={true}
|
|
||||||
>
|
>
|
||||||
<>
|
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
</ConfirmDialog>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.clear-canvas {
|
.confirm-dialog {
|
||||||
&-buttons {
|
&-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ToolIcon__icon {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon_type_button {
|
||||||
min-width: 2.5rem;
|
margin-left: 0.8rem;
|
||||||
width: auto;
|
padding: 0 0.5rem;
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_button {
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
@@ -34,9 +33,5 @@
|
|||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--cancel.ToolIcon_type_button {
|
|
||||||
background-color: $oc-gray-2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export interface DialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
@@ -18,7 +18,10 @@ export const Dialog = (props: {
|
|||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
}) => {
|
closeOnClickOutside?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dialog = (props: DialogProps) => {
|
||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
const [lastActiveElement] = useState(document.activeElement);
|
const [lastActiveElement] = useState(document.activeElement);
|
||||||
const { id } = useExcalidrawContainer();
|
const { id } = useExcalidrawContainer();
|
||||||
@@ -81,6 +84,7 @@ export const Dialog = (props: {
|
|||||||
maxWidth={props.small ? 550 : 800}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={onClose}
|
onCloseRequest={onClose}
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
|
closeOnClickOutside={props.closeOnClickOutside}
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ import { AppState } from "../types";
|
|||||||
import {
|
import {
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isTextBindableContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface HintViewerProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements }: Hint) => {
|
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (elementType === "arrow" || elementType === "line") {
|
if (elementType === "arrow" || elementType === "line") {
|
||||||
if (!multiMode) {
|
if (!multiMode) {
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElement");
|
||||||
@@ -39,6 +42,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
lastPointerDownWith === "mouse" &&
|
lastPointerDownWith === "mouse" &&
|
||||||
@@ -57,13 +61,18 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.rotate");
|
return t("hints.rotate");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
if (selectedElements.length === 1) {
|
||||||
if (appState.editingLinearElement) {
|
if (isLinearElement(selectedElements[0])) {
|
||||||
return appState.editingLinearElement.activePointIndex
|
if (appState.editingLinearElement) {
|
||||||
? t("hints.lineEditor_pointSelected")
|
return appState.editingLinearElement.selectedPointsIndices
|
||||||
: t("hints.lineEditor_nothingSelected");
|
? t("hints.lineEditor_pointSelected")
|
||||||
|
: t("hints.lineEditor_nothingSelected");
|
||||||
|
}
|
||||||
|
return t("hints.lineEditor_info");
|
||||||
|
}
|
||||||
|
if (isTextBindableContainer(selectedElements[0])) {
|
||||||
|
return t("hints.bindTextToElement");
|
||||||
}
|
}
|
||||||
return t("hints.lineEditor_info");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||||
@@ -74,13 +83,22 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (elementType === "selection" && !selectedElements.length && !isMobile) {
|
||||||
|
return t("hints.canvasPanning");
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
export const HintViewer = ({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
isMobile,
|
||||||
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
if (!hint) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--button-gray-2);
|
||||||
& svg {
|
& svg {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
.picker-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-template-columns: repeat(3, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const ImageExportModal = ({
|
|||||||
const { exportBackground, viewBackgroundColor } = appState;
|
const { exportBackground, viewBackgroundColor } = appState;
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const exportedElements = exportSelected
|
||||||
? getSelectedElements(elements, appState)
|
? getSelectedElements(elements, appState, true)
|
||||||
: elements;
|
: elements;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
--padding: 0;
|
--padding: 0;
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
|||||||
@@ -1,42 +1,6 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.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 {
|
.layer-ui__wrapper {
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
|
|||||||
+64
-356
@@ -1,29 +1,15 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, {
|
import React, { useCallback } from "react";
|
||||||
RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import {
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
AppProps,
|
|
||||||
AppState,
|
|
||||||
ExcalidrawProps,
|
|
||||||
BinaryFiles,
|
|
||||||
LibraryItem,
|
|
||||||
LibraryItems,
|
|
||||||
} from "../types";
|
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
@@ -32,10 +18,7 @@ import { ErrorDialog } from "./ErrorDialog";
|
|||||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { exportFile, load, trash } from "./icons";
|
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@@ -43,13 +26,16 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
|
import { LibraryMenu } from "./LibraryMenu";
|
||||||
|
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import "./Toolbar.scss";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -81,302 +67,6 @@ interface LayerUIProps {
|
|||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
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 = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
@@ -426,34 +116,34 @@ const LayerUI = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createExporter = (type: ExportType): ExportCB => async (
|
const createExporter =
|
||||||
exportedElements,
|
(type: ExportType): ExportCB =>
|
||||||
) => {
|
async (exportedElements) => {
|
||||||
const fileHandle = await exportCanvas(
|
const fileHandle = await exportCanvas(
|
||||||
type,
|
type,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
appState,
|
appState,
|
||||||
files,
|
files,
|
||||||
{
|
{
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
appState.exportEmbedScene &&
|
appState.exportEmbedScene &&
|
||||||
fileHandle &&
|
fileHandle &&
|
||||||
isImageFileHandle(fileHandle)
|
isImageFileHandle(fileHandle)
|
||||||
) {
|
) {
|
||||||
setAppState({ fileHandle });
|
setAppState({ fileHandle });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageExportDialog
|
<ImageExportDialog
|
||||||
@@ -561,12 +251,15 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLibrary = useCallback(
|
const closeLibrary = useCallback(() => {
|
||||||
(event) => {
|
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
},
|
// Prevent closing if any dialog is open
|
||||||
[setAppState],
|
if (isDialogOpen) {
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
const deselectItems = useCallback(() => {
|
||||||
setAppState({
|
setAppState({
|
||||||
@@ -577,8 +270,8 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
const libraryMenu = appState.isLibraryOpen ? (
|
||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClickOutside={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertShape={onInsertElements}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
@@ -588,6 +281,7 @@ const LayerUI = ({
|
|||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
files={files}
|
files={files}
|
||||||
id={id}
|
id={id}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -613,7 +307,12 @@ const LayerUI = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row
|
||||||
|
gap={1}
|
||||||
|
className={clsx("App-toolbar-container", {
|
||||||
|
"zen-mode": zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<LockButton
|
<LockButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={zenModeEnabled}
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
@@ -622,9 +321,15 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
className={clsx("App-toolbar", {
|
||||||
|
"zen-mode": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer appState={appState} elements={elements} />
|
<HintViewer
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
@@ -705,7 +410,8 @@ const LayerUI = ({
|
|||||||
{!viewModeEnabled && (
|
{!viewModeEnabled && (
|
||||||
<div
|
<div
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
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" })}
|
{actionManager.renderAction("undo", { size: "small" })}
|
||||||
@@ -719,7 +425,8 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
"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 +552,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||||
prev.langCode === next.langCode &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
|
prev.files === next.files &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
|
|||||||
export const LibraryButton: React.FC<{
|
export const LibraryButton: React.FC<{
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
}> = ({ appState, setAppState }) => {
|
isMobile?: boolean;
|
||||||
|
}> = ({ appState, setAppState, isMobile }) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
"ToolIcon ToolIcon_type_floating ToolIcon__library",
|
||||||
`ToolIcon_size_medium`,
|
`ToolIcon_size_medium`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
"is-mobile": isMobile,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import {
|
||||||
|
LibraryItems,
|
||||||
|
LibraryItem,
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawProps,
|
||||||
|
} from "../types";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import PublishLibrary from "./PublishLibrary";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import "./LibraryMenu.scss";
|
||||||
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
|
import { EVENT } from "../constants";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
const useOnClickOutside = (
|
||||||
|
ref: RefObject<HTMLElement>,
|
||||||
|
cb: (event: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
(ref.current.contains(event.target) ||
|
||||||
|
!document.body.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(event);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", listener, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", listener);
|
||||||
|
};
|
||||||
|
}, [ref, cb]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedItems = (
|
||||||
|
libraryItems: LibraryItems,
|
||||||
|
selectedItems: LibraryItem["id"][],
|
||||||
|
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
|
export const LibraryMenu = ({
|
||||||
|
onClose,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
onAddToLibrary,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
files,
|
||||||
|
libraryReturnUrl,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
appState,
|
||||||
|
}: {
|
||||||
|
pendingElements: LibraryItem["elements"];
|
||||||
|
onClose: () => void;
|
||||||
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
|
onAddToLibrary: () => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
appState: AppState;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === KEYS.ESCAPE) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||||
|
|
||||||
|
const [loadingState, setIsLoading] = useState<
|
||||||
|
"preloading" | "loading" | "ready"
|
||||||
|
>("preloading");
|
||||||
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
|
useState(false);
|
||||||
|
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
}>(null);
|
||||||
|
const loadingTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.race([
|
||||||
|
new Promise((resolve) => {
|
||||||
|
loadingTimerRef.current = window.setTimeout(() => {
|
||||||
|
resolve("loading");
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
library.loadLibrary().then((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setIsLoading("ready");
|
||||||
|
}),
|
||||||
|
]).then((data) => {
|
||||||
|
if (data === "loading") {
|
||||||
|
setIsLoading("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(loadingTimerRef.current!);
|
||||||
|
};
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
|
const removeFromLibrary = useCallback(async () => {
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
|
||||||
|
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
|
});
|
||||||
|
setSelectedItems([]);
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||||
|
|
||||||
|
const resetLibrary = useCallback(() => {
|
||||||
|
library.resetLibrary();
|
||||||
|
setLibraryItems([]);
|
||||||
|
focusContainer();
|
||||||
|
}, [library, focusContainer]);
|
||||||
|
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
async (elements: LibraryItem["elements"]) => {
|
||||||
|
if (elements.some((element) => element.type === "image")) {
|
||||||
|
return setAppState({
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
const nextItems: LibraryItems = [
|
||||||
|
{
|
||||||
|
status: "unpublished",
|
||||||
|
elements,
|
||||||
|
id: randomId(),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
];
|
||||||
|
onAddToLibrary();
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
|
});
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
},
|
||||||
|
[onAddToLibrary, library, setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPublishSuccess = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||||
|
title={t("publishSuccessDialog.title")}
|
||||||
|
className="publish-library-success"
|
||||||
|
small={true}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{t("publishSuccessDialog.content", {
|
||||||
|
authorName: publishLibSuccess!.authorName,
|
||||||
|
})}{" "}
|
||||||
|
<a
|
||||||
|
href={publishLibSuccess?.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishSuccessDialog.link")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.close")}
|
||||||
|
aria-label={t("buttons.close")}
|
||||||
|
label={t("buttons.close")}
|
||||||
|
onClick={() => setPublishLibSuccess(null)}
|
||||||
|
data-testid="publish-library-success-close"
|
||||||
|
className="publish-library-success-close"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
|
const onPublishLibSuccess = useCallback(
|
||||||
|
(data) => {
|
||||||
|
setShowPublishLibraryDialog(false);
|
||||||
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
|
const nextLibItems = libraryItems.slice();
|
||||||
|
nextLibItems.forEach((libItem) => {
|
||||||
|
if (selectedItems.includes(libItem.id)) {
|
||||||
|
libItem.status = "published";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
library.saveLibrary(nextLibItems);
|
||||||
|
setLibraryItems(nextLibItems);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setShowPublishLibraryDialog,
|
||||||
|
setPublishLibSuccess,
|
||||||
|
libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
library,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
|
LibraryItem["id"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
return loadingState === "preloading" ? null : (
|
||||||
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
|
{showPublishLibraryDialog && (
|
||||||
|
<PublishLibrary
|
||||||
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
|
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||||
|
appState={appState}
|
||||||
|
onSuccess={onPublishLibSuccess}
|
||||||
|
onError={(error) => window.alert(error)}
|
||||||
|
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||||
|
onRemove={(id: string) =>
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
|
|
||||||
|
{loadingState === "loading" ? (
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
{t("labels.libraryLoadingMessage")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LibraryMenuItems
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
|
onAddToLibrary={addToLibrary}
|
||||||
|
onInsertShape={onInsertShape}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
setAppState={setAppState}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
library={library}
|
||||||
|
theme={theme}
|
||||||
|
files={files}
|
||||||
|
id={id}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggle={(id, event) => {
|
||||||
|
const shouldSelect = !selectedItems.includes(id);
|
||||||
|
|
||||||
|
if (shouldSelect) {
|
||||||
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
|
const rangeStart = libraryItems.findIndex(
|
||||||
|
(item) => item.id === lastSelectedItem,
|
||||||
|
);
|
||||||
|
const rangeEnd = libraryItems.findIndex(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rangeStart === -1 || rangeEnd === -1) {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
|
const nextSelectedIds = libraryItems.reduce(
|
||||||
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
|
if (
|
||||||
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
|
selectedItemsMap.has(item.id)
|
||||||
|
) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedItems(nextSelectedIds);
|
||||||
|
} else {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
}
|
||||||
|
setLastSelectedItem(id);
|
||||||
|
} else {
|
||||||
|
setLastSelectedItem(null);
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { chunk } from "lodash";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import {
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawProps,
|
||||||
|
LibraryItem,
|
||||||
|
LibraryItems,
|
||||||
|
} from "../types";
|
||||||
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { useIsMobile } from "./App";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
|
import "./LibraryMenuItems.scss";
|
||||||
|
import { VERSIONS } from "../constants";
|
||||||
|
|
||||||
|
const LibraryMenuItems = ({
|
||||||
|
libraryItems,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
onAddToLibrary,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
libraryReturnUrl,
|
||||||
|
library,
|
||||||
|
files,
|
||||||
|
id,
|
||||||
|
selectedItems,
|
||||||
|
onToggle,
|
||||||
|
onPublish,
|
||||||
|
resetLibrary,
|
||||||
|
}: {
|
||||||
|
libraryItems: LibraryItems;
|
||||||
|
pendingElements: LibraryItem["elements"];
|
||||||
|
onRemoveFromLibrary: () => void;
|
||||||
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
resetLibrary: () => void;
|
||||||
|
}) => {
|
||||||
|
const renderRemoveLibAlert = useCallback(() => {
|
||||||
|
const content = selectedItems.length
|
||||||
|
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||||
|
: t("alerts.resetLibrary");
|
||||||
|
const title = selectedItems.length
|
||||||
|
? t("confirmDialog.removeItemsFromLib")
|
||||||
|
: t("confirmDialog.resetLibrary");
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedItems.length) {
|
||||||
|
onRemoveFromLibrary();
|
||||||
|
} else {
|
||||||
|
resetLibrary();
|
||||||
|
}
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<p>{content}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||||
|
|
||||||
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const renderLibraryActions = () => {
|
||||||
|
const itemsSelected = !!selectedItems.length;
|
||||||
|
const items = itemsSelected
|
||||||
|
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||||
|
: libraryItems;
|
||||||
|
const resetLabel = itemsSelected
|
||||||
|
? t("buttons.remove")
|
||||||
|
: t("buttons.resetLibrary");
|
||||||
|
return (
|
||||||
|
<div className="library-actions">
|
||||||
|
{(!itemsSelected || !isMobile) && (
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={() => {
|
||||||
|
importLibraryFromJSON(library)
|
||||||
|
.then(() => {
|
||||||
|
// Close and then open to get the libraries updated
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
setAppState({ isLibraryOpen: true });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="library-actions--load"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!items.length && (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportToFileIcon}
|
||||||
|
onClick={async () => {
|
||||||
|
const libraryItems = itemsSelected
|
||||||
|
? items
|
||||||
|
: await library.loadLibrary();
|
||||||
|
saveLibraryAsJSON(libraryItems)
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="library-actions--export"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={resetLabel}
|
||||||
|
aria-label={resetLabel}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => setShowRemoveLibAlert(true)}
|
||||||
|
className="library-actions--remove"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{itemsSelected && !isPublished && (
|
||||||
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
aria-label={t("buttons.publishLibrary")}
|
||||||
|
label={t("buttons.publishLibrary")}
|
||||||
|
icon={publishIcon}
|
||||||
|
className="library-actions--publish"
|
||||||
|
onClick={onPublish}
|
||||||
|
>
|
||||||
|
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
|
||||||
|
const referrer =
|
||||||
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
|
const isPublished = selectedItems.some(
|
||||||
|
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLibraryItemCompo = (params: {
|
||||||
|
item:
|
||||||
|
| LibraryItem
|
||||||
|
| /* pending library item */ {
|
||||||
|
id: null;
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
onClick?: () => void;
|
||||||
|
key: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Stack.Col key={params.key}>
|
||||||
|
<LibraryUnit
|
||||||
|
elements={params.item?.elements}
|
||||||
|
files={files}
|
||||||
|
isPending={!params.item?.id && !!params.item?.elements}
|
||||||
|
onClick={params.onClick || (() => {})}
|
||||||
|
id={params.item?.id || null}
|
||||||
|
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||||
|
onToggle={(id, event) => {
|
||||||
|
onToggle(id, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLibrarySection = (
|
||||||
|
items: (
|
||||||
|
| LibraryItem
|
||||||
|
| /* pending library item */ {
|
||||||
|
id: null;
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
}
|
||||||
|
)[],
|
||||||
|
) => {
|
||||||
|
const _items = items.map((item) => {
|
||||||
|
if (item.id) {
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
item,
|
||||||
|
onClick: () => onInsertShape(item.elements),
|
||||||
|
key: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
key: "__pending__item__",
|
||||||
|
item,
|
||||||
|
onClick: () => onAddToLibrary(pendingElements),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ensure we render all empty cells if no items are present
|
||||||
|
let rows = chunk(_items, CELLS_PER_ROW);
|
||||||
|
if (!rows.length) {
|
||||||
|
rows = [[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((rowItems, index, rows) => {
|
||||||
|
if (index === rows.length - 1) {
|
||||||
|
// pad row with empty cells
|
||||||
|
rowItems = rowItems.concat(
|
||||||
|
new Array(CELLS_PER_ROW - rowItems.length)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => {
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
key: `empty_${index}`,
|
||||||
|
item: null,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack.Row align="center" gap={1} key={index}>
|
||||||
|
{rowItems}
|
||||||
|
</Stack.Row>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishedItems = libraryItems.filter(
|
||||||
|
(item) => item.status === "published",
|
||||||
|
);
|
||||||
|
const unpublishedItems = [
|
||||||
|
// append pending library item
|
||||||
|
...(pendingElements.length
|
||||||
|
? [{ id: null, elements: pendingElements }]
|
||||||
|
: []),
|
||||||
|
...libraryItems.filter((item) => item.status !== "published"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-menu-items-container">
|
||||||
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
|
{renderLibraryActions()}
|
||||||
|
<a
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
|
VERSIONS.excalidrawLibrary
|
||||||
|
}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Stack.Col
|
||||||
|
className="library-menu-items-container__items"
|
||||||
|
align="start"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="separator">{t("labels.personalLib")}</div>
|
||||||
|
{renderLibrarySection(unpublishedItems)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||||
|
|
||||||
|
{renderLibrarySection(publishedItems)}
|
||||||
|
</>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryMenuItems;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-unit {
|
.library-unit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -7,6 +9,20 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 63px;
|
width: 63px;
|
||||||
height: 63px; // match width
|
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 {
|
.library-unit__dragger {
|
||||||
@@ -22,9 +38,9 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__removeFromLibrary,
|
.library-unit__checkbox-container,
|
||||||
.library-unit__removeFromLibrary:hover,
|
.library-unit__checkbox-container:hover,
|
||||||
.library-unit__removeFromLibrary:active {
|
.library-unit__checkbox-container:active {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -32,10 +48,35 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0.5rem;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
left: 2rem;
|
||||||
top: 5px;
|
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 {
|
.library-unit__removeFromLibrary > svg {
|
||||||
@@ -43,29 +84,37 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__pulse {
|
.library-unit__adder {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
animation: library-unit__pulse-animation 1s ease-in infinite;
|
animation: library-unit__adder-animation 1s ease-in infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__adder {
|
.library-unit__adder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 40%;
|
||||||
top: 50%;
|
top: 40%;
|
||||||
width: 20px;
|
width: 2rem;
|
||||||
height: 20px;
|
height: 2rem;
|
||||||
margin-left: -10px;
|
margin-left: -10px;
|
||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.library-unit:hover .library-unit__adder {
|
||||||
|
fill: $oc-blue-7;
|
||||||
|
}
|
||||||
|
.library-unit:active .library-unit__adder {
|
||||||
|
animation: none;
|
||||||
|
transform: scale(0.8);
|
||||||
|
fill: $oc-black;
|
||||||
|
}
|
||||||
|
|
||||||
.library-unit__active {
|
.library-unit__active {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes library-unit__pulse-animation {
|
@keyframes library-unit__adder-animation {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.95);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
@@ -73,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(0.95);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { close } from "../components/icons";
|
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { BinaryFiles, LibraryItem } from "../types";
|
import { BinaryFiles, LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
|
|
||||||
// fa-plus
|
|
||||||
const PLUS_ICON = (
|
const PLUS_ICON = (
|
||||||
<svg viewBox="0 0 1792 1792">
|
<svg viewBox="0 0 1792 1792">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
|
||||||
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
|
style={{
|
||||||
|
stroke: "#fff",
|
||||||
|
strokeWidth: 140,
|
||||||
|
}}
|
||||||
|
transform="translate(0 64)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
|
id,
|
||||||
elements,
|
elements,
|
||||||
files,
|
files,
|
||||||
pendingElements,
|
isPending,
|
||||||
onRemoveFromLibrary,
|
|
||||||
onClick,
|
onClick,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
elements?: LibraryItem;
|
id: LibraryItem["id"] | /** for pending item */ null;
|
||||||
|
elements?: LibraryItem["elements"];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
pendingElements?: LibraryItem;
|
isPending?: boolean;
|
||||||
onRemoveFromLibrary: () => void;
|
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,12 +46,11 @@ export const LibraryUnit = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const elementsToRender = elements || pendingElements;
|
if (!elements) {
|
||||||
if (!elementsToRender) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const svg = await exportToSvg(
|
const svg = await exportToSvg(
|
||||||
elementsToRender,
|
elements,
|
||||||
{
|
{
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
@@ -58,30 +63,41 @@ export const LibraryUnit = ({
|
|||||||
return () => {
|
return () => {
|
||||||
node.innerHTML = "";
|
node.innerHTML = "";
|
||||||
};
|
};
|
||||||
}, [elements, pendingElements, files]);
|
}, [elements, files]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const adder = isPending && (
|
||||||
const adder = (isHovered || isMobile) && pendingElements && (
|
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("library-unit", {
|
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)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx("library-unit__dragger", {
|
className={clsx("library-unit__dragger", {
|
||||||
"library-unit__pulse": !!pendingElements,
|
"library-unit__pulse": !!isPending,
|
||||||
})}
|
})}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
draggable={!!elements}
|
draggable={!!elements}
|
||||||
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
onClick={
|
||||||
|
!!elements || !!isPending
|
||||||
|
? (event) => {
|
||||||
|
if (id && event.shiftKey) {
|
||||||
|
onToggle(id, event);
|
||||||
|
} else {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
@@ -91,14 +107,12 @@ export const LibraryUnit = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adder}
|
{adder}
|
||||||
{elements && (isHovered || isMobile) && (
|
{id && elements && (isHovered || isMobile || selected) && (
|
||||||
<button
|
<CheckboxItem
|
||||||
className="library-unit__removeFromLibrary"
|
checked={selected}
|
||||||
aria-label={t("labels.removeFromLibrary")}
|
onChange={(checked, event) => onToggle(id, event)}
|
||||||
onClick={onRemoveFromLibrary}
|
className="library-unit__checkbox"
|
||||||
>
|
/>
|
||||||
{close}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type LockIconProps = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
zenModeEnabled?: boolean;
|
zenModeEnabled?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||||
@@ -42,10 +43,10 @@ export const LockButton = (props: LockIconProps) => {
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
"is-mobile": props.isMobile,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${props.title} — Q`}
|
title={`${props.title} — Q`}
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export const MobileMenu = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="center">
|
<Stack.Col gap={4} align="center">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1} className="App-toolbar-container">
|
||||||
<Island padding={1}>
|
<Island padding={1} className="App-toolbar">
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
@@ -85,14 +85,19 @@ export const MobileMenu = ({
|
|||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
<LibraryButton
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
isMobile
|
||||||
/>
|
/>
|
||||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
<HintViewer appState={appState} elements={elements} />
|
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export const Modal = (props: {
|
|||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
|
closeOnClickOutside?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { theme = THEME.LIGHT } = props;
|
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||||
const modalRoot = useBodyRoot(theme);
|
const modalRoot = useBodyRoot(theme);
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
@@ -39,7 +40,10 @@ export const Modal = (props: {
|
|||||||
onKeyDown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
aria-labelledby={props.labelledBy}
|
aria-labelledby={props.labelledBy}
|
||||||
>
|
>
|
||||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
<div
|
||||||
|
className="Modal__background"
|
||||||
|
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
className="Modal__content"
|
className="Modal__content"
|
||||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const PasteChartDialog = ({
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onInsertChart: (elements: LibraryItem) => void;
|
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
|
|||||||
</label>
|
</label>
|
||||||
{props.isNameEditable ? (
|
{props.isNameEditable ? (
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
className="TextInput"
|
className="TextInput"
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
|
import OpenColor from "open-color";
|
||||||
|
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||||
|
import { exportToCanvas } from "../packages/utils";
|
||||||
|
import {
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
EXPORT_SOURCE,
|
||||||
|
MIME_TYPES,
|
||||||
|
VERSIONS,
|
||||||
|
} from "../constants";
|
||||||
|
import { ExportedLibraryData } from "../data/types";
|
||||||
|
|
||||||
|
import "./PublishLibrary.scss";
|
||||||
|
import SingleLibraryItem from "./SingleLibraryItem";
|
||||||
|
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||||
|
import { chunk } from "../utils";
|
||||||
|
|
||||||
|
interface PublishLibraryDataParams {
|
||||||
|
authorName: string;
|
||||||
|
githubHandle: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
twitterHandle: string;
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
|
||||||
|
|
||||||
|
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
|
||||||
|
JSON.stringify(data),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Unable to access window.localStorage
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPublishLibDataFromStorage = () => {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Unable to access localStorage
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
||||||
|
const MAX_ITEMS_PER_ROW = 6;
|
||||||
|
const BOX_SIZE = 128;
|
||||||
|
const BOX_PADDING = Math.round(BOX_SIZE / 16);
|
||||||
|
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
|
||||||
|
|
||||||
|
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
canvas.width =
|
||||||
|
rows[0].length * BOX_SIZE +
|
||||||
|
(rows[0].length + 1) * (BOX_PADDING * 2) -
|
||||||
|
BOX_PADDING * 2;
|
||||||
|
canvas.height =
|
||||||
|
rows.length * BOX_SIZE +
|
||||||
|
(rows.length + 1) * (BOX_PADDING * 2) -
|
||||||
|
BOX_PADDING * 2;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
ctx.fillStyle = OpenColor.white;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// draw items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
for (const [index, item] of libraryItems.entries()) {
|
||||||
|
const itemCanvas = await exportToCanvas({
|
||||||
|
elements: item.elements,
|
||||||
|
files: null,
|
||||||
|
maxWidthOrHeight: BOX_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { width, height } = itemCanvas;
|
||||||
|
|
||||||
|
// draw item
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const rowOffset =
|
||||||
|
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
|
||||||
|
const colOffset =
|
||||||
|
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
itemCanvas,
|
||||||
|
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
|
||||||
|
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
|
||||||
|
);
|
||||||
|
|
||||||
|
// draw item border
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
ctx.lineWidth = BORDER_WIDTH;
|
||||||
|
ctx.strokeStyle = OpenColor.gray[4];
|
||||||
|
ctx.strokeRect(
|
||||||
|
colOffset + BOX_PADDING / 2,
|
||||||
|
rowOffset + BOX_PADDING / 2,
|
||||||
|
BOX_SIZE + BOX_PADDING,
|
||||||
|
BOX_SIZE + BOX_PADDING,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resizeImageFile(
|
||||||
|
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
|
||||||
|
{
|
||||||
|
outputType: MIME_TYPES.jpg,
|
||||||
|
maxWidthOrHeight: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PublishLibrary = ({
|
||||||
|
onClose,
|
||||||
|
libraryItems,
|
||||||
|
appState,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
updateItemsInStorage,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
libraryItems: LibraryItems;
|
||||||
|
appState: AppState;
|
||||||
|
onSuccess: (data: {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
items: LibraryItems;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
updateItemsInStorage: (items: LibraryItems) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
|
||||||
|
authorName: "",
|
||||||
|
githubHandle: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
twitterHandle: "",
|
||||||
|
website: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = importPublishLibDataFromStorage();
|
||||||
|
if (data) {
|
||||||
|
setLibraryData(data);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
|
||||||
|
libraryItems.slice(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setClonedLibItems(libraryItems.slice());
|
||||||
|
}, [libraryItems]);
|
||||||
|
|
||||||
|
const onInputChange = (event: any) => {
|
||||||
|
setLibraryData({
|
||||||
|
...libraryData,
|
||||||
|
[event.target.name]: event.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const erroredLibItems: LibraryItem[] = [];
|
||||||
|
let isError = false;
|
||||||
|
clonedLibItems.forEach((libItem) => {
|
||||||
|
let error = "";
|
||||||
|
if (!libItem.name) {
|
||||||
|
error = t("publishDialog.errors.required");
|
||||||
|
isError = true;
|
||||||
|
}
|
||||||
|
erroredLibItems.push({ ...libItem, error });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
setClonedLibItems(erroredLibItems);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewImage = await generatePreviewImage(clonedLibItems);
|
||||||
|
|
||||||
|
const libContent: ExportedLibraryData = {
|
||||||
|
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||||
|
version: VERSIONS.excalidrawLibrary,
|
||||||
|
source: EXPORT_SOURCE,
|
||||||
|
libraryItems: clonedLibItems,
|
||||||
|
};
|
||||||
|
const content = JSON.stringify(libContent, null, 2);
|
||||||
|
const lib = new Blob([content], { type: "application/json" });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("excalidrawLib", lib);
|
||||||
|
formData.append("previewImage", previewImage);
|
||||||
|
formData.append("previewImageType", previewImage.type);
|
||||||
|
formData.append("title", libraryData.name);
|
||||||
|
formData.append("authorName", libraryData.authorName);
|
||||||
|
formData.append("githubHandle", libraryData.githubHandle);
|
||||||
|
formData.append("name", libraryData.name);
|
||||||
|
formData.append("description", libraryData.description);
|
||||||
|
formData.append("twitterHandle", libraryData.twitterHandle);
|
||||||
|
formData.append("website", libraryData.website);
|
||||||
|
|
||||||
|
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
||||||
|
method: "post",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json().then(({ url }) => {
|
||||||
|
// flush data from local storage
|
||||||
|
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||||
|
onSuccess({
|
||||||
|
url,
|
||||||
|
authorName: libraryData.authorName,
|
||||||
|
items: clonedLibItems,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
.json()
|
||||||
|
.catch(() => {
|
||||||
|
throw new Error(response.statusText || "something went wrong");
|
||||||
|
})
|
||||||
|
.then((error) => {
|
||||||
|
throw new Error(
|
||||||
|
error.message || response.statusText || "something went wrong",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error(err);
|
||||||
|
onError(err);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
onError(err);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLibraryItems = () => {
|
||||||
|
const items: ReactNode[] = [];
|
||||||
|
clonedLibItems.forEach((libItem, index) => {
|
||||||
|
items.push(
|
||||||
|
<div className="single-library-item-wrapper" key={index}>
|
||||||
|
<SingleLibraryItem
|
||||||
|
libItem={libItem}
|
||||||
|
appState={appState}
|
||||||
|
index={index}
|
||||||
|
onChange={(val, index) => {
|
||||||
|
const items = clonedLibItems.slice();
|
||||||
|
items[index].name = val;
|
||||||
|
setClonedLibItems(items);
|
||||||
|
}}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return <div className="selected-library-items">{items}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDialogClose = useCallback(() => {
|
||||||
|
updateItemsInStorage(clonedLibItems);
|
||||||
|
savePublishLibDataToStorage(libraryData);
|
||||||
|
onClose();
|
||||||
|
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||||
|
|
||||||
|
const shouldRenderForm = !!libraryItems.length;
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={onDialogClose}
|
||||||
|
title={t("publishDialog.title")}
|
||||||
|
className="publish-library"
|
||||||
|
>
|
||||||
|
{shouldRenderForm ? (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="publish-library-note">
|
||||||
|
{t("publishDialog.noteDescription.pre")}
|
||||||
|
<a
|
||||||
|
href="https://libraries.excalidraw.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishDialog.noteDescription.link")}
|
||||||
|
</a>{" "}
|
||||||
|
{t("publishDialog.noteDescription.post")}
|
||||||
|
</div>
|
||||||
|
<span className="publish-library-note">
|
||||||
|
{t("publishDialog.noteGuidelines.pre")}
|
||||||
|
<a
|
||||||
|
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishDialog.noteGuidelines.link")}
|
||||||
|
</a>
|
||||||
|
{t("publishDialog.noteGuidelines.post")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="publish-library-note">
|
||||||
|
{t("publishDialog.noteItems")}
|
||||||
|
</div>
|
||||||
|
{renderLibraryItems()}
|
||||||
|
<div className="publish-library__fields">
|
||||||
|
<label>
|
||||||
|
<div>
|
||||||
|
<span>{t("publishDialog.libraryName")}</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
value={libraryData.name}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.libraryName")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<span>{t("publishDialog.libraryDesc")}</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
value={libraryData.description}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.libraryDesc")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div>
|
||||||
|
<span>{t("publishDialog.authorName")}</span>
|
||||||
|
<span aria-hidden="true" className="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="authorName"
|
||||||
|
required
|
||||||
|
value={libraryData.authorName}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.authorName")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("publishDialog.githubUsername")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="githubHandle"
|
||||||
|
value={libraryData.githubHandle}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.githubHandle")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("publishDialog.twitterUsername")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="twitterHandle"
|
||||||
|
value={libraryData.twitterHandle}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.twitterHandle")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("publishDialog.website")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
pattern="https?://.+"
|
||||||
|
title={t("publishDialog.errors.website")}
|
||||||
|
value={libraryData.website}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t("publishDialog.placeholder.website")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="publish-library-note">
|
||||||
|
{t("publishDialog.noteLicense.pre")}
|
||||||
|
<a
|
||||||
|
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishDialog.noteLicense.link")}
|
||||||
|
</a>
|
||||||
|
{t("publishDialog.noteLicense.post")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="publish-library__buttons">
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.cancel")}
|
||||||
|
aria-label={t("buttons.cancel")}
|
||||||
|
label={t("buttons.cancel")}
|
||||||
|
onClick={onDialogClose}
|
||||||
|
data-testid="cancel-clear-canvas-button"
|
||||||
|
className="publish-library__buttons--cancel"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
type="submit"
|
||||||
|
title={t("buttons.submit")}
|
||||||
|
aria-label={t("buttons.submit")}
|
||||||
|
label={t("buttons.submit")}
|
||||||
|
className="publish-library__buttons--confirm"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
|
||||||
|
{t("publishDialog.atleastOneLibItem")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublishLibrary;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
.excalidraw {
|
||||||
.TextInput {
|
.TextInput {
|
||||||
color: var(--text-primary-color);
|
|
||||||
display: inline-block;
|
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;
|
visible?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolButtonProps =
|
type ToolButtonProps =
|
||||||
@@ -33,6 +34,11 @@ type ToolButtonProps =
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?(event: React.MouseEvent): void;
|
onClick?(event: React.MouseEvent): void;
|
||||||
})
|
})
|
||||||
|
| (ToolButtonBaseProps & {
|
||||||
|
type: "submit";
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?(event: React.MouseEvent): void;
|
||||||
|
})
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "icon";
|
type: "icon";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -61,9 +67,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await ret;
|
await ret;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!(error instanceof AbortError)) {
|
||||||
throw error;
|
throw error;
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
@@ -82,7 +90,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
|
|
||||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -102,10 +117,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
hidden={props.hidden}
|
hidden={props.hidden}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
type="button"
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
disabled={isLoading}
|
disabled={isLoading || props.isLoading}
|
||||||
>
|
>
|
||||||
{(props.icon || props.label) && (
|
{(props.icon || props.label) && (
|
||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
<div className="ToolIcon__icon" aria-hidden="true">
|
||||||
@@ -115,6 +130,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
{props.keyBindingLabel}
|
{props.keyBindingLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{props.isLoading && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.showAriaLabel && (
|
{props.showAriaLabel && (
|
||||||
|
|||||||
@@ -6,20 +6,9 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
font-family: Cascadia;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
border-radius: var(--space-factor);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
background-color: var(--button-gray-1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon--plain {
|
.ToolIcon--plain {
|
||||||
@@ -30,6 +19,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
& + .ToolIcon__icon {
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--button-gray-2);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: var(--button-gray-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
@@ -39,7 +42,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--border-radius-lg);
|
||||||
|
|
||||||
|
& + .ToolIcon__label {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -47,10 +54,6 @@
|
|||||||
fill: var(--icon-fill-color);
|
fill: var(--icon-fill-color);
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
& + .ToolIcon__label {
|
|
||||||
margin-inline-start: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
@@ -80,7 +83,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +125,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus + .ToolIcon__icon {
|
&:focus-visible + .ToolIcon__icon {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +145,6 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -160,13 +159,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
|
||||||
margin-left: 0.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__keybinding {
|
.ToolIcon__keybinding {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
|
@mixin toolbarButtonColorStates {
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
& + .ToolIcon__icon:active {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
&:checked + .ToolIcon__icon {
|
||||||
|
background: var(--color-primary);
|
||||||
|
--icon-fill-color: #{$oc-white};
|
||||||
|
--keybinding-color: #{$oc-white};
|
||||||
|
}
|
||||||
|
&:checked + .ToolIcon__icon:active {
|
||||||
|
background: var(--color-primary-darker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__keybinding {
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.App-toolbar-container {
|
||||||
|
.ToolIcon_type_floating {
|
||||||
|
@include toolbarButtonColorStates;
|
||||||
|
|
||||||
|
&:not(.is-mobile) {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
padding: 1px;
|
||||||
|
background-color: var(--island-bg-color);
|
||||||
|
box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: box-shadow 0.5s ease, transform 0.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
&:focus-within + .ToolIcon__icon {
|
||||||
|
// override for custom floating button shadow
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon.ToolIcon__lock {
|
||||||
|
margin-inline-end: var(--space-factor);
|
||||||
|
&.ToolIcon_type_floating {
|
||||||
|
margin-left: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__library {
|
||||||
|
margin-inline-start: var(--space-factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.zen-mode {
|
||||||
|
.ToolIcon_type_floating {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
box-shadow: none;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
|
||||||
|
& + .ToolIcon__icon {
|
||||||
|
svg {
|
||||||
|
fill: $oc-gray-5;
|
||||||
|
color: $oc-gray-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-toolbar {
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
|
||||||
|
|
||||||
|
.ToolIcon {
|
||||||
|
&:hover {
|
||||||
|
--icon-fill-color: var(--color-primary-chubb);
|
||||||
|
--keybinding-color: var(--color-primary-chubb);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
--icon-fill-color: #{$oc-gray-9};
|
||||||
|
--keybinding-color: #{$oc-gray-9};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include toolbarButtonColorStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.zen-mode {
|
||||||
|
.ToolIcon__keybinding,
|
||||||
|
.HintViewer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme--dark .App-toolbar .ToolIcon:active {
|
||||||
|
--icon-fill-color: #{$oc-gray-3};
|
||||||
|
--keybinding-color: #{$oc-gray-3};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
// wraps the element we want to apply the tooltip to
|
// wraps the element we want to apply the tooltip to
|
||||||
.excalidraw-tooltip-wrapper {
|
.excalidraw-tooltip-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
.excalidraw-tooltip-icon {
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ const updateTooltip = (
|
|||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
} = item.getBoundingClientRect();
|
} = item.getBoundingClientRect();
|
||||||
|
|
||||||
const {
|
const { width: labelWidth, height: labelHeight } =
|
||||||
width: labelWidth,
|
tooltip.getBoundingClientRect();
|
||||||
height: labelHeight,
|
|
||||||
} = tooltip.getBoundingClientRect();
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
@@ -64,9 +62,15 @@ type TooltipProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
long?: boolean;
|
long?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
export const Tooltip = ({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
long = false,
|
||||||
|
style,
|
||||||
|
}: TooltipProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () =>
|
return () =>
|
||||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
||||||
@@ -86,6 +90,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
|||||||
onPointerLeave={() =>
|
onPointerLeave={() =>
|
||||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
||||||
}
|
}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
for (const [key, value] of Object.entries({ ...localStorage })) {
|
for (const [key, value] of Object.entries({ ...localStorage })) {
|
||||||
try {
|
try {
|
||||||
_localStorage[key] = JSON.parse(value);
|
_localStorage[key] = JSON.parse(value);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
_localStorage[key] = value;
|
_localStorage[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
)
|
)
|
||||||
).default;
|
).default;
|
||||||
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList > * {
|
.UserList > * {
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { THEME } from "../constants";
|
|||||||
|
|
||||||
const activeElementColor = (theme: Theme) =>
|
const activeElementColor = (theme: Theme) =>
|
||||||
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
||||||
const iconFillColor = (theme: Theme) =>
|
|
||||||
theme === THEME.LIGHT ? oc.black : oc.gray[4];
|
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||||
|
|
||||||
const handlerColor = (theme: Theme) =>
|
const handlerColor = (theme: Theme) =>
|
||||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||||
|
|
||||||
@@ -30,8 +31,12 @@ export const createIcon = (
|
|||||||
d: string | React.ReactNode,
|
d: string | React.ReactNode,
|
||||||
opts: number | Opts = 512,
|
opts: number | Opts = 512,
|
||||||
) => {
|
) => {
|
||||||
const { width = 512, height = width, mirror, style } =
|
const {
|
||||||
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
width = 512,
|
||||||
|
height = width,
|
||||||
|
mirror,
|
||||||
|
style,
|
||||||
|
} = typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -81,8 +86,17 @@ export const clipboard = createIcon(
|
|||||||
|
|
||||||
export const trash = 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",
|
"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 },
|
{ width: 448, height: 512 },
|
||||||
);
|
);
|
||||||
|
export const backgroundIcon = createIcon(
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M512 320s-64 92.65-64 128c0 35.35 28.66 64 64 64s64-28.65 64-64-64-128-64-128zm-9.37-102.94L294.94 9.37C288.69 3.12 280.5 0 272.31 0s-16.38 3.12-22.62 9.37l-81.58 81.58L81.93 4.76c-6.25-6.25-16.38-6.25-22.62 0L36.69 27.38c-6.24 6.25-6.24 16.38 0 22.62l86.19 86.18-94.76 94.76c-37.49 37.48-37.49 98.26 0 135.75l117.19 117.19c18.74 18.74 43.31 28.12 67.87 28.12 24.57 0 49.13-9.37 67.87-28.12l221.57-221.57c12.5-12.5 12.5-32.75.01-45.25zm-116.22 70.97H65.93c1.36-3.84 3.57-7.98 7.43-11.83l13.15-13.15 81.61-81.61 58.6 58.6c12.49 12.49 32.75 12.49 45.24 0s12.49-32.75 0-45.24l-58.6-58.6 58.95-58.95 162.44 162.44-48.34 48.34z"
|
||||||
|
></path>,
|
||||||
|
|
||||||
|
{ width: 576, height: 512 },
|
||||||
|
);
|
||||||
|
|
||||||
export const palette = createIcon(
|
export const palette = createIcon(
|
||||||
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
|
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
|
||||||
@@ -752,6 +766,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 }) =>
|
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
@@ -863,3 +892,11 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|||||||
{ width: 448, height: 512 },
|
{ 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 },
|
||||||
|
);
|
||||||
|
|||||||
@@ -174,3 +174,12 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
|
|||||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
export const ENCRYPTION_KEY_BITS = 128;
|
||||||
|
|
||||||
|
export const VERSIONS = {
|
||||||
|
excalidraw: 2,
|
||||||
|
excalidrawLibrary: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PADDING = 30;
|
||||||
|
|||||||
+31
-8
@@ -180,7 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.buttonList label:focus-within,
|
.buttonList label:focus-within,
|
||||||
input:focus {
|
input:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
@@ -190,14 +190,14 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-md);
|
||||||
margin: 0.125rem 0;
|
margin: 0.125rem 0;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
@@ -217,14 +217,16 @@
|
|||||||
|
|
||||||
.active,
|
.active,
|
||||||
.buttonList label.active {
|
.buttonList label.active {
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--color-primary);
|
||||||
|
|
||||||
|
--icon-fill-color: #{$oc-white};
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--color-primary-darker);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--button-gray-3);
|
background-color: var(--color-primary-darkest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
svg {
|
svg {
|
||||||
width: 36px;
|
width: 35px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -311,7 +313,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.App-menu_top {
|
.App-menu_top {
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: auto max-content auto;
|
||||||
grid-gap: 4px;
|
grid-gap: 4px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -517,6 +519,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
textarea:not(.excalidraw-wysiwyg) {
|
||||||
|
color: var(--text-primary-color);
|
||||||
|
border: 1.5px solid var(--input-border-color);
|
||||||
|
padding: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
|
background-color: var(--input-bg-color);
|
||||||
|
|
||||||
|
&:not(:focus) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--input-hover-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.App-bottom-bar,
|
.App-bottom-bar,
|
||||||
.FixedSideContainer,
|
.FixedSideContainer,
|
||||||
|
|||||||
+21
-4
@@ -12,11 +12,11 @@
|
|||||||
--dialog-border-color: #{$oc-gray-6};
|
--dialog-border-color: #{$oc-gray-6};
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||||
--focus-highlight-color: #{$oc-blue-2};
|
--focus-highlight-color: #{$oc-blue-2};
|
||||||
--icon-fill-color: #{$oc-black};
|
--icon-fill-color: #{$oc-gray-9};
|
||||||
--icon-green-fill-color: #{$oc-green-9};
|
--icon-green-fill-color: #{$oc-green-9};
|
||||||
--default-bg-color: #{$oc-white};
|
--default-bg-color: #{$oc-white};
|
||||||
--input-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-hover-bg-color: #{$oc-gray-1};
|
||||||
--input-label-color: #{$oc-gray-7};
|
--input-label-color: #{$oc-gray-7};
|
||||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||||
@@ -32,10 +32,20 @@
|
|||||||
--sar: env(safe-area-inset-right);
|
--sar: env(safe-area-inset-right);
|
||||||
--sat: env(safe-area-inset-top);
|
--sat: env(safe-area-inset-top);
|
||||||
--select-highlight-color: #{$oc-blue-5};
|
--select-highlight-color: #{$oc-blue-5};
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
--shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
|
||||||
|
|
||||||
--space-factor: 0.25rem;
|
--space-factor: 0.25rem;
|
||||||
--text-primary-color: #{$oc-gray-8};
|
--text-primary-color: #{$oc-gray-8};
|
||||||
|
|
||||||
|
--color-primary: #6965db;
|
||||||
|
--color-primary-chubb: #625ee0; // to offset Chubb illusion
|
||||||
|
--color-primary-darker: #5b57d1;
|
||||||
|
--color-primary-darkest: #4a47b1;
|
||||||
|
--color-primary-light: #e2e1fc;
|
||||||
|
|
||||||
|
--border-radius-md: 0.375rem;
|
||||||
|
--border-radius-lg: 0.5rem;
|
||||||
|
|
||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
background: $oc-black;
|
background: $oc-black;
|
||||||
|
|
||||||
@@ -64,13 +74,20 @@
|
|||||||
--input-label-color: #{$oc-gray-2};
|
--input-label-color: #{$oc-gray-2};
|
||||||
--island-bg-color: rgba(30, 30, 30, 0.98);
|
--island-bg-color: rgba(30, 30, 30, 0.98);
|
||||||
--keybinding-color: #{$oc-gray-6};
|
--keybinding-color: #{$oc-gray-6};
|
||||||
|
--link-color: #{$oc-blue-4};
|
||||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||||
--popup-bg-color: #2c2c2c;
|
--popup-bg-color: #2c2c2c;
|
||||||
--popup-secondary-bg-color: #222;
|
--popup-secondary-bg-color: #222;
|
||||||
--popup-text-color: #{$oc-gray-4};
|
--popup-text-color: #{$oc-gray-4};
|
||||||
--popup-text-inverted-color: #2c2c2c;
|
--popup-text-inverted-color: #2c2c2c;
|
||||||
--select-highlight-color: #{$oc-blue-4};
|
--select-highlight-color: #{$oc-blue-4};
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
--shadow-island: 1px 1px 5px #{transparentize($oc-black, 0.7)};
|
||||||
--text-primary-color: #{$oc-gray-4};
|
--text-primary-color: #{$oc-gray-4};
|
||||||
|
|
||||||
|
--color-primary: #5650f0;
|
||||||
|
--color-primary-chubb: #726dff; // to offset Chubb illusion
|
||||||
|
--color-primary-darker: #4b46d8;
|
||||||
|
--color-primary-darkest: #3e39be;
|
||||||
|
--color-primary-light: #3f3d64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-20
@@ -11,6 +11,7 @@ import { CanvasError } from "../errors";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState, DataURL } from "../types";
|
import { AppState, DataURL } from "../types";
|
||||||
|
import { bytesToHexString } from "../utils";
|
||||||
import { FileSystemHandle } from "./filesystem";
|
import { FileSystemHandle } from "./filesystem";
|
||||||
import { isValidExcalidrawData } from "./json";
|
import { isValidExcalidrawData } from "./json";
|
||||||
import { restore } from "./restore";
|
import { restore } from "./restore";
|
||||||
@@ -24,7 +25,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
return await (
|
return await (
|
||||||
await import(/* webpackChunkName: "image" */ "./image")
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
).decodePngMetadata(blob);
|
).decodePngMetadata(blob);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.message === "INVALID") {
|
if (error.message === "INVALID") {
|
||||||
throw new DOMException(
|
throw new DOMException(
|
||||||
t("alerts.imageDoesNotContainScene"),
|
t("alerts.imageDoesNotContainScene"),
|
||||||
@@ -58,7 +59,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
).decodeSvgMetadata({
|
).decodeSvgMetadata({
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.message === "INVALID") {
|
if (error.message === "INVALID") {
|
||||||
throw new DOMException(
|
throw new DOMException(
|
||||||
t("alerts.imageDoesNotContainScene"),
|
t("alerts.imageDoesNotContainScene"),
|
||||||
@@ -156,7 +157,7 @@ export const loadFromBlob = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ export const canvasToBlob = async (
|
|||||||
}
|
}
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -195,26 +196,18 @@ export const canvasToBlob = async (
|
|||||||
|
|
||||||
/** generates SHA-1 digest from supplied file (if not supported, falls back
|
/** generates SHA-1 digest from supplied file (if not supported, falls back
|
||||||
to a 40-char base64 random id) */
|
to a 40-char base64 random id) */
|
||||||
export const generateIdFromFile = async (file: File) => {
|
export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
||||||
let id: FileId;
|
|
||||||
try {
|
try {
|
||||||
const hashBuffer = await window.crypto.subtle.digest(
|
const hashBuffer = await window.crypto.subtle.digest(
|
||||||
"SHA-1",
|
"SHA-1",
|
||||||
await file.arrayBuffer(),
|
await file.arrayBuffer(),
|
||||||
);
|
);
|
||||||
id =
|
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
||||||
// convert buffer to byte array
|
} catch (error: any) {
|
||||||
Array.from(new Uint8Array(hashBuffer))
|
|
||||||
// convert to hex string
|
|
||||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
||||||
.join("") as FileId;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
|
// 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> => {
|
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
||||||
@@ -244,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
|||||||
|
|
||||||
export const resizeImageFile = async (
|
export const resizeImageFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
maxWidthOrHeight: number,
|
opts: {
|
||||||
|
/** undefined indicates auto */
|
||||||
|
outputType?: typeof MIME_TYPES["jpg"];
|
||||||
|
maxWidthOrHeight: number;
|
||||||
|
},
|
||||||
): Promise<File> => {
|
): Promise<File> => {
|
||||||
// SVG files shouldn't a can't be resized
|
// SVG files shouldn't a can't be resized
|
||||||
if (file.type === MIME_TYPES.svg) {
|
if (file.type === MIME_TYPES.svg) {
|
||||||
@@ -264,16 +261,26 @@ export const resizeImageFile = async (
|
|||||||
pica: pica({ features: ["js", "wasm"] }),
|
pica: pica({ features: ["js", "wasm"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileType = file.type;
|
if (opts.outputType) {
|
||||||
|
const { outputType } = opts;
|
||||||
|
reduce._create_blob = function (env) {
|
||||||
|
return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
|
||||||
|
env.out_blob = blob;
|
||||||
|
return env;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSupportedImageFile(file)) {
|
if (!isSupportedImageFile(file)) {
|
||||||
throw new Error(t("errors.unsupportedFileType"));
|
throw new Error(t("errors.unsupportedFileType"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new File(
|
return new File(
|
||||||
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
|
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
||||||
file.name,
|
file.name,
|
||||||
{ type: fileType },
|
{
|
||||||
|
type: opts.outputType || file.type,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -85,7 +85,7 @@ export const encode = async ({
|
|||||||
if (compress !== false) {
|
if (compress !== false) {
|
||||||
try {
|
try {
|
||||||
deflated = await toByteString(deflate(text));
|
deflated = await toByteString(deflate(text));
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("encode: cannot deflate", error);
|
console.error("encode: cannot deflate", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,19 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
|
|||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
// first chunk is the version (ignored for now)
|
// first chunk is the version
|
||||||
|
const version = dataView(
|
||||||
|
concatenatedBuffer,
|
||||||
|
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
|
||||||
|
cursor,
|
||||||
|
);
|
||||||
|
// If version is outside of the supported versions, throw an error.
|
||||||
|
// This usually means the buffer wasn't encoded using this API, so we'd only
|
||||||
|
// waste compute.
|
||||||
|
if (version > CONCAT_BUFFERS_VERSION) {
|
||||||
|
throw new Error(`invalid version ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
cursor += VERSION_DATAVIEW_BYTES;
|
cursor += VERSION_DATAVIEW_BYTES;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -367,7 +379,7 @@ export const decompressData = async <T extends Record<string, any>>(
|
|||||||
/** data can be anything so the caller must decode it */
|
/** data can be anything so the caller must decode it */
|
||||||
data: contentsBuffer,
|
data: contentsBuffer,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error during decompressing and decrypting the file.`,
|
`Error during decompressing and decrypting the file.`,
|
||||||
encodingMetadata,
|
encodingMetadata,
|
||||||
|
|||||||
+21
-8
@@ -1,3 +1,5 @@
|
|||||||
|
import { ENCRYPTION_KEY_BITS } from "../constants";
|
||||||
|
|
||||||
export const IV_LENGTH_BYTES = 12;
|
export const IV_LENGTH_BYTES = 12;
|
||||||
|
|
||||||
export const createIV = () => {
|
export const createIV = () => {
|
||||||
@@ -5,19 +7,27 @@ export const createIV = () => {
|
|||||||
return window.crypto.getRandomValues(arr);
|
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(
|
const key = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
length: 128,
|
length: ENCRYPTION_KEY_BITS,
|
||||||
},
|
},
|
||||||
true, // extractable
|
true, // extractable
|
||||||
["encrypt", "decrypt"],
|
["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(
|
window.crypto.subtle.importKey(
|
||||||
"jwk",
|
"jwk",
|
||||||
{
|
{
|
||||||
@@ -29,17 +39,18 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
length: 128,
|
length: ENCRYPTION_KEY_BITS,
|
||||||
},
|
},
|
||||||
false, // extractable
|
false, // extractable
|
||||||
[usage],
|
[usage],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const encryptData = async (
|
export const encryptData = async (
|
||||||
key: string,
|
key: string | CryptoKey,
|
||||||
data: Uint8Array | ArrayBuffer | Blob | File | string,
|
data: Uint8Array | ArrayBuffer | Blob | File | string,
|
||||||
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
|
): 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 iv = createIV();
|
||||||
const buffer: ArrayBuffer | Uint8Array =
|
const buffer: ArrayBuffer | Uint8Array =
|
||||||
typeof data === "string"
|
typeof data === "string"
|
||||||
@@ -50,6 +61,8 @@ export const encryptData = async (
|
|||||||
? await data.arrayBuffer()
|
? await data.arrayBuffer()
|
||||||
: data;
|
: 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(
|
const encryptedBuffer = await window.crypto.subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
@@ -67,7 +80,7 @@ export const decryptData = async (
|
|||||||
encrypted: Uint8Array | ArrayBuffer,
|
encrypted: Uint8Array | ArrayBuffer,
|
||||||
privateKey: string,
|
privateKey: string,
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<ArrayBuffer> => {
|
||||||
const key = await getImportedKey(privateKey, "decrypt");
|
const key = await getCryptoKey(privateKey, "decrypt");
|
||||||
return window.crypto.subtle.decrypt(
|
return window.crypto.subtle.decrypt(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
fileSave as _fileSave,
|
fileSave as _fileSave,
|
||||||
FileSystemHandle,
|
FileSystemHandle,
|
||||||
supported as nativeFileSystemSupported,
|
supported as nativeFileSystemSupported,
|
||||||
} from "@dwelle/browser-fs-access";
|
} from "browser-fs-access";
|
||||||
import { EVENT, MIME_TYPES } from "../constants";
|
import { EVENT, MIME_TYPES } from "../constants";
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import { debounce } from "../utils";
|
import { debounce } from "../utils";
|
||||||
@@ -22,7 +22,7 @@ const INPUT_CHANGE_INTERVAL_MS = 500;
|
|||||||
|
|
||||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||||
extensions?: FILE_EXTENSION[];
|
extensions?: FILE_EXTENSION[];
|
||||||
description?: string;
|
description: string;
|
||||||
multiple?: M;
|
multiple?: M;
|
||||||
}): Promise<
|
}): Promise<
|
||||||
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
||||||
@@ -94,7 +94,7 @@ export const fileSave = (
|
|||||||
name: string;
|
name: string;
|
||||||
/** file extension */
|
/** file extension */
|
||||||
extension: FILE_EXTENSION;
|
extension: FILE_EXTENSION;
|
||||||
description?: string;
|
description: string;
|
||||||
/** existing FileSystemHandle */
|
/** existing FileSystemHandle */
|
||||||
fileHandle?: FileSystemHandle | null;
|
fileHandle?: FileSystemHandle | null;
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -76,7 +76,7 @@ export const decodePngMetadata = async (blob: Blob) => {
|
|||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return await decode(encodedData);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
|||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return await decode(encodedData);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -54,6 +54,7 @@ export const exportCanvas = async (
|
|||||||
return await fileSave(
|
return await fileSave(
|
||||||
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
|
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
|
||||||
{
|
{
|
||||||
|
description: "Export to SVG",
|
||||||
name,
|
name,
|
||||||
extension: "svg",
|
extension: "svg",
|
||||||
fileHandle,
|
fileHandle,
|
||||||
@@ -86,6 +87,7 @@ export const exportCanvas = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await fileSave(blob, {
|
return await fileSave(blob, {
|
||||||
|
description: "Export to PNG",
|
||||||
name,
|
name,
|
||||||
extension: "png",
|
extension: "png",
|
||||||
fileHandle,
|
fileHandle,
|
||||||
@@ -93,7 +95,7 @@ export const exportCanvas = async (
|
|||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
try {
|
try {
|
||||||
await copyBlobToClipboardAsPng(blob);
|
await copyBlobToClipboardAsPng(blob);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -1,9 +1,14 @@
|
|||||||
import { fileOpen, fileSave } from "./filesystem";
|
import { fileOpen, fileSave } from "./filesystem";
|
||||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
import {
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
EXPORT_SOURCE,
|
||||||
|
MIME_TYPES,
|
||||||
|
VERSIONS,
|
||||||
|
} from "../constants";
|
||||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +47,7 @@ export const serializeAsJSON = (
|
|||||||
): string => {
|
): string => {
|
||||||
const data: ExportedDataState = {
|
const data: ExportedDataState = {
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
version: 2,
|
version: VERSIONS.excalidraw,
|
||||||
source: EXPORT_SOURCE,
|
source: EXPORT_SOURCE,
|
||||||
elements:
|
elements:
|
||||||
type === "local"
|
type === "local"
|
||||||
@@ -114,17 +119,16 @@ export const isValidLibrary = (json: any) => {
|
|||||||
typeof json === "object" &&
|
typeof json === "object" &&
|
||||||
json &&
|
json &&
|
||||||
json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
|
json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
|
||||||
json.version === 1
|
(json.version === 1 || json.version === 2)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveLibraryAsJSON = async (library: Library) => {
|
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||||
const libraryItems = await library.loadLibrary();
|
|
||||||
const data: ExportedLibraryData = {
|
const data: ExportedLibraryData = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||||
version: 1,
|
version: VERSIONS.excalidrawLibrary,
|
||||||
source: EXPORT_SOURCE,
|
source: EXPORT_SOURCE,
|
||||||
library: libraryItems,
|
libraryItems,
|
||||||
};
|
};
|
||||||
const serialized = JSON.stringify(data, null, 2);
|
const serialized = JSON.stringify(data, null, 2);
|
||||||
await fileSave(
|
await fileSave(
|
||||||
|
|||||||
+24
-17
@@ -1,6 +1,6 @@
|
|||||||
import { loadLibraryFromBlob } from "./blob";
|
import { loadLibraryFromBlob } from "./blob";
|
||||||
import { LibraryItems, LibraryItem } from "../types";
|
import { LibraryItems, LibraryItem } from "../types";
|
||||||
import { restoreElements } from "./restore";
|
import { restoreElements, restoreLibraryItems } from "./restore";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
|
|
||||||
@@ -18,14 +18,16 @@ class Library {
|
|||||||
};
|
};
|
||||||
|
|
||||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||||
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
|
const elements = getNonDeletedElements(
|
||||||
return elements.length ? elements : null;
|
restoreElements(libraryItem.elements, null),
|
||||||
|
);
|
||||||
|
return elements.length ? { ...libraryItem, elements } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** imports library (currently merges, removing duplicates) */
|
/** imports library (currently merges, removing duplicates) */
|
||||||
async importLibrary(blob: Blob) {
|
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
||||||
const libraryFile = await loadLibraryFromBlob(blob);
|
const libraryFile = await loadLibraryFromBlob(blob);
|
||||||
if (!libraryFile || !libraryFile.library) {
|
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,17 +39,17 @@ class Library {
|
|||||||
targetLibraryItem: LibraryItem,
|
targetLibraryItem: LibraryItem,
|
||||||
) => {
|
) => {
|
||||||
return !existingLibraryItems.find((libraryItem) => {
|
return !existingLibraryItems.find((libraryItem) => {
|
||||||
if (libraryItem.length !== targetLibraryItem.length) {
|
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// detect z-index difference by checking the excalidraw elements
|
// detect z-index difference by checking the excalidraw elements
|
||||||
// are in order
|
// are in order
|
||||||
return libraryItem.every((libItemExcalidrawItem, idx) => {
|
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||||
return (
|
return (
|
||||||
libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
|
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||||
libItemExcalidrawItem.versionNonce ===
|
libItemExcalidrawItem.versionNonce ===
|
||||||
targetLibraryItem[idx].versionNonce
|
targetLibraryItem.elements[idx].versionNonce
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -55,15 +57,20 @@ class Library {
|
|||||||
|
|
||||||
const existingLibraryItems = await this.loadLibrary();
|
const existingLibraryItems = await this.loadLibrary();
|
||||||
|
|
||||||
const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
|
const library = libraryFile.libraryItems || libraryFile.library || [];
|
||||||
const restoredItem = this.restoreLibraryItem(libraryItem);
|
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)) {
|
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> => {
|
loadLibrary = (): Promise<LibraryItems> => {
|
||||||
@@ -90,7 +97,7 @@ class Library {
|
|||||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
this.libraryCache = JSON.parse(JSON.stringify(items));
|
||||||
|
|
||||||
resolve(items);
|
resolve(items);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
resolve([]);
|
resolve([]);
|
||||||
}
|
}
|
||||||
@@ -105,7 +112,7 @@ class Library {
|
|||||||
// immediately
|
// immediately
|
||||||
this.libraryCache = JSON.parse(serializedItems);
|
this.libraryCache = JSON.parse(serializedItems);
|
||||||
await this.app.props.onLibraryChange?.(items);
|
await this.app.props.onLibraryChange?.(items);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
this.libraryCache = prevLibraryItems;
|
this.libraryCache = prevLibraryItems;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-17
@@ -3,13 +3,14 @@ import {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
|
|
||||||
import { ImportedDataState } from "./types";
|
|
||||||
import {
|
import {
|
||||||
getElementMap,
|
AppState,
|
||||||
getNormalizedDimensions,
|
BinaryFiles,
|
||||||
isInvisiblySmallElement,
|
LibraryItem,
|
||||||
} from "../element";
|
NormalizedZoomValue,
|
||||||
|
} from "../types";
|
||||||
|
import { ImportedDataState } from "./types";
|
||||||
|
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
|
||||||
import { isLinearElementType } from "../element/typeChecks";
|
import { isLinearElementType } from "../element/typeChecks";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
|
import { getUpdatedTimestamp } from "../utils";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -59,9 +62,12 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
T extends ExcalidrawElement,
|
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>,
|
element: Required<T> & {
|
||||||
|
/** @deprecated */
|
||||||
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
|
},
|
||||||
extra: Pick<
|
extra: Pick<
|
||||||
T,
|
T,
|
||||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||||
@@ -95,14 +101,17 @@ const restoreElementWithProperties = <
|
|||||||
strokeSharpness:
|
strokeSharpness:
|
||||||
element.strokeSharpness ??
|
element.strokeSharpness ??
|
||||||
(isLinearElementType(element.type) ? "round" : "sharp"),
|
(isLinearElementType(element.type) ? "round" : "sharp"),
|
||||||
boundElementIds: element.boundElementIds ?? [],
|
boundElements: element.boundElementIds
|
||||||
|
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
|
||||||
|
: element.boundElements ?? [],
|
||||||
|
updated: element.updated ?? getUpdatedTimestamp(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return ({
|
return {
|
||||||
...base,
|
...base,
|
||||||
...getNormalizedDimensions(base),
|
...getNormalizedDimensions(base),
|
||||||
...extra,
|
...extra,
|
||||||
} as unknown) as T;
|
} as unknown as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreElement = (
|
const restoreElement = (
|
||||||
@@ -113,10 +122,9 @@ const restoreElement = (
|
|||||||
let fontSize = element.fontSize;
|
let fontSize = element.fontSize;
|
||||||
let fontFamily = element.fontFamily;
|
let fontFamily = element.fontFamily;
|
||||||
if ("font" in element) {
|
if ("font" in element) {
|
||||||
const [fontPx, _fontFamily]: [
|
const [fontPx, _fontFamily]: [string, string] = (
|
||||||
string,
|
element as any
|
||||||
string,
|
).font.split(" ");
|
||||||
] = (element as any).font.split(" ");
|
|
||||||
fontSize = parseInt(fontPx, 10);
|
fontSize = parseInt(fontPx, 10);
|
||||||
fontFamily = getFontFamilyByName(_fontFamily);
|
fontFamily = getFontFamilyByName(_fontFamily);
|
||||||
}
|
}
|
||||||
@@ -127,6 +135,8 @@ const restoreElement = (
|
|||||||
baseline: element.baseline,
|
baseline: element.baseline,
|
||||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||||
|
containerId: element.containerId ?? null,
|
||||||
|
originalText: element.originalText ?? "",
|
||||||
});
|
});
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
return restoreElementWithProperties(element, {
|
return restoreElementWithProperties(element, {
|
||||||
@@ -200,14 +210,14 @@ export const restoreElements = (
|
|||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const localElementsMap = localElements ? getElementMap(localElements) : null;
|
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||||
return (elements || []).reduce((elements, element) => {
|
return (elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.[element.id];
|
const localElement = localElementsMap?.get(element.id);
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
migratedElement = bumpVersion(migratedElement, localElement.version);
|
||||||
}
|
}
|
||||||
@@ -274,3 +284,30 @@ export const restore = (
|
|||||||
files: data?.files || {},
|
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;
|
||||||
|
};
|
||||||
|
|||||||
+9
-5
@@ -1,6 +1,7 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
|
||||||
import type { cleanAppStateForExport } from "../appState";
|
import type { cleanAppStateForExport } from "../appState";
|
||||||
|
import { VERSIONS } from "../constants";
|
||||||
|
|
||||||
export interface ExportedDataState {
|
export interface ExportedDataState {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -18,15 +19,18 @@ export interface ImportedDataState {
|
|||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: Readonly<Partial<AppState>> | null;
|
appState?: Readonly<Partial<AppState>> | null;
|
||||||
scrollToContent?: boolean;
|
scrollToContent?: boolean;
|
||||||
libraryItems?: LibraryItems;
|
libraryItems?: LibraryItems | LibraryItems_v1;
|
||||||
files?: BinaryFiles;
|
files?: BinaryFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportedLibraryData {
|
export interface ExportedLibraryData {
|
||||||
type: string;
|
type: string;
|
||||||
version: number;
|
version: typeof VERSIONS.excalidrawLibrary;
|
||||||
source: string;
|
source: string;
|
||||||
library: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
|
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
|
||||||
|
/** @deprecated v1 */
|
||||||
|
library?: LibraryItems;
|
||||||
|
}
|
||||||
|
|||||||
+113
-85
@@ -8,7 +8,11 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import { getElementAtPosition } from "../scene";
|
import { getElementAtPosition } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isBindableElement, isBindingElement } from "./typeChecks";
|
import {
|
||||||
|
isBindableElement,
|
||||||
|
isBindingElement,
|
||||||
|
isLinearElement,
|
||||||
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
bindingBorderTest,
|
bindingBorderTest,
|
||||||
distanceToBindableElement,
|
distanceToBindableElement,
|
||||||
@@ -20,7 +24,7 @@ import {
|
|||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { tupleToCoors } from "../utils";
|
import { arrayToMap, tupleToCoors } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
@@ -74,8 +78,9 @@ export const bindOrUnbindLinearElement = (
|
|||||||
.getNonDeletedElements(onlyUnbound)
|
.getNonDeletedElements(onlyUnbound)
|
||||||
.forEach((element) => {
|
.forEach((element) => {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
boundElementIds: element.boundElementIds?.filter(
|
boundElements: element.boundElements?.filter(
|
||||||
(id) => id !== linearElement.id,
|
(element) =>
|
||||||
|
element.type !== "arrow" || element.id !== linearElement.id,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -137,14 +142,13 @@ export const bindOrUnbindSelectedElements = (
|
|||||||
const maybeBindBindableElement = (
|
const maybeBindBindableElement = (
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
): void => {
|
): void => {
|
||||||
getElligibleElementsForBindableElementAndWhere(
|
getElligibleElementsForBindableElementAndWhere(bindableElement).forEach(
|
||||||
bindableElement,
|
([linearElement, where]) =>
|
||||||
).forEach(([linearElement, where]) =>
|
bindOrUnbindLinearElement(
|
||||||
bindOrUnbindLinearElement(
|
linearElement,
|
||||||
linearElement,
|
where === "end" ? "keep" : bindableElement,
|
||||||
where === "end" ? "keep" : bindableElement,
|
where === "start" ? "keep" : bindableElement,
|
||||||
where === "start" ? "keep" : bindableElement,
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,11 +185,16 @@ const bindLinearElement = (
|
|||||||
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
|
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
|
||||||
} as PointBinding,
|
} as PointBinding,
|
||||||
});
|
});
|
||||||
mutateElement(hoveredElement, {
|
|
||||||
boundElementIds: Array.from(
|
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
|
||||||
new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
|
if (!boundElementsMap.has(linearElement.id)) {
|
||||||
),
|
mutateElement(hoveredElement, {
|
||||||
});
|
boundElements: (hoveredElement.boundElements || []).concat({
|
||||||
|
id: linearElement.id,
|
||||||
|
type: "arrow",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't bind both ends of a simple segment
|
// Don't bind both ends of a simple segment
|
||||||
@@ -285,50 +294,56 @@ export const updateBoundElements = (
|
|||||||
newSize?: { width: number; height: number };
|
newSize?: { width: number; height: number };
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const boundElementIds = changedElement.boundElementIds ?? [];
|
const boundLinearElements = (changedElement.boundElements ?? []).filter(
|
||||||
if (boundElementIds.length === 0) {
|
(el) => el.type === "arrow",
|
||||||
|
);
|
||||||
|
if (boundLinearElements.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
(Scene.getScene(changedElement)!.getNonDeletedElements(
|
Scene.getScene(changedElement)!
|
||||||
boundElementIds,
|
.getNonDeletedElements(boundLinearElements.map((el) => el.id))
|
||||||
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
|
.forEach((element) => {
|
||||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
if (!isLinearElement(element)) {
|
||||||
// In case the boundElementIds are stale
|
return;
|
||||||
if (!doesNeedUpdate(linearElement, bindableElement)) {
|
}
|
||||||
return;
|
|
||||||
}
|
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||||
const startBinding = maybeCalculateNewGapWhenScaling(
|
// In case the boundElements are stale
|
||||||
bindableElement,
|
if (!doesNeedUpdate(element, bindableElement)) {
|
||||||
linearElement.startBinding,
|
return;
|
||||||
newSize,
|
}
|
||||||
);
|
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||||
const endBinding = maybeCalculateNewGapWhenScaling(
|
bindableElement,
|
||||||
bindableElement,
|
element.startBinding,
|
||||||
linearElement.endBinding,
|
newSize,
|
||||||
newSize,
|
);
|
||||||
);
|
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||||
// `linearElement` is being moved/scaled already, just update the binding
|
bindableElement,
|
||||||
if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
|
element.endBinding,
|
||||||
mutateElement(linearElement, { startBinding, endBinding });
|
newSize,
|
||||||
return;
|
);
|
||||||
}
|
// `linearElement` is being moved/scaled already, just update the binding
|
||||||
updateBoundPoint(
|
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||||
linearElement,
|
mutateElement(element, { startBinding, endBinding });
|
||||||
"start",
|
return;
|
||||||
startBinding,
|
}
|
||||||
changedElement as ExcalidrawBindableElement,
|
updateBoundPoint(
|
||||||
);
|
element,
|
||||||
updateBoundPoint(
|
"start",
|
||||||
linearElement,
|
startBinding,
|
||||||
"end",
|
changedElement as ExcalidrawBindableElement,
|
||||||
endBinding,
|
);
|
||||||
changedElement as ExcalidrawBindableElement,
|
updateBoundPoint(
|
||||||
);
|
element,
|
||||||
});
|
"end",
|
||||||
|
endBinding,
|
||||||
|
changedElement as ExcalidrawBindableElement,
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const doesNeedUpdate = (
|
const doesNeedUpdate = (
|
||||||
@@ -400,10 +415,17 @@ const updateBoundPoint = (
|
|||||||
newEdgePoint = intersections[0];
|
newEdgePoint = intersections[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LinearElementEditor.movePoint(
|
LinearElementEditor.movePoints(
|
||||||
linearElement,
|
linearElement,
|
||||||
edgePointIndex,
|
[
|
||||||
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
|
{
|
||||||
|
index: edgePointIndex,
|
||||||
|
point: LinearElementEditor.pointFromAbsoluteCoords(
|
||||||
|
linearElement,
|
||||||
|
newEdgePoint,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
|
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -551,11 +573,11 @@ export const fixBindingsAfterDuplication = (
|
|||||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||||
oldElements.forEach((oldElement) => {
|
oldElements.forEach((oldElement) => {
|
||||||
const { boundElementIds } = oldElement;
|
const { boundElements } = oldElement;
|
||||||
if (boundElementIds != null && boundElementIds.length > 0) {
|
if (boundElements != null && boundElements.length > 0) {
|
||||||
boundElementIds.forEach((boundElementId) => {
|
boundElements.forEach((boundElement) => {
|
||||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
|
||||||
allBoundElementIds.add(boundElementId);
|
allBoundElementIds.add(boundElement.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
||||||
@@ -580,9 +602,11 @@ export const fixBindingsAfterDuplication = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update the linear elements
|
// Update the linear elements
|
||||||
(sceneElements.filter(({ id }) =>
|
(
|
||||||
allBoundElementIds.has(id),
|
sceneElements.filter(({ id }) =>
|
||||||
) as ExcalidrawLinearElement[]).forEach((element) => {
|
allBoundElementIds.has(id),
|
||||||
|
) as ExcalidrawLinearElement[]
|
||||||
|
).forEach((element) => {
|
||||||
const { startBinding, endBinding } = element;
|
const { startBinding, endBinding } = element;
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
startBinding: newBindingAfterDuplication(
|
startBinding: newBindingAfterDuplication(
|
||||||
@@ -597,12 +621,16 @@ export const fixBindingsAfterDuplication = (
|
|||||||
sceneElements
|
sceneElements
|
||||||
.filter(({ id }) => allBindableElementIds.has(id))
|
.filter(({ id }) => allBindableElementIds.has(id))
|
||||||
.forEach((bindableElement) => {
|
.forEach((bindableElement) => {
|
||||||
const { boundElementIds } = bindableElement;
|
const { boundElements } = bindableElement;
|
||||||
if (boundElementIds != null && boundElementIds.length > 0) {
|
if (boundElements != null && boundElements.length > 0) {
|
||||||
mutateElement(bindableElement, {
|
mutateElement(bindableElement, {
|
||||||
boundElementIds: boundElementIds.map(
|
boundElements: boundElements.map((boundElement) =>
|
||||||
(boundElementId) =>
|
oldIdToDuplicatedId.has(boundElement.id)
|
||||||
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
|
? {
|
||||||
|
id: oldIdToDuplicatedId.get(boundElement.id)!,
|
||||||
|
type: boundElement.type,
|
||||||
|
}
|
||||||
|
: boundElement,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -635,24 +663,24 @@ export const fixBindingsAfterDeletion = (
|
|||||||
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
deletedElements.forEach((deletedElement) => {
|
deletedElements.forEach((deletedElement) => {
|
||||||
if (isBindableElement(deletedElement)) {
|
if (isBindableElement(deletedElement)) {
|
||||||
deletedElement.boundElementIds?.forEach((id) => {
|
deletedElement.boundElements?.forEach((element) => {
|
||||||
if (!deletedElementIds.has(id)) {
|
if (!deletedElementIds.has(element.id)) {
|
||||||
boundElementIds.add(id);
|
boundElementIds.add(element.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(sceneElements.filter(({ id }) =>
|
(
|
||||||
boundElementIds.has(id),
|
sceneElements.filter(({ id }) =>
|
||||||
) as ExcalidrawLinearElement[]).forEach(
|
boundElementIds.has(id),
|
||||||
(element: ExcalidrawLinearElement) => {
|
) as ExcalidrawLinearElement[]
|
||||||
const { startBinding, endBinding } = element;
|
).forEach((element: ExcalidrawLinearElement) => {
|
||||||
mutateElement(element, {
|
const { startBinding, endBinding } = element;
|
||||||
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
mutateElement(element, {
|
||||||
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||||
});
|
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const newBindingAfterDeletion = (
|
const newBindingAfterDeletion = (
|
||||||
|
|||||||
+19
-2
@@ -3,6 +3,7 @@ import {
|
|||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
NonDeleted,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate } from "../math";
|
import { distance2d, rotate } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
@@ -78,7 +79,7 @@ const getMinMaxXYFromCurvePathOps = (
|
|||||||
// move, bcurveTo, lineTo, and curveTo
|
// move, bcurveTo, lineTo, and curveTo
|
||||||
if (op === "move") {
|
if (op === "move") {
|
||||||
// change starting point
|
// change starting point
|
||||||
currentP = (data as unknown) as Point;
|
currentP = data as unknown as Point;
|
||||||
// move operation does not draw anything; so, it always
|
// move operation does not draw anything; so, it always
|
||||||
// returns false
|
// returns false
|
||||||
} else if (op === "bcurveTo") {
|
} else if (op === "bcurveTo") {
|
||||||
@@ -227,7 +228,7 @@ export const getArrowheadPoints = (
|
|||||||
const prevOp = ops[index - 1];
|
const prevOp = ops[index - 1];
|
||||||
let p0: Point = [0, 0];
|
let p0: Point = [0, 0];
|
||||||
if (prevOp.op === "move") {
|
if (prevOp.op === "move") {
|
||||||
p0 = (prevOp.data as unknown) as Point;
|
p0 = prevOp.data as unknown as Point;
|
||||||
} else if (prevOp.op === "bcurveTo") {
|
} else if (prevOp.op === "bcurveTo") {
|
||||||
p0 = [prevOp.data[4], prevOp.data[5]];
|
p0 = [prevOp.data[4], prevOp.data[5]];
|
||||||
}
|
}
|
||||||
@@ -258,6 +259,7 @@ export const getArrowheadPoints = (
|
|||||||
arrow: 30,
|
arrow: 30,
|
||||||
bar: 15,
|
bar: 15,
|
||||||
dot: 15,
|
dot: 15,
|
||||||
|
triangle: 15,
|
||||||
}[arrowhead]; // pixels (will differ for each arrowhead)
|
}[arrowhead]; // pixels (will differ for each arrowhead)
|
||||||
|
|
||||||
let length = 0;
|
let length = 0;
|
||||||
@@ -294,6 +296,7 @@ export const getArrowheadPoints = (
|
|||||||
const angle = {
|
const angle = {
|
||||||
arrow: 20,
|
arrow: 20,
|
||||||
bar: 90,
|
bar: 90,
|
||||||
|
triangle: 25,
|
||||||
}[arrowhead]; // degrees
|
}[arrowhead]; // degrees
|
||||||
|
|
||||||
// Return points
|
// Return points
|
||||||
@@ -511,3 +514,17 @@ export const getClosestElementBounds = (
|
|||||||
|
|
||||||
return getElementBounds(closestElement);
|
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 };
|
||||||
|
};
|
||||||
|
|||||||
+13
-12
@@ -31,7 +31,9 @@ import { Point } from "../types";
|
|||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShapeForElement } from "../renderer/renderElement";
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
import { isImageElement } from "./typeChecks";
|
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||||
|
import { isTextElement } from ".";
|
||||||
|
import { isTransparent } from "../utils";
|
||||||
|
|
||||||
const isElementDraggableFromInside = (
|
const isElementDraggableFromInside = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
@@ -43,9 +45,9 @@ const isElementDraggableFromInside = (
|
|||||||
if (element.type === "freedraw") {
|
if (element.type === "freedraw") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const isDraggableFromInside =
|
||||||
const isDraggableFromInside = element.backgroundColor !== "transparent";
|
!isTransparent(element.backgroundColor) ||
|
||||||
|
(isTransparent(element.backgroundColor) && hasBoundTextElement(element));
|
||||||
if (element.type === "line") {
|
if (element.type === "line") {
|
||||||
return isDraggableFromInside && isPathALoop(element.points);
|
return isDraggableFromInside && isPathALoop(element.points);
|
||||||
}
|
}
|
||||||
@@ -83,19 +85,18 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHittingElementNotConsideringBoundingBox = (
|
export const isHittingElementNotConsideringBoundingBox = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
point: Point,
|
point: Point,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const threshold = 10 / appState.zoom.value;
|
const threshold = 10 / appState.zoom.value;
|
||||||
|
|
||||||
const check =
|
const check = isTextElement(element)
|
||||||
element.type === "text"
|
? isStrictlyInside
|
||||||
? isStrictlyInside
|
: isElementDraggableFromInside(element)
|
||||||
: isElementDraggableFromInside(element)
|
? isInsideCheck
|
||||||
? isInsideCheck
|
: isNearCheck;
|
||||||
: isNearCheck;
|
|
||||||
|
|
||||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||||
};
|
};
|
||||||
@@ -866,7 +867,7 @@ const hitTestRoughShape = (
|
|||||||
// move, bcurveTo, lineTo, and curveTo
|
// move, bcurveTo, lineTo, and curveTo
|
||||||
if (op === "move") {
|
if (op === "move") {
|
||||||
// change starting point
|
// change starting point
|
||||||
currentP = (data as unknown) as Point;
|
currentP = data as unknown as Point;
|
||||||
// move operation does not draw anything; so, it always
|
// move operation does not draw anything; so, it always
|
||||||
// returns false
|
// returns false
|
||||||
} else if (op === "bcurveTo") {
|
} else if (op === "bcurveTo") {
|
||||||
|
|||||||
+49
-18
@@ -6,13 +6,13 @@ import { getPerfectElementSize } from "./sizeHelpers";
|
|||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { NonDeletedExcalidrawElement } from "./types";
|
import { NonDeletedExcalidrawElement } from "./types";
|
||||||
import { PointerDownState } from "../types";
|
import { PointerDownState } from "../types";
|
||||||
|
import { getBoundTextElementId } from "./textElement";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: NonDeletedExcalidrawElement[],
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
scene: Scene,
|
|
||||||
lockDirection: boolean = false,
|
lockDirection: boolean = false,
|
||||||
distanceX: number = 0,
|
distanceX: number = 0,
|
||||||
distanceY: number = 0,
|
distanceY: number = 0,
|
||||||
@@ -20,30 +20,61 @@ export const dragSelectedElements = (
|
|||||||
const [x1, y1] = getCommonBounds(selectedElements);
|
const [x1, y1] = getCommonBounds(selectedElements);
|
||||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||||
selectedElements.forEach((element) => {
|
selectedElements.forEach((element) => {
|
||||||
let x: number;
|
updateElementCoords(
|
||||||
let y: number;
|
lockDirection,
|
||||||
if (lockDirection) {
|
distanceX,
|
||||||
const lockX = lockDirection && distanceX < distanceY;
|
distanceY,
|
||||||
const lockY = lockDirection && distanceX > distanceY;
|
pointerDownState,
|
||||||
const original = pointerDownState.originalElements.get(element.id);
|
element,
|
||||||
x = lockX && original ? original.x : element.x + offset.x;
|
offset,
|
||||||
y = lockY && original ? original.y : element.y + offset.y;
|
);
|
||||||
} else {
|
if (!element.groupIds.length) {
|
||||||
x = element.x + offset.x;
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
y = element.y + offset.y;
|
if (boundTextElementId) {
|
||||||
|
const textElement =
|
||||||
|
Scene.getScene(element)!.getElement(boundTextElementId);
|
||||||
|
updateElementCoords(
|
||||||
|
lockDirection,
|
||||||
|
distanceX,
|
||||||
|
distanceY,
|
||||||
|
pointerDownState,
|
||||||
|
textElement!,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(element, {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBoundElements(element, {
|
updateBoundElements(element, {
|
||||||
simultaneouslyUpdated: selectedElements,
|
simultaneouslyUpdated: selectedElements,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateElementCoords = (
|
||||||
|
lockDirection: boolean,
|
||||||
|
distanceX: number,
|
||||||
|
distanceY: number,
|
||||||
|
pointerDownState: PointerDownState,
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
offset: { x: number; y: number },
|
||||||
|
) => {
|
||||||
|
let x: number;
|
||||||
|
let y: number;
|
||||||
|
if (lockDirection) {
|
||||||
|
const lockX = lockDirection && distanceX < distanceY;
|
||||||
|
const lockY = lockDirection && distanceX > distanceY;
|
||||||
|
const original = pointerDownState.originalElements.get(element.id);
|
||||||
|
x = lockX && original ? original.x : element.x + offset.x;
|
||||||
|
y = lockY && original ? original.y : element.y + offset.y;
|
||||||
|
} else {
|
||||||
|
x = element.x + offset.x;
|
||||||
|
y = element.y + offset.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateElement(element, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
|
};
|
||||||
export const getDragOffsetXY = (
|
export const getDragOffsetXY = (
|
||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: NonDeletedExcalidrawElement[],
|
||||||
x: number,
|
x: number,
|
||||||
|
|||||||
+14
-1
@@ -63,7 +63,7 @@ export const updateImageCache = async ({
|
|||||||
const image = await imagePromise;
|
const image = await imagePromise;
|
||||||
|
|
||||||
imageCache.set(fileId, { ...data, image });
|
imageCache.set(fileId, { ...data, image });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
erroredFiles.set(fileId, true);
|
erroredFiles.set(fileId, true);
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
@@ -109,3 +109,16 @@ export const normalizeSVG = async (SVGString: string) => {
|
|||||||
return svg.outerHTML;
|
return svg.outerHTML;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const imageFromImageData = (imagedata: ImageData) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
canvas.width = imagedata.width;
|
||||||
|
canvas.height = imagedata.height;
|
||||||
|
ctx.putImageData(imagedata, 0, 0);
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
const dataURL = canvas.toDataURL() as DataURL;
|
||||||
|
image.src = dataURL;
|
||||||
|
return { image, dataURL };
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { distance2d } from "../math";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import {
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
InitializedExcalidrawImageElement,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export type EditingImageElement = {
|
||||||
|
editorType: "alpha";
|
||||||
|
elementId: ExcalidrawImageElement["id"];
|
||||||
|
origImageData: Readonly<ImageData>;
|
||||||
|
imageData: ImageData;
|
||||||
|
pointerDownState: {
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
sampledPixel: readonly [number, number, number, number] | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElement = (id: EditingImageElement["elementId"]) => {
|
||||||
|
const element = Scene.getScene(id)?.getNonDeletedElement(id);
|
||||||
|
if (element) {
|
||||||
|
return element as InitializedExcalidrawImageElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ImageEditor {
|
||||||
|
static handlePointerDown(
|
||||||
|
editingElement: EditingImageElement,
|
||||||
|
scenePointer: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
const imageElement = getElement(editingElement.elementId);
|
||||||
|
|
||||||
|
if (imageElement) {
|
||||||
|
if (
|
||||||
|
scenePointer.x >= imageElement.x &&
|
||||||
|
scenePointer.x <= imageElement.x + imageElement.width &&
|
||||||
|
scenePointer.y >= imageElement.y &&
|
||||||
|
scenePointer.y <= imageElement.y + imageElement.height
|
||||||
|
) {
|
||||||
|
editingElement.pointerDownState.screenX = scenePointer.x;
|
||||||
|
editingElement.pointerDownState.screenY = scenePointer.y;
|
||||||
|
|
||||||
|
const { width, height, data } = editingElement.origImageData;
|
||||||
|
|
||||||
|
const imageOffsetX = Math.round(
|
||||||
|
(scenePointer.x - imageElement.x) * (width / imageElement.width),
|
||||||
|
);
|
||||||
|
const imageOffsetY = Math.round(
|
||||||
|
(scenePointer.y - imageElement.y) * (height / imageElement.height),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampledPixel = [
|
||||||
|
data[(imageOffsetY * width + imageOffsetX) * 4 + 0],
|
||||||
|
data[(imageOffsetY * width + imageOffsetX) * 4 + 1],
|
||||||
|
data[(imageOffsetY * width + imageOffsetX) * 4 + 2],
|
||||||
|
data[(imageOffsetY * width + imageOffsetX) * 4 + 3],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
editingElement.pointerDownState.sampledPixel = sampledPixel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static handlePointerMove(
|
||||||
|
editingElement: EditingImageElement,
|
||||||
|
scenePointer: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
const { sampledPixel } = editingElement.pointerDownState;
|
||||||
|
if (sampledPixel) {
|
||||||
|
const { screenX, screenY } = editingElement.pointerDownState;
|
||||||
|
const distance = distance2d(
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
screenX,
|
||||||
|
screenY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { width, height, data } = editingElement.origImageData;
|
||||||
|
const newImageData = new ImageData(width, height);
|
||||||
|
|
||||||
|
for (let x = 0; x < width; ++x) {
|
||||||
|
for (let y = 0; y < height; ++y) {
|
||||||
|
if (
|
||||||
|
Math.abs(sampledPixel[0] - data[(y * width + x) * 4 + 0]) +
|
||||||
|
Math.abs(sampledPixel[1] - data[(y * width + x) * 4 + 1]) +
|
||||||
|
Math.abs(sampledPixel[2] - data[(y * width + x) * 4 + 2]) <
|
||||||
|
distance
|
||||||
|
) {
|
||||||
|
newImageData.data[(y * width + x) * 4 + 0] = 0;
|
||||||
|
newImageData.data[(y * width + x) * 4 + 1] = 255;
|
||||||
|
newImageData.data[(y * width + x) * 4 + 2] = 0;
|
||||||
|
newImageData.data[(y * width + x) * 4 + 3] = 0;
|
||||||
|
} else {
|
||||||
|
for (let p = 0; p < 4; ++p) {
|
||||||
|
newImageData.data[(y * width + x) * 4 + p] =
|
||||||
|
data[(y * width + x) * 4 + p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newImageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static handlePointerUp(editingElement: EditingImageElement) {
|
||||||
|
editingElement.pointerDownState.sampledPixel = null;
|
||||||
|
editingElement.origImageData = editingElement.imageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,15 +59,6 @@ export {
|
|||||||
} from "./sizeHelpers";
|
} from "./sizeHelpers";
|
||||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||||
|
|
||||||
export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
|
|
||||||
elements.reduce(
|
|
||||||
(acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
|
|
||||||
acc[element.id] = element;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||||
elements.reduce((acc, el) => acc + el.version, 0);
|
elements.reduce((acc, el) => acc + el.version, 0);
|
||||||
|
|
||||||
|
|||||||
+419
-107
@@ -25,11 +25,19 @@ export class LinearElementEditor {
|
|||||||
public elementId: ExcalidrawElement["id"] & {
|
public elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
};
|
};
|
||||||
public activePointIndex: number | null;
|
/** indices */
|
||||||
|
public selectedPointsIndices: readonly number[] | null;
|
||||||
|
|
||||||
|
public pointerDownState: Readonly<{
|
||||||
|
prevSelectedPointsIndices: readonly number[] | null;
|
||||||
|
/** index */
|
||||||
|
lastClickedPoint: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
/** whether you're dragging a point */
|
/** whether you're dragging a point */
|
||||||
public isDragging: boolean;
|
public isDragging: boolean;
|
||||||
public lastUncommittedPoint: Point | null;
|
public lastUncommittedPoint: Point | null;
|
||||||
public pointerOffset: { x: number; y: number };
|
public pointerOffset: Readonly<{ x: number; y: number }>;
|
||||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
|
|
||||||
@@ -40,12 +48,16 @@ export class LinearElementEditor {
|
|||||||
Scene.mapElementToScene(this.elementId, scene);
|
Scene.mapElementToScene(this.elementId, scene);
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element);
|
||||||
|
|
||||||
this.activePointIndex = null;
|
this.selectedPointsIndices = null;
|
||||||
this.lastUncommittedPoint = null;
|
this.lastUncommittedPoint = null;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.pointerOffset = { x: 0, y: 0 };
|
this.pointerOffset = { x: 0, y: 0 };
|
||||||
this.startBindingElement = "keep";
|
this.startBindingElement = "keep";
|
||||||
this.endBindingElement = "keep";
|
this.endBindingElement = "keep";
|
||||||
|
this.pointerDownState = {
|
||||||
|
prevSelectedPointsIndices: null,
|
||||||
|
lastClickedPoint: -1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -66,6 +78,58 @@ export class LinearElementEditor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static handleBoxSelection(
|
||||||
|
event: PointerEvent,
|
||||||
|
appState: AppState,
|
||||||
|
setState: React.Component<any, AppState>["setState"],
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!appState.editingLinearElement ||
|
||||||
|
appState.draggingElement?.type !== "selection"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { editingLinearElement } = appState;
|
||||||
|
const { selectedPointsIndices, elementId } = editingLinearElement;
|
||||||
|
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||||
|
getElementAbsoluteCoords(appState.draggingElement);
|
||||||
|
|
||||||
|
const pointsSceneCoords =
|
||||||
|
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
|
||||||
|
const nextSelectedPoints = pointsSceneCoords.reduce(
|
||||||
|
(acc: number[], point, index) => {
|
||||||
|
if (
|
||||||
|
(point[0] >= selectionX1 &&
|
||||||
|
point[0] <= selectionX2 &&
|
||||||
|
point[1] >= selectionY1 &&
|
||||||
|
point[1] <= selectionY2) ||
|
||||||
|
(event.shiftKey && selectedPointsIndices?.includes(index))
|
||||||
|
) {
|
||||||
|
acc.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
editingLinearElement: {
|
||||||
|
...editingLinearElement,
|
||||||
|
selectedPointsIndices: nextSelectedPoints.length
|
||||||
|
? nextSelectedPoints
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** @returns whether point was dragged */
|
/** @returns whether point was dragged */
|
||||||
static handlePointDragging(
|
static handlePointDragging(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -74,21 +138,27 @@ export class LinearElementEditor {
|
|||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
maybeSuggestBinding: (
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startOrEnd: "start" | "end",
|
pointSceneCoords: { x: number; y: number }[],
|
||||||
) => void,
|
) => void,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!appState.editingLinearElement) {
|
if (!appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { editingLinearElement } = appState;
|
const { editingLinearElement } = appState;
|
||||||
const { activePointIndex, elementId, isDragging } = editingLinearElement;
|
const { selectedPointsIndices, elementId, isDragging } =
|
||||||
|
editingLinearElement;
|
||||||
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePointIndex != null && activePointIndex > -1) {
|
// point that's being dragged (out of all selected points)
|
||||||
|
const draggingPoint = element.points[
|
||||||
|
editingLinearElement.pointerDownState.lastClickedPoint
|
||||||
|
] as [number, number] | undefined;
|
||||||
|
|
||||||
|
if (selectedPointsIndices && draggingPoint) {
|
||||||
if (isDragging === false) {
|
if (isDragging === false) {
|
||||||
setState({
|
setState({
|
||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
@@ -98,18 +168,79 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPoint = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||||
appState.gridSize,
|
appState.gridSize,
|
||||||
);
|
);
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
|
|
||||||
|
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||||
|
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||||
|
|
||||||
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
|
const newPointPosition =
|
||||||
|
pointIndex ===
|
||||||
|
editingLinearElement.pointerDownState.lastClickedPoint
|
||||||
|
? LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||||
|
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||||
|
appState.gridSize,
|
||||||
|
)
|
||||||
|
: ([
|
||||||
|
element.points[pointIndex][0] + deltaX,
|
||||||
|
element.points[pointIndex][1] + deltaY,
|
||||||
|
] as const);
|
||||||
|
return {
|
||||||
|
index: pointIndex,
|
||||||
|
point: newPointPosition,
|
||||||
|
isDragging:
|
||||||
|
pointIndex ===
|
||||||
|
editingLinearElement.pointerDownState.lastClickedPoint,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// suggest bindings for first and last point if selected
|
||||||
if (isBindingElement(element)) {
|
if (isBindingElement(element)) {
|
||||||
maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
|
const coords: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
const firstSelectedIndex = selectedPointsIndices[0];
|
||||||
|
if (firstSelectedIndex === 0) {
|
||||||
|
coords.push(
|
||||||
|
tupleToCoors(
|
||||||
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
element.points[0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSelectedIndex =
|
||||||
|
selectedPointsIndices[selectedPointsIndices.length - 1];
|
||||||
|
if (lastSelectedIndex === element.points.length - 1) {
|
||||||
|
coords.push(
|
||||||
|
tupleToCoors(
|
||||||
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
element.points[lastSelectedIndex],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coords.length) {
|
||||||
|
maybeSuggestBinding(element, coords);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,46 +249,79 @@ export class LinearElementEditor {
|
|||||||
editingLinearElement: LinearElementEditor,
|
editingLinearElement: LinearElementEditor,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
const { elementId, activePointIndex, isDragging } = editingLinearElement;
|
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||||
|
editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return editingLinearElement;
|
return editingLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let binding = {};
|
const bindings: Partial<
|
||||||
if (
|
Pick<
|
||||||
isDragging &&
|
InstanceType<typeof LinearElementEditor>,
|
||||||
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
|
"startBindingElement" | "endBindingElement"
|
||||||
) {
|
>
|
||||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
> = {};
|
||||||
LinearElementEditor.movePoint(
|
|
||||||
element,
|
if (isDragging && selectedPointsIndices) {
|
||||||
activePointIndex,
|
for (const selectedPoint of selectedPointsIndices) {
|
||||||
activePointIndex === 0
|
if (
|
||||||
? element.points[element.points.length - 1]
|
selectedPoint === 0 ||
|
||||||
: element.points[0],
|
selectedPoint === element.points.length - 1
|
||||||
);
|
) {
|
||||||
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
|
LinearElementEditor.movePoints(element, [
|
||||||
|
{
|
||||||
|
index: selectedPoint,
|
||||||
|
point:
|
||||||
|
selectedPoint === 0
|
||||||
|
? element.points[element.points.length - 1]
|
||||||
|
: element.points[0],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingElement = isBindingEnabled(appState)
|
||||||
|
? getHoveredElementForBinding(
|
||||||
|
tupleToCoors(
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
selectedPoint!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Scene.getScene(element)!,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
bindings[
|
||||||
|
selectedPoint === 0 ? "startBindingElement" : "endBindingElement"
|
||||||
|
] = bindingElement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const bindingElement = isBindingEnabled(appState)
|
|
||||||
? getHoveredElementForBinding(
|
|
||||||
tupleToCoors(
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
element,
|
|
||||||
activePointIndex!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Scene.getScene(element)!,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
binding = {
|
|
||||||
[activePointIndex === 0
|
|
||||||
? "startBindingElement"
|
|
||||||
: "endBindingElement"]: bindingElement,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...editingLinearElement,
|
...editingLinearElement,
|
||||||
...binding,
|
...bindings,
|
||||||
|
// if clicking without previously dragging a point(s), and not holding
|
||||||
|
// shift, deselect all points except the one clicked. If holding shift,
|
||||||
|
// toggle the point.
|
||||||
|
selectedPointsIndices:
|
||||||
|
isDragging || event.shiftKey
|
||||||
|
? !isDragging &&
|
||||||
|
event.shiftKey &&
|
||||||
|
pointerDownState.prevSelectedPointsIndices?.includes(
|
||||||
|
pointerDownState.lastClickedPoint,
|
||||||
|
)
|
||||||
|
? selectedPointsIndices &&
|
||||||
|
selectedPointsIndices.filter(
|
||||||
|
(pointIndex) =>
|
||||||
|
pointIndex !== pointerDownState.lastClickedPoint,
|
||||||
|
)
|
||||||
|
: selectedPointsIndices
|
||||||
|
: selectedPointsIndices?.includes(pointerDownState.lastClickedPoint)
|
||||||
|
? [pointerDownState.lastClickedPoint]
|
||||||
|
: selectedPointsIndices,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
pointerOffset: { x: 0, y: 0 },
|
pointerOffset: { x: 0, y: 0 },
|
||||||
};
|
};
|
||||||
@@ -207,7 +371,12 @@ export class LinearElementEditor {
|
|||||||
setState({
|
setState({
|
||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
activePointIndex: element.points.length - 1,
|
pointerDownState: {
|
||||||
|
prevSelectedPointsIndices:
|
||||||
|
appState.editingLinearElement.selectedPointsIndices,
|
||||||
|
lastClickedPoint: -1,
|
||||||
|
},
|
||||||
|
selectedPointsIndices: [element.points.length - 1],
|
||||||
lastUncommittedPoint: null,
|
lastUncommittedPoint: null,
|
||||||
endBindingElement: getHoveredElementForBinding(
|
endBindingElement: getHoveredElementForBinding(
|
||||||
scenePointer,
|
scenePointer,
|
||||||
@@ -236,10 +405,8 @@ export class LinearElementEditor {
|
|||||||
// from the end points of the `linearElement` - this is to allow disabling
|
// from the end points of the `linearElement` - this is to allow disabling
|
||||||
// binding (which needs to happen at the point the user finishes moving
|
// binding (which needs to happen at the point the user finishes moving
|
||||||
// the point).
|
// the point).
|
||||||
const {
|
const { startBindingElement, endBindingElement } =
|
||||||
startBindingElement,
|
appState.editingLinearElement;
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||||
bindOrUnbindLinearElement(
|
bindOrUnbindLinearElement(
|
||||||
element,
|
element,
|
||||||
@@ -262,10 +429,28 @@ export class LinearElementEditor {
|
|||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nextSelectedPointsIndices =
|
||||||
|
clickedPointIndex > -1 || event.shiftKey
|
||||||
|
? event.shiftKey ||
|
||||||
|
appState.editingLinearElement.selectedPointsIndices?.includes(
|
||||||
|
clickedPointIndex,
|
||||||
|
)
|
||||||
|
? normalizeSelectedPoints([
|
||||||
|
...(appState.editingLinearElement.selectedPointsIndices || []),
|
||||||
|
clickedPointIndex,
|
||||||
|
])
|
||||||
|
: [clickedPointIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
|
pointerDownState: {
|
||||||
|
prevSelectedPointsIndices:
|
||||||
|
appState.editingLinearElement.selectedPointsIndices,
|
||||||
|
lastClickedPoint: clickedPointIndex,
|
||||||
|
},
|
||||||
|
selectedPointsIndices: nextSelectedPointsIndices,
|
||||||
pointerOffset: targetPoint
|
pointerOffset: targetPoint
|
||||||
? {
|
? {
|
||||||
x: scenePointer.x - targetPoint[0],
|
x: scenePointer.x - targetPoint[0],
|
||||||
@@ -295,7 +480,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoint(element, points.length - 1, "delete");
|
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||||
}
|
}
|
||||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
return { ...editingLinearElement, lastUncommittedPoint: null };
|
||||||
}
|
}
|
||||||
@@ -308,13 +493,14 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoint(
|
LinearElementEditor.movePoints(element, [
|
||||||
element,
|
{
|
||||||
element.points.length - 1,
|
index: element.points.length - 1,
|
||||||
newPoint,
|
point: newPoint,
|
||||||
);
|
},
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.movePoint(element, "new", newPoint);
|
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -323,6 +509,21 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** scene coords */
|
||||||
|
static getPointGlobalCoordinates(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
point: Point,
|
||||||
|
) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
const cy = (y1 + y2) / 2;
|
||||||
|
|
||||||
|
let { x, y } = element;
|
||||||
|
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||||
|
return [x, y] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** scene coords */
|
||||||
static getPointsGlobalCoordinates(
|
static getPointsGlobalCoordinates(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
) {
|
) {
|
||||||
@@ -442,22 +643,122 @@ export class LinearElementEditor {
|
|||||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
static movePointByOffset(
|
static duplicateSelectedPoints(appState: AppState) {
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
if (!appState.editingLinearElement) {
|
||||||
pointIndex: number,
|
return false;
|
||||||
offset: { x: number; y: number },
|
}
|
||||||
) {
|
|
||||||
const [x, y] = element.points[pointIndex];
|
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||||
LinearElementEditor.movePoint(element, pointIndex, [
|
|
||||||
x + offset.x,
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
y + offset.y,
|
|
||||||
]);
|
if (!element || selectedPointsIndices === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { points } = element;
|
||||||
|
|
||||||
|
const nextSelectedIndices: number[] = [];
|
||||||
|
|
||||||
|
let pointAddedToEnd = false;
|
||||||
|
let indexCursor = -1;
|
||||||
|
const nextPoints = points.reduce((acc: Point[], point, index) => {
|
||||||
|
++indexCursor;
|
||||||
|
acc.push(point);
|
||||||
|
|
||||||
|
const isSelected = selectedPointsIndices.includes(index);
|
||||||
|
if (isSelected) {
|
||||||
|
const nextPoint = points[index + 1];
|
||||||
|
|
||||||
|
if (!nextPoint) {
|
||||||
|
pointAddedToEnd = true;
|
||||||
|
}
|
||||||
|
acc.push(
|
||||||
|
nextPoint
|
||||||
|
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
|
||||||
|
: [point[0], point[1]],
|
||||||
|
);
|
||||||
|
|
||||||
|
nextSelectedIndices.push(indexCursor + 1);
|
||||||
|
++indexCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
mutateElement(element, { points: nextPoints });
|
||||||
|
|
||||||
|
// temp hack to ensure the line doesn't move when adding point to the end,
|
||||||
|
// potentially expanding the bounding box
|
||||||
|
if (pointAddedToEnd) {
|
||||||
|
const lastPoint = element.points[element.points.length - 1];
|
||||||
|
LinearElementEditor.movePoints(element, [
|
||||||
|
{
|
||||||
|
index: element.points.length - 1,
|
||||||
|
point: [lastPoint[0] + 30, lastPoint[1] + 30],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement: {
|
||||||
|
...appState.editingLinearElement,
|
||||||
|
selectedPointsIndices: nextSelectedIndices,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static movePoint(
|
static deletePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
pointIndex: number | "new",
|
pointIndices: readonly number[],
|
||||||
targetPosition: Point | "delete",
|
) {
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
const isDeletingOriginPoint = pointIndices.includes(0);
|
||||||
|
|
||||||
|
// if deleting first point, make the next to be [0,0] and recalculate
|
||||||
|
// positions of the rest with respect to it
|
||||||
|
if (isDeletingOriginPoint) {
|
||||||
|
const firstNonDeletedPoint = element.points.find((point, idx) => {
|
||||||
|
return !pointIndices.includes(idx);
|
||||||
|
});
|
||||||
|
if (firstNonDeletedPoint) {
|
||||||
|
offsetX = firstNonDeletedPoint[0];
|
||||||
|
offsetY = firstNonDeletedPoint[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
|
||||||
|
if (!pointIndices.includes(idx)) {
|
||||||
|
acc.push(
|
||||||
|
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addPoints(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
targetPoints: { point: Point }[],
|
||||||
|
) {
|
||||||
|
const offsetX = 0;
|
||||||
|
const offsetY = 0;
|
||||||
|
|
||||||
|
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||||
|
|
||||||
|
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static movePoints(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
|
||||||
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
||||||
) {
|
) {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@@ -470,49 +771,50 @@ export class LinearElementEditor {
|
|||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
|
|
||||||
let nextPoints: (readonly [number, number])[];
|
const selectedOriginPoint = targetPoints.find(({ index }) => index === 0);
|
||||||
if (targetPosition === "delete") {
|
|
||||||
// remove point
|
|
||||||
if (pointIndex === "new") {
|
|
||||||
throw new Error("invalid args in movePoint");
|
|
||||||
}
|
|
||||||
nextPoints = points.slice();
|
|
||||||
nextPoints.splice(pointIndex, 1);
|
|
||||||
if (pointIndex === 0) {
|
|
||||||
// if deleting first point, make the next to be [0,0] and recalculate
|
|
||||||
// positions of the rest with respect to it
|
|
||||||
offsetX = nextPoints[0][0];
|
|
||||||
offsetY = nextPoints[0][1];
|
|
||||||
nextPoints = nextPoints.map((point, idx) => {
|
|
||||||
if (idx === 0) {
|
|
||||||
return [0, 0];
|
|
||||||
}
|
|
||||||
return [point[0] - offsetX, point[1] - offsetY];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (pointIndex === "new") {
|
|
||||||
nextPoints = [...points, targetPosition];
|
|
||||||
} else {
|
|
||||||
const deltaX = targetPosition[0] - points[pointIndex][0];
|
|
||||||
const deltaY = targetPosition[1] - points[pointIndex][1];
|
|
||||||
nextPoints = points.map((point, idx) => {
|
|
||||||
if (idx === pointIndex) {
|
|
||||||
if (idx === 0) {
|
|
||||||
offsetX = deltaX;
|
|
||||||
offsetY = deltaY;
|
|
||||||
return point;
|
|
||||||
}
|
|
||||||
offsetX = 0;
|
|
||||||
offsetY = 0;
|
|
||||||
|
|
||||||
return [point[0] + deltaX, point[1] + deltaY] as const;
|
if (selectedOriginPoint) {
|
||||||
}
|
offsetX =
|
||||||
return offsetX || offsetY
|
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
||||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
offsetY =
|
||||||
: point;
|
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextPoints = points.map((point, idx) => {
|
||||||
|
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
||||||
|
if (selectedPointData) {
|
||||||
|
if (selectedOriginPoint) {
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX =
|
||||||
|
selectedPointData.point[0] - points[selectedPointData.index][0];
|
||||||
|
const deltaY =
|
||||||
|
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||||
|
|
||||||
|
return [point[0] + deltaX, point[1] + deltaY] as const;
|
||||||
|
}
|
||||||
|
return offsetX || offsetY
|
||||||
|
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
||||||
|
: point;
|
||||||
|
});
|
||||||
|
|
||||||
|
LinearElementEditor._updatePoints(
|
||||||
|
element,
|
||||||
|
nextPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
otherUpdates,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static _updatePoints(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
nextPoints: readonly Point[],
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
||||||
|
) {
|
||||||
const nextCoords = getElementPointsCoords(
|
const nextCoords = getElementPointsCoords(
|
||||||
element,
|
element,
|
||||||
nextPoints,
|
nextPoints,
|
||||||
@@ -520,7 +822,7 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
const prevCoords = getElementPointsCoords(
|
const prevCoords = getElementPointsCoords(
|
||||||
element,
|
element,
|
||||||
points,
|
element.points,
|
||||||
element.strokeSharpness || "round",
|
element.strokeSharpness || "round",
|
||||||
);
|
);
|
||||||
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
||||||
@@ -539,3 +841,13 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeSelectedPoints = (
|
||||||
|
points: (number | null)[],
|
||||||
|
): number[] | null => {
|
||||||
|
let nextPoints = [
|
||||||
|
...new Set(points.filter((p) => p !== null && p !== -1)),
|
||||||
|
] as number[];
|
||||||
|
nextPoints = nextPoints.sort((a, b) => a - b);
|
||||||
|
return nextPoints.length ? nextPoints : null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Scene from "../scene/Scene";
|
|||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
|
import { getUpdatedTimestamp } from "../utils";
|
||||||
|
|
||||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
@@ -92,6 +93,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
|
|
||||||
element.version++;
|
element.version++;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = randomInteger();
|
||||||
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
if (informMutation) {
|
if (informMutation) {
|
||||||
Scene.getScene(element)?.informMutation();
|
Scene.getScene(element)?.informMutation();
|
||||||
@@ -126,13 +128,14 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
...updates,
|
...updates,
|
||||||
|
updated: getUpdatedTimestamp(),
|
||||||
version: element.version + 1,
|
version: element.version + 1,
|
||||||
versionNonce: randomInteger(),
|
versionNonce: randomInteger(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutates element and updates `version` & `versionNonce`.
|
* Mutates element, bumping `version`, `versionNonce`, and `updated`.
|
||||||
*
|
*
|
||||||
* NOTE: does not trigger re-render.
|
* NOTE: does not trigger re-render.
|
||||||
*/
|
*/
|
||||||
@@ -142,5 +145,6 @@ export const bumpVersion = (
|
|||||||
) => {
|
) => {
|
||||||
element.version = (version ?? element.version) + 1;
|
element.version = (version ?? element.version) + 1;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = randomInteger();
|
||||||
|
element.updated = getUpdatedTimestamp();
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
|||||||
+81
-35
@@ -11,23 +11,28 @@ import {
|
|||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
|
ExcalidrawRectangleElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { measureText, getFontString } from "../utils";
|
import { getFontString, getUpdatedTimestamp } from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getNewGroupIdsForDuplication } from "../groups";
|
import { getNewGroupIdsForDuplication } from "../groups";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
import { adjustXYWithRotation } from "../math";
|
import { adjustXYWithRotation } from "../math";
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
|
import { measureText } from "./textElement";
|
||||||
|
import { isBoundToContainer } from "./typeChecks";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import { PADDING } from "../constants";
|
||||||
|
|
||||||
type ElementConstructorOpts = MarkOptional<
|
type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
| "width"
|
| "width"
|
||||||
| "height"
|
| "height"
|
||||||
| "angle"
|
| "angle"
|
||||||
| "groupIds"
|
| "groupIds"
|
||||||
| "boundElementIds"
|
| "boundElements"
|
||||||
| "seed"
|
| "seed"
|
||||||
| "version"
|
| "version"
|
||||||
| "versionNonce"
|
| "versionNonce"
|
||||||
@@ -50,32 +55,36 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
angle = 0,
|
angle = 0,
|
||||||
groupIds = [],
|
groupIds = [],
|
||||||
strokeSharpness,
|
strokeSharpness,
|
||||||
boundElementIds = null,
|
boundElements = null,
|
||||||
...rest
|
...rest
|
||||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||||
) => ({
|
) => {
|
||||||
id: rest.id || randomId(),
|
const element = {
|
||||||
type,
|
id: rest.id || randomId(),
|
||||||
x,
|
type,
|
||||||
y,
|
x,
|
||||||
width,
|
y,
|
||||||
height,
|
width,
|
||||||
angle,
|
height,
|
||||||
strokeColor,
|
angle,
|
||||||
backgroundColor,
|
strokeColor,
|
||||||
fillStyle,
|
backgroundColor,
|
||||||
strokeWidth,
|
fillStyle,
|
||||||
strokeStyle,
|
strokeWidth,
|
||||||
roughness,
|
strokeStyle,
|
||||||
opacity,
|
roughness,
|
||||||
groupIds,
|
opacity,
|
||||||
strokeSharpness,
|
groupIds,
|
||||||
seed: rest.seed ?? randomInteger(),
|
strokeSharpness,
|
||||||
version: rest.version || 1,
|
seed: rest.seed ?? randomInteger(),
|
||||||
versionNonce: rest.versionNonce ?? 0,
|
version: rest.version || 1,
|
||||||
isDeleted: false as false,
|
versionNonce: rest.versionNonce ?? 0,
|
||||||
boundElementIds,
|
isDeleted: false as false,
|
||||||
});
|
boundElements,
|
||||||
|
updated: getUpdatedTimestamp(),
|
||||||
|
};
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
export const newElement = (
|
export const newElement = (
|
||||||
opts: {
|
opts: {
|
||||||
@@ -113,6 +122,7 @@ export const newTextElement = (
|
|||||||
fontFamily: FontFamilyValues;
|
fontFamily: FontFamilyValues;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
verticalAlign: VerticalAlign;
|
verticalAlign: VerticalAlign;
|
||||||
|
containerId?: ExcalidrawRectangleElement["id"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): NonDeleted<ExcalidrawTextElement> => {
|
||||||
const metrics = measureText(opts.text, getFontString(opts));
|
const metrics = measureText(opts.text, getFontString(opts));
|
||||||
@@ -130,6 +140,8 @@ export const newTextElement = (
|
|||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
|
containerId: opts.containerId || null,
|
||||||
|
originalText: opts.text,
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -146,18 +158,25 @@ const getAdjustedDimensions = (
|
|||||||
height: number;
|
height: number;
|
||||||
baseline: number;
|
baseline: number;
|
||||||
} => {
|
} => {
|
||||||
|
const maxWidth = element.containerId ? element.width : null;
|
||||||
const {
|
const {
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
baseline: nextBaseline,
|
baseline: nextBaseline,
|
||||||
} = measureText(nextText, getFontString(element));
|
} = measureText(nextText, getFontString(element), maxWidth);
|
||||||
const { textAlign, verticalAlign } = element;
|
const { textAlign, verticalAlign } = element;
|
||||||
|
|
||||||
let x: number;
|
let x: number;
|
||||||
let y: number;
|
let y: number;
|
||||||
|
if (
|
||||||
if (textAlign === "center" && verticalAlign === "middle") {
|
textAlign === "center" &&
|
||||||
const prevMetrics = measureText(element.text, getFontString(element));
|
verticalAlign === "middle" &&
|
||||||
|
!element.containerId
|
||||||
|
) {
|
||||||
|
const prevMetrics = measureText(
|
||||||
|
element.text,
|
||||||
|
getFontString(element),
|
||||||
|
maxWidth,
|
||||||
|
);
|
||||||
const offsets = getTextElementPositionOffsets(element, {
|
const offsets = getTextElementPositionOffsets(element, {
|
||||||
width: nextWidth - prevMetrics.width,
|
width: nextWidth - prevMetrics.width,
|
||||||
height: nextHeight - prevMetrics.height,
|
height: nextHeight - prevMetrics.height,
|
||||||
@@ -194,6 +213,22 @@ const getAdjustedDimensions = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure container dimensions are set properly when
|
||||||
|
// text editor overflows beyond viewport dimensions
|
||||||
|
if (isBoundToContainer(element)) {
|
||||||
|
const container = Scene.getScene(element)!.getElement(element.containerId)!;
|
||||||
|
let height = container.height;
|
||||||
|
let width = container.width;
|
||||||
|
if (nextHeight > height - PADDING * 2) {
|
||||||
|
height = nextHeight + PADDING * 2;
|
||||||
|
}
|
||||||
|
if (nextWidth > width - PADDING * 2) {
|
||||||
|
width = nextWidth + PADDING * 2;
|
||||||
|
}
|
||||||
|
if (height !== container.height || width !== container.width) {
|
||||||
|
mutateElement(container, { height, width });
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
@@ -205,12 +240,22 @@ const getAdjustedDimensions = (
|
|||||||
|
|
||||||
export const updateTextElement = (
|
export const updateTextElement = (
|
||||||
element: ExcalidrawTextElement,
|
element: ExcalidrawTextElement,
|
||||||
{ text, isDeleted }: { text: string; isDeleted?: boolean },
|
{
|
||||||
|
text,
|
||||||
|
isDeleted,
|
||||||
|
originalText,
|
||||||
|
}: { text: string; isDeleted?: boolean; originalText: string },
|
||||||
|
|
||||||
|
updateDimensions: boolean,
|
||||||
): ExcalidrawTextElement => {
|
): ExcalidrawTextElement => {
|
||||||
|
const dimensions = updateDimensions
|
||||||
|
? getAdjustedDimensions(element, text)
|
||||||
|
: undefined;
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
text,
|
text,
|
||||||
|
originalText,
|
||||||
isDeleted: isDeleted ?? element.isDeleted,
|
isDeleted: isDeleted ?? element.isDeleted,
|
||||||
...getAdjustedDimensions(element, text),
|
...dimensions,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,6 +382,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
} else {
|
} else {
|
||||||
copy.id = randomId();
|
copy.id = randomId();
|
||||||
}
|
}
|
||||||
|
copy.updated = getUpdatedTimestamp();
|
||||||
copy.seed = randomInteger();
|
copy.seed = randomInteger();
|
||||||
copy.groupIds = getNewGroupIdsForDuplication(
|
copy.groupIds = getNewGroupIdsForDuplication(
|
||||||
copy.groupIds,
|
copy.groupIds,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import { measureText, getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import {
|
import {
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
@@ -33,6 +33,13 @@ import {
|
|||||||
TransformHandleDirection,
|
TransformHandleDirection,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
import { Point, PointerDownState } from "../types";
|
import { Point, PointerDownState } from "../types";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import {
|
||||||
|
getApproxMinLineWidth,
|
||||||
|
getBoundTextElementId,
|
||||||
|
handleBindTextResize,
|
||||||
|
measureText,
|
||||||
|
} from "./textElement";
|
||||||
|
|
||||||
export const normalizeAngle = (angle: number): number => {
|
export const normalizeAngle = (angle: number): number => {
|
||||||
if (angle >= 2 * Math.PI) {
|
if (angle >= 2 * Math.PI) {
|
||||||
@@ -132,6 +139,7 @@ export const transformElements = (
|
|||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
|
handleBindTextResize(selectedElements, transformHandleType);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +162,11 @@ const rotateSingleElement = (
|
|||||||
}
|
}
|
||||||
angle = normalizeAngle(angle);
|
angle = normalizeAngle(angle);
|
||||||
mutateElement(element, { angle });
|
mutateElement(element, { angle });
|
||||||
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
if (boundTextElementId) {
|
||||||
|
const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
|
||||||
|
mutateElement(textElement!, { angle });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// used in DEV only
|
// used in DEV only
|
||||||
@@ -272,6 +285,7 @@ const measureFontSizeFromWH = (
|
|||||||
const metrics = measureText(
|
const metrics = measureText(
|
||||||
element.text,
|
element.text,
|
||||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||||
|
element.containerId ? element.width : null,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
size: nextFontSize,
|
size: nextFontSize,
|
||||||
@@ -413,6 +427,9 @@ export const resizeSingleElement = (
|
|||||||
element.width,
|
element.width,
|
||||||
element.height,
|
element.height,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
const boundsCurrentWidth = esx2 - esx1;
|
||||||
const boundsCurrentHeight = esy2 - esy1;
|
const boundsCurrentHeight = esy2 - esy1;
|
||||||
|
|
||||||
@@ -464,19 +481,20 @@ export const resizeSingleElement = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||||
newBoundsX1,
|
getResizedElementAbsoluteCoords(
|
||||||
newBoundsY1,
|
stateAtResizeStart,
|
||||||
newBoundsX2,
|
eleNewWidth,
|
||||||
newBoundsY2,
|
eleNewHeight,
|
||||||
] = getResizedElementAbsoluteCoords(
|
);
|
||||||
stateAtResizeStart,
|
|
||||||
eleNewWidth,
|
|
||||||
eleNewHeight,
|
|
||||||
);
|
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||||
|
|
||||||
|
// don't allow resize to negative dimensions when text is bounded to container
|
||||||
|
if ((newBoundsWidth < 0 || newBoundsHeight < 0) && boundTextElementId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate new topLeft based on fixed corner during resize
|
// Calculate new topLeft based on fixed corner during resize
|
||||||
let newTopLeft = [...startTopLeft] as [number, number];
|
let newTopLeft = [...startTopLeft] as [number, number];
|
||||||
if (["n", "w", "nw"].includes(transformHandleDirection)) {
|
if (["n", "w", "nw"].includes(transformHandleDirection)) {
|
||||||
@@ -569,9 +587,16 @@ export const resizeSingleElement = (
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let minWidth = 0;
|
||||||
|
if (boundTextElementId) {
|
||||||
|
const boundTextElement = Scene.getScene(element)!.getElement(
|
||||||
|
boundTextElementId,
|
||||||
|
) as ExcalidrawTextElement;
|
||||||
|
minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
resizedElement.width !== 0 &&
|
resizedElement.width > minWidth &&
|
||||||
resizedElement.height !== 0 &&
|
resizedElement.height !== 0 &&
|
||||||
Number.isFinite(resizedElement.x) &&
|
Number.isFinite(resizedElement.x) &&
|
||||||
Number.isFinite(resizedElement.y)
|
Number.isFinite(resizedElement.y)
|
||||||
@@ -580,6 +605,7 @@ export const resizeSingleElement = (
|
|||||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||||
});
|
});
|
||||||
mutateElement(element, resizedElement);
|
mutateElement(element, resizedElement);
|
||||||
|
handleBindTextResize([element], transformHandleDirection);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -651,7 +677,7 @@ const resizeMultipleElements = (
|
|||||||
const width = element.width * scale;
|
const width = element.width * scale;
|
||||||
const height = element.height * scale;
|
const height = element.height * scale;
|
||||||
let font: { fontSize?: number; baseline?: number } = {};
|
let font: { fontSize?: number; baseline?: number } = {};
|
||||||
if (element.type === "text") {
|
if (isTextElement(element)) {
|
||||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
const nextFont = measureFontSizeFromWH(element, width, height);
|
||||||
if (nextFont === null) {
|
if (nextFont === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -732,6 +758,16 @@ const rotateMultipleElements = (
|
|||||||
y: element.y + (rotatedCY - cy),
|
y: element.y + (rotatedCY - cy),
|
||||||
angle: normalizeAngle(centerAngle + origAngle),
|
angle: normalizeAngle(centerAngle + origAngle),
|
||||||
});
|
});
|
||||||
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
if (boundTextElementId) {
|
||||||
|
const textElement =
|
||||||
|
Scene.getScene(element)!.getElement(boundTextElementId)!;
|
||||||
|
mutateElement(textElement, {
|
||||||
|
x: textElement.x + (rotatedCX - cx),
|
||||||
|
y: textElement.y + (rotatedCY - cy),
|
||||||
|
angle: normalizeAngle(centerAngle + origAngle),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ export const resizeTest = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { rotation: rotationTransformHandle, ...transformHandles } =
|
||||||
rotation: rotationTransformHandle,
|
getTransformHandles(element, zoom, pointerType);
|
||||||
...transformHandles
|
|
||||||
} = getTransformHandles(element, zoom, pointerType);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rotationTransformHandle &&
|
rotationTransformHandle &&
|
||||||
@@ -49,9 +47,8 @@ export const resizeTest = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filter = Object.keys(transformHandles).filter((key) => {
|
const filter = Object.keys(transformHandles).filter((key) => {
|
||||||
const transformHandle = transformHandles[
|
const transformHandle =
|
||||||
key as Exclude<TransformHandleType, "rotation">
|
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
|
||||||
]!;
|
|
||||||
if (!transformHandle) {
|
if (!transformHandle) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -105,9 +102,8 @@ export const getTransformHandleTypeFromCoords = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const found = Object.keys(transformHandles).find((key) => {
|
const found = Object.keys(transformHandles).find((key) => {
|
||||||
const transformHandle = transformHandles[
|
const transformHandle =
|
||||||
key as Exclude<TransformHandleType, "rotation">
|
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
|
||||||
]!;
|
|
||||||
return (
|
return (
|
||||||
transformHandle &&
|
transformHandle &&
|
||||||
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
||||||
|
|||||||
+380
-3
@@ -1,12 +1,389 @@
|
|||||||
import { measureText, getFontString } from "../utils";
|
import { getFontString, arrayToMap } from "../utils";
|
||||||
import { ExcalidrawTextElement } from "./types";
|
import {
|
||||||
|
ExcalidrawBindableElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
FontString,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "./types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
import { PADDING } from "../constants";
|
||||||
|
import { MaybeTransformHandleType } from "./transformHandles";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
|
||||||
export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
|
export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
|
||||||
const metrics = measureText(element.text, getFontString(element));
|
let maxWidth;
|
||||||
|
if (element.containerId) {
|
||||||
|
maxWidth = element.width;
|
||||||
|
}
|
||||||
|
const metrics = measureText(
|
||||||
|
element.originalText,
|
||||||
|
getFontString(element),
|
||||||
|
maxWidth,
|
||||||
|
);
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const bindTextToShapeAfterDuplication = (
|
||||||
|
sceneElements: ExcalidrawElement[],
|
||||||
|
oldElements: ExcalidrawElement[],
|
||||||
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
|
): void => {
|
||||||
|
const sceneElementMap = arrayToMap(sceneElements) as Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
ExcalidrawElement
|
||||||
|
>;
|
||||||
|
oldElements.forEach((element) => {
|
||||||
|
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
|
||||||
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
|
if (boundTextElementId) {
|
||||||
|
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId)!;
|
||||||
|
mutateElement(
|
||||||
|
sceneElementMap.get(newElementId) as ExcalidrawBindableElement,
|
||||||
|
{
|
||||||
|
boundElements: element.boundElements?.concat({
|
||||||
|
type: "text",
|
||||||
|
id: newTextElementId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mutateElement(
|
||||||
|
sceneElementMap.get(newTextElementId) as ExcalidrawTextElement,
|
||||||
|
{
|
||||||
|
containerId: newElementId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleBindTextResize = (
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
transformHandleType: MaybeTransformHandleType,
|
||||||
|
) => {
|
||||||
|
elements.forEach((element) => {
|
||||||
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
if (boundTextElementId) {
|
||||||
|
const textElement = Scene.getScene(element)!.getElement(
|
||||||
|
boundTextElementId,
|
||||||
|
) as ExcalidrawTextElement;
|
||||||
|
if (textElement && textElement.text) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = textElement.text;
|
||||||
|
let nextHeight = textElement.height;
|
||||||
|
let containerHeight = element.height;
|
||||||
|
let nextBaseLine = textElement.baseline;
|
||||||
|
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||||
|
let minCharWidthTillNow = 0;
|
||||||
|
if (text) {
|
||||||
|
minCharWidthTillNow = getMinCharWidth(getFontString(textElement));
|
||||||
|
// check if the diff has exceeded min char width needed
|
||||||
|
const diff = Math.abs(
|
||||||
|
element.width - textElement.width + PADDING * 2,
|
||||||
|
);
|
||||||
|
if (diff >= minCharWidthTillNow) {
|
||||||
|
text = wrapText(
|
||||||
|
textElement.originalText,
|
||||||
|
getFontString(textElement),
|
||||||
|
element.width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensions = measureText(
|
||||||
|
text,
|
||||||
|
getFontString(textElement),
|
||||||
|
element.width,
|
||||||
|
);
|
||||||
|
nextHeight = dimensions.height;
|
||||||
|
nextBaseLine = dimensions.baseline;
|
||||||
|
}
|
||||||
|
// increase height in case text element height exceeds
|
||||||
|
if (nextHeight > element.height - PADDING * 2) {
|
||||||
|
containerHeight = nextHeight + PADDING * 2;
|
||||||
|
const diff = containerHeight - element.height;
|
||||||
|
// fix the y coord when resizing from ne/nw/n
|
||||||
|
const updatedY =
|
||||||
|
transformHandleType === "ne" ||
|
||||||
|
transformHandleType === "nw" ||
|
||||||
|
transformHandleType === "n"
|
||||||
|
? element.y - diff
|
||||||
|
: element.y;
|
||||||
|
mutateElement(element, {
|
||||||
|
height: containerHeight,
|
||||||
|
y: updatedY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedY = element.y + containerHeight / 2 - nextHeight / 2;
|
||||||
|
mutateElement(textElement, {
|
||||||
|
text,
|
||||||
|
// preserve padding and set width correctly
|
||||||
|
width: element.width - PADDING * 2,
|
||||||
|
height: nextHeight,
|
||||||
|
x: element.x + PADDING,
|
||||||
|
y: updatedY,
|
||||||
|
baseline: nextBaseLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||||
|
export const measureText = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
maxWidth?: number | null,
|
||||||
|
) => {
|
||||||
|
text = text
|
||||||
|
.split("\n")
|
||||||
|
// replace empty lines with single space because leading/trailing empty
|
||||||
|
// lines would be stripped from computation
|
||||||
|
.map((x) => x || " ")
|
||||||
|
.join("\n");
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.style.position = "absolute";
|
||||||
|
container.style.whiteSpace = "pre";
|
||||||
|
container.style.font = font;
|
||||||
|
container.style.minHeight = "1em";
|
||||||
|
|
||||||
|
if (maxWidth) {
|
||||||
|
const lineHeight = getApproxLineHeight(font);
|
||||||
|
container.style.width = `${String(maxWidth)}px`;
|
||||||
|
container.style.maxWidth = `${String(maxWidth)}px`;
|
||||||
|
container.style.overflow = "hidden";
|
||||||
|
container.style.wordBreak = "break-word";
|
||||||
|
container.style.lineHeight = `${String(lineHeight)}px`;
|
||||||
|
container.style.whiteSpace = "pre-wrap";
|
||||||
|
}
|
||||||
|
document.body.appendChild(container);
|
||||||
|
container.innerText = text;
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.style.display = "inline-block";
|
||||||
|
span.style.overflow = "hidden";
|
||||||
|
span.style.width = "1px";
|
||||||
|
span.style.height = "1px";
|
||||||
|
container.appendChild(span);
|
||||||
|
// Baseline is important for positioning text on canvas
|
||||||
|
const baseline = span.offsetTop + span.offsetHeight;
|
||||||
|
const width = container.offsetWidth;
|
||||||
|
|
||||||
|
const height = container.offsetHeight;
|
||||||
|
document.body.removeChild(container);
|
||||||
|
|
||||||
|
return { width, height, baseline };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||||
|
export const getApproxLineHeight = (font: FontString) => {
|
||||||
|
return measureText(DUMMY_TEXT, font, null).height;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
|
const getTextWidth = (text: string, font: FontString) => {
|
||||||
|
if (!canvas) {
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
}
|
||||||
|
const canvas2dContext = canvas.getContext("2d")!;
|
||||||
|
canvas2dContext.font = font;
|
||||||
|
|
||||||
|
const metrics = canvas2dContext.measureText(text);
|
||||||
|
|
||||||
|
return metrics.width;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const wrapText = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
containerWidth: number,
|
||||||
|
) => {
|
||||||
|
const maxWidth = containerWidth - PADDING * 2;
|
||||||
|
|
||||||
|
const lines: Array<string> = [];
|
||||||
|
const originalLines = text.split("\n");
|
||||||
|
const spaceWidth = getTextWidth(" ", font);
|
||||||
|
originalLines.forEach((originalLine) => {
|
||||||
|
const words = originalLine.split(" ");
|
||||||
|
// This means its newline so push it
|
||||||
|
if (words.length === 1 && words[0] === "") {
|
||||||
|
lines.push(words[0]);
|
||||||
|
} else {
|
||||||
|
let currentLine = "";
|
||||||
|
let currentLineWidthTillNow = 0;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
while (index < words.length) {
|
||||||
|
const currentWordWidth = getTextWidth(words[index], font);
|
||||||
|
|
||||||
|
// Start breaking longer words exceeding max width
|
||||||
|
if (currentWordWidth > maxWidth) {
|
||||||
|
// push current line since the current word exceeds the max width
|
||||||
|
// so will be appended in next line
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
currentLine = "";
|
||||||
|
currentLineWidthTillNow = 0;
|
||||||
|
while (words[index].length > 0) {
|
||||||
|
const currentChar = words[index][0];
|
||||||
|
const width = charWidth.calculate(currentChar, font);
|
||||||
|
currentLineWidthTillNow += width;
|
||||||
|
words[index] = words[index].slice(1);
|
||||||
|
|
||||||
|
if (currentLineWidthTillNow >= maxWidth) {
|
||||||
|
// only remove last trailing space which we have added when joining words
|
||||||
|
if (currentLine.slice(-1) === " ") {
|
||||||
|
currentLine = currentLine.slice(0, -1);
|
||||||
|
}
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = currentChar;
|
||||||
|
currentLineWidthTillNow = width;
|
||||||
|
if (currentLineWidthTillNow === maxWidth) {
|
||||||
|
currentLine = "";
|
||||||
|
currentLineWidthTillNow = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentLine += currentChar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// push current line if appending space exceeds max width
|
||||||
|
if (currentLineWidthTillNow + spaceWidth > maxWidth) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = "";
|
||||||
|
currentLineWidthTillNow = 0;
|
||||||
|
} else {
|
||||||
|
// space needs to be appended before next word
|
||||||
|
// as currentLine contains chars which couldn't be appended
|
||||||
|
// to previous line
|
||||||
|
currentLine += " ";
|
||||||
|
currentLineWidthTillNow += spaceWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
// Start appending words in a line till max width reached
|
||||||
|
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||||
|
const word = words[index];
|
||||||
|
currentLineWidthTillNow = getTextWidth(currentLine + word, font);
|
||||||
|
|
||||||
|
if (currentLineWidthTillNow >= maxWidth) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLineWidthTillNow = 0;
|
||||||
|
currentLine = "";
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
currentLine += `${word} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLineWidthTillNow === maxWidth) {
|
||||||
|
currentLine = "";
|
||||||
|
currentLineWidthTillNow = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLine) {
|
||||||
|
// only remove last trailing space which we have added when joining words
|
||||||
|
if (currentLine.slice(-1) === " ") {
|
||||||
|
currentLine = currentLine.slice(0, -1);
|
||||||
|
}
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lines.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const charWidth = (() => {
|
||||||
|
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||||
|
|
||||||
|
const calculate = (char: string, font: FontString) => {
|
||||||
|
const ascii = char.charCodeAt(0);
|
||||||
|
if (!cachedCharWidth[font]) {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
}
|
||||||
|
if (!cachedCharWidth[font][ascii]) {
|
||||||
|
const width = getTextWidth(char, font);
|
||||||
|
cachedCharWidth[font][ascii] = width;
|
||||||
|
}
|
||||||
|
return cachedCharWidth[font][ascii];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCache = (char: string, font: FontString) => {
|
||||||
|
const ascii = char.charCodeAt(0);
|
||||||
|
|
||||||
|
if (!cachedCharWidth[font][ascii]) {
|
||||||
|
cachedCharWidth[font][ascii] = calculate(char, font);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCacheforFont = (font: FontString) => {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCache = (font: FontString) => {
|
||||||
|
return cachedCharWidth[font];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
calculate,
|
||||||
|
updateCache,
|
||||||
|
clearCacheforFont,
|
||||||
|
getCache,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
export const getApproxMinLineWidth = (font: FontString) => {
|
||||||
|
return measureText(DUMMY_TEXT.split("").join("\n"), font).width + PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApproxMinLineHeight = (font: FontString) => {
|
||||||
|
return getApproxLineHeight(font) + PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
|
||||||
|
return Math.min(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||||
|
// Generally lower case is used so converting to lower case
|
||||||
|
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||||
|
const batchLength = 6;
|
||||||
|
let index = 0;
|
||||||
|
let widthTillNow = 0;
|
||||||
|
let str = "";
|
||||||
|
while (widthTillNow <= width) {
|
||||||
|
const batch = dummyText.substr(index, index + batchLength);
|
||||||
|
str += batch;
|
||||||
|
widthTillNow += getTextWidth(str, font);
|
||||||
|
if (index === dummyText.length - 1) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
index = index + batchLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (widthTillNow > width) {
|
||||||
|
str = str.substr(0, str.length - 1);
|
||||||
|
widthTillNow = getTextWidth(str, font);
|
||||||
|
}
|
||||||
|
return str.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||||
|
return container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id;
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user