Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle 8946b2637f update changelog & readme 2021-10-23 13:53:10 +02:00
dwelle a834a4fda0 feat: expose app instance on excalidrawAPI 2021-10-23 13:41:14 +02:00
229 changed files with 6501 additions and 21359 deletions
+5
View File
@@ -0,0 +1,5 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
-8
View File
@@ -1,8 +0,0 @@
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
-10
View File
@@ -1,11 +1 @@
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
+1 -2
View File
@@ -1,7 +1,6 @@
{ {
"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"
} }
} }
-3
View File
@@ -10,7 +10,6 @@ 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/
@@ -22,7 +21,6 @@ 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/
@@ -34,4 +32,3 @@ updates:
- ad1992 - ad1992
assignees: assignees:
- ad1992 - ad1992
open-pull-requests-limit: 20
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: docker/build-push-action@v2 - uses: docker/build-push-action@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
- name: Setup Node.js 14.x - name: Setup Node.js 14.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 16.x node-version: 14.x
- name: Install and test - name: Install and test
run: | run: |
yarn --frozen-lockfile yarn --frozen-lockfile
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
yarn lint-staged
-4
View File
@@ -118,10 +118,6 @@ yarn start
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor. Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Collaboration
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
#### Commands #### Commands
| Command | Description | | Command | Description |
+28 -30
View File
@@ -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.16.1", "@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "12.1.2", "@testing-library/react": "11.2.6",
"@tldraw/vec": "1.4.3", "@tldraw/vec": "0.0.106",
"@types/jest": "27.4.0", "@types/jest": "26.0.22",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "17.0.38", "@types/react": "17.0.3",
"@types/react-dom": "17.0.11", "@types/react-dom": "17.0.3",
"@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.7", "fake-indexeddb": "3.1.3",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.2", "i18next-browser-languagedetector": "6.1.0",
"idb-keyval": "6.0.3", "idb-keyval": "5.1.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.30", "nanoid": "3.1.22",
"open-color": "1.9.1", "open-color": "1.8.0",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.16", "perfect-freehand": "1.0.15",
"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,40 +49,39 @@
"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.5.2", "roughjs": "4.4.1",
"sass": "1.47.0", "sass": "1.32.10",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.5.4" "typescript": "4.2.4"
}, },
"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.3.0",
"@types/lodash.throttle": "4.1.6", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3", "@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.6", "@types/resize-observer-browser": "0.1.5",
"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.23.0", "firebase-tools": "9.9.0",
"husky": "7.0.4", "husky": "4.3.8",
"jest-canvas-mock": "2.3.1", "jest-canvas-mock": "2.3.1",
"lint-staged": "12.1.7", "lint-staged": "10.5.4",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.5.1", "prettier": "2.2.1",
"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|browser-fs-access)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
], ],
"resetMocks": false "resetMocks": false
}, },
@@ -101,7 +100,6 @@
"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",
+12
View File
@@ -13,6 +13,18 @@
<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"
+1
View File
@@ -26,6 +26,7 @@
} }
} }
], ],
"capture_links": "new-client",
"share_target": { "share_target": {
"action": "/web-share-target", "action": "/web-share-target",
"method": "POST", "method": "POST",
+2 -2
View File
@@ -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 (error) { } catch (e) {
console.error(error); console.error(e);
} }
}; };
+9 -12
View File
@@ -1,16 +1,11 @@
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 = {}, result = {}, extraKey = "") => { const flatten = (object) =>
for (const key in object) { Object.keys(object).reduce(
if (typeof object[key] !== "object") { (initial, current) => ({ ...initial, ...object[current] }),
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",
@@ -24,8 +19,10 @@ 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);
percentages[currentLocale.replace(".json", "")] = percentage; const percentage = (100 * translatedKeys.length) / allKeys.length;
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
} }
writeFileSync( writeFileSync(
+6 -21
View File
@@ -5,9 +5,7 @@ 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",
@@ -33,14 +31,11 @@ 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",
@@ -50,10 +45,7 @@ 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": "🇪🇸",
@@ -67,9 +59,7 @@ 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": "🇳🇱",
@@ -81,24 +71,21 @@ 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",
@@ -112,9 +99,7 @@ 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",
@@ -126,15 +111,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
View File
@@ -25,8 +25,8 @@ const release = async (nextVersion) => {
); );
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log("Done!"); console.log("Done!");
} catch (error) { } catch (e) {
console.error(error); console.error(e);
process.exit(1); process.exit(1);
} }
}; };
+2 -2
View File
@@ -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 (error) { } catch (e) {
console.error(error); console.error(e);
} }
}; };
+7 -43
View File
@@ -2,8 +2,6 @@ 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",
@@ -11,49 +9,15 @@ 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!",
},
};
}
return app.library app.library.loadLibrary().then((items) => {
.loadLibrary() app.library.saveLibrary([
.then((items) => { ...items,
return app.library.saveLibrary([ selectedElements.map(deepCopyElement),
{ ]);
id: randomId(), });
status: "unpublished", return false;
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",
}); });
+4 -6
View File
@@ -8,13 +8,13 @@ import {
CenterVerticallyIcon, CenterVerticallyIcon,
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getElementMap, 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 { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
@@ -34,11 +34,9 @@ const alignSelectedElements = (
const updatedElements = alignElements(selectedElements, alignment); const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
export const actionAlignTop = register({ export const actionAlignTop = register({
+1 -1
View File
@@ -160,7 +160,7 @@ export const actionResetZoom = register({
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> <Tooltip label={t("buttons.resetZoom")}>
<ToolButton <ToolButton
type="button" type="button"
className="reset-zoom-button" className="reset-zoom-button"
+2 -4
View File
@@ -42,7 +42,6 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -57,7 +56,7 @@ export const actionCopyAsSvg = register({
return { return {
commitToHistory: false, commitToHistory: false,
}; };
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
@@ -82,7 +81,6 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -108,7 +106,7 @@ export const actionCopyAsPng = register({
}, },
commitToHistory: false, commitToHistory: false,
}; };
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
+16 -24
View File
@@ -11,7 +11,6 @@ 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[],
@@ -22,12 +21,6 @@ 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: {
@@ -62,7 +55,7 @@ export const actionDeleteSelected = register({
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
elementId, elementId,
selectedPointsIndices, activePointIndex,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
} = appState.editingLinearElement; } = appState.editingLinearElement;
@@ -72,7 +65,8 @@ export const actionDeleteSelected = register({
} }
if ( if (
// case: no point selected → delete whole element // case: no point selected → delete whole element
selectedPointsIndices == null || activePointIndex == null ||
activePointIndex === -1 ||
// case: deleting last remaining point // case: deleting last remaining point
element.points.length < 2 element.points.length < 2
) { ) {
@@ -92,17 +86,15 @@ 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: selectedPointsIndices?.includes(0) startBindingElement:
? null activePointIndex === 0 ? null : startBindingElement,
: startBindingElement, endBindingElement:
endBindingElement: selectedPointsIndices?.includes( activePointIndex === element.points.length - 1
element.points.length - 1, ? null
) : endBindingElement,
? null
: endBindingElement,
}; };
LinearElementEditor.deletePoints(element, selectedPointsIndices); LinearElementEditor.movePoint(element, activePointIndex, "delete");
return { return {
elements, elements,
@@ -111,17 +103,17 @@ export const actionDeleteSelected = register({
editingLinearElement: { editingLinearElement: {
...appState.editingLinearElement, ...appState.editingLinearElement,
...binding, ...binding,
selectedPointsIndices: activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]
: [0],
}, },
}, },
commitToHistory: true, commitToHistory: true,
}; };
} }
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState); let {
elements: nextElements,
appState: nextAppState,
} = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),
+4 -6
View File
@@ -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 { getNonDeletedElements } from "../element"; import { getElementMap, 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 { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
@@ -30,11 +30,9 @@ const distributeSelectedElements = (
const updatedElements = distributeElements(selectedElements, distribution); const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
export const distributeHorizontally = register({ export const distributeHorizontally = register({
+32 -22
View File
@@ -2,12 +2,13 @@ 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 { getSelectedElements, isSomeElementSelected } from "../scene"; import { 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 { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import { import {
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
getSelectedGroupForElement, getSelectedGroupForElement,
@@ -17,23 +18,41 @@ 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 selected point(s) if editing a line // duplicate point if selected while editing multi-point element
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(appState); const { activePointIndex, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!ret) { if (!element || activePointIndex === null) {
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,
}; };
} }
@@ -87,12 +106,9 @@ 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 (selectedElementIds.get(element.id)) { if (appState.selectedElementIds[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
@@ -114,11 +130,7 @@ const duplicateElements = (
} }
index++; index++;
} }
bindTextToShapeAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId); fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return { return {
@@ -128,9 +140,7 @@ const duplicateElements = (
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: newElements.reduce((acc, element) => { selectedElementIds: newElements.reduce((acc, element) => {
if (!isBoundToContainer(element)) { acc[element.id] = true;
acc[element.id] = true;
}
return acc; return acc;
}, {} as any), }, {} as any),
}, },
+3 -8
View File
@@ -151,11 +151,9 @@ export const actionSaveToActiveFile = register({
: null, : null,
}, },
}; };
} catch (error: any) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -183,11 +181,9 @@ export const actionSaveFileToDisk = register({
app.files, app.files,
); );
return { commitToHistory: false, appState: { ...appState, fileHandle } }; return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error: any) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -223,9 +219,8 @@ export const actionLoadScene = register({
files, files,
commitToHistory: true, commitToHistory: true,
}; };
} catch (error: any) { } catch (error) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
console.warn(error);
return false; return false;
} }
return { return {
+5 -2
View File
@@ -19,8 +19,11 @@ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => { perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const {
appState.editingLinearElement; elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (element) { if (element) {
+7 -9
View File
@@ -1,6 +1,6 @@
import { register } from "./register"; import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getElementMap, 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,7 +9,6 @@ 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[],
@@ -84,11 +83,9 @@ const flipSelectedElements = (
flipDirection, flipDirection,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
const flipElements = ( const flipElements = (
@@ -145,9 +142,10 @@ const flipElement = (
} }
if (isLinearElement(element)) { if (isLinearElement(element)) {
for (let index = 1; index < element.points.length; index++) { for (let i = 1; i < element.points.length; i++) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoint(element, i, [
{ index, point: [-element.points[index][0], element.points[index][1]] }, -element.points[i][0],
element.points[i][1],
]); ]);
} }
LinearElementEditor.normalizePoints(element); LinearElementEditor.normalizePoints(element);
+10 -26
View File
@@ -1,6 +1,6 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { 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";
@@ -17,9 +17,8 @@ import {
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { randomId } from "../random"; import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@@ -45,7 +44,6 @@ 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)
@@ -58,7 +56,6 @@ 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
@@ -86,9 +83,8 @@ 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 (!selectElementIds.get(element.id)) { if (!appState.selectedElementIds[element.id]) {
return element; return element;
} }
return newElementWith(element, { return newElementWith(element, {
@@ -103,8 +99,9 @@ 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 = const lastGroupElementIndex = updatedElements.lastIndexOf(
updatedElements.lastIndexOf(lastElementInGroup); 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)
@@ -152,12 +149,7 @@ export const actionUngroup = register({
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => { const nextElements = elements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
const nextGroupIds = removeFromSelectedGroups( const nextGroupIds = removeFromSelectedGroups(
element.groupIds, element.groupIds,
appState.selectedGroupIds, appState.selectedGroupIds,
@@ -169,19 +161,11 @@ export const actionUngroup = register({
groupIds: nextGroupIds, groupIds: nextGroupIds,
}); });
}); });
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
);
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return { return {
appState: updateAppState, appState: selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
elements: nextElements, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };
+5 -5
View File
@@ -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 = arrayToMap(prevElements); const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements; const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements); const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter( const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id), (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
); );
const elements = nextElements const elements = nextElements
.map((nextElement) => .map((nextElement) =>
newElementWith( newElementWith(
prevElementMap.get(nextElement.id) || nextElement, prevElementMap[nextElement.id] || nextElement,
nextElement, nextElement,
), ),
) )
+51 -235
View File
@@ -6,7 +6,6 @@ import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadDotIcon,
ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
EdgeRoundIcon, EdgeRoundIcon,
EdgeSharpIcon, EdgeSharpIcon,
@@ -41,16 +40,8 @@ import {
isTextElement, isTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { import { isLinearElement, isLinearElementType } from "../element/typeChecks";
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
isBoundToContainer,
isLinearElement,
isLinearElementType,
} from "../element/typeChecks";
import { import {
Arrowhead, Arrowhead,
ExcalidrawElement, ExcalidrawElement,
@@ -60,34 +51,25 @@ import {
TextAlign, TextAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements,
getTargetElements, getTargetElements,
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
callback: (element: ExcalidrawElement) => ExcalidrawElement, callback: (element: ExcalidrawElement) => ExcalidrawElement,
includeBoundText = false,
) => { ) => {
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText),
);
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || appState.selectedElementIds[element.id] ||
element.id === appState.editingElement?.id element.id === appState.editingElement?.id
) { ) {
return callback(element); return callback(element);
@@ -117,96 +99,18 @@ const getFormValue = function <T>(
); );
}; };
const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
) => {
if (isBoundToContainer(nextElement)) {
return nextElement;
}
return mutateElement(
nextElement,
{
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
};
const changeFontSize = (
elements: readonly ExcalidrawElement[],
appState: AppState,
getNewFontSize: (element: ExcalidrawTextElement) => number,
) => {
const newFontSizes = new Set<number>();
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
// update state only if we've set all select text elements to
// the same font size
currentItemFontSize:
newFontSizes.size === 1
? [...newFontSizes][0]
: appState.currentItemFontSize,
},
commitToHistory: true,
};
};
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({ export const actionChangeStrokeColor = register({
name: "changeStrokeColor", name: "changeStrokeColor",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, return hasStrokeColor(el.type)
appState, ? newElementWith(el, {
(el) => { strokeColor: value.currentItemStrokeColor,
return hasStrokeColor(el.type) })
? newElementWith(el, { : el;
strokeColor: value.currentItemStrokeColor, }),
})
: el;
},
true,
),
}), }),
appState: { appState: {
...appState, ...appState,
@@ -520,7 +424,24 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value); return {
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontSize: value,
});
redrawTextBoundingBox(element);
return element;
}
return el;
}),
appState: {
...appState,
currentItemFontSize: value,
},
commitToHistory: true,
};
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
@@ -552,16 +473,7 @@ export const actionChangeFontSize = register({
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.fontSize,
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
appState.currentItemFontSize || DEFAULT_FONT_SIZE, appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -570,71 +482,21 @@ export const actionChangeFontSize = register({
), ),
}); });
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
// get previous value before relative increase (doesn't work fully
// due to rounding and float precision issues)
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.COMMA needed for MacOS
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
);
},
});
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.PERIOD needed for MacOS
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
);
},
});
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, if (isTextElement(el)) {
appState, const element: ExcalidrawTextElement = newElementWith(el, {
(oldElement) => { fontFamily: value,
if (isTextElement(oldElement)) { });
const newElement: ExcalidrawTextElement = newElementWith( redrawTextBoundingBox(element);
oldElement, return element;
{ }
fontFamily: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemFontFamily: value, currentItemFontFamily: value,
@@ -674,16 +536,7 @@ export const actionChangeFontFamily = register({
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.fontFamily,
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -697,29 +550,17 @@ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, if (isTextElement(el)) {
appState, const element: ExcalidrawTextElement = newElementWith(el, {
(oldElement) => { textAlign: value,
if (isTextElement(oldElement)) { });
const newElement: ExcalidrawTextElement = newElementWith( redrawTextBoundingBox(element);
oldElement, return element;
{ }
textAlign: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemTextAlign: value, currentItemTextAlign: value,
@@ -752,16 +593,7 @@ export const actionChangeTextAlign = register({
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.textAlign,
if (isTextElement(element)) {
return element.textAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.textAlign;
}
return null;
},
appState.currentItemTextAlign, appState.currentItemTextAlign,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -906,14 +738,6 @@ 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,
@@ -956,14 +780,6 @@ 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,
+2 -5
View File
@@ -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, isTextElement } from "../element"; import { getNonDeletedElements } from "../element";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@@ -15,10 +15,7 @@ export const actionSelectAll = register({
...appState, ...appState,
editingGroupId: null, editingGroupId: null,
selectedElementIds: elements.reduce((map, element) => { selectedElementIds: elements.reduce((map, element) => {
if ( if (!element.isDeleted) {
!element.isDeleted &&
!(isTextElement(element) && element.containerId)
) {
map[element.id] = true; map[element.id] = true;
} }
return map; return map;
+2 -8
View File
@@ -12,7 +12,6 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getContainerElement } from "../element/textElement";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@@ -56,18 +55,13 @@ export const actionPasteStyles = register({
opacity: pastedElement?.opacity, opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness, roughness: pastedElement?.roughness,
}); });
if (isTextElement(newElement) && isTextElement(element)) { if (isTextElement(newElement)) {
mutateElement(newElement, { mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE, fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY, fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
redrawTextBoundingBox(newElement);
redrawTextBoundingBox(
element,
getContainerElement(element),
appState,
);
} }
return newElement; return newElement;
} }
+1 -3
View File
@@ -101,9 +101,7 @@ export type ActionName =
| "flipVertical" | "flipVertical"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme";
| "increaseFontSize"
| "decreaseFontSize";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+35 -2
View File
@@ -1,7 +1,13 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds"; import { getCommonBounds } from "./element";
import { getMaximumGroups } from "./groups";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -31,6 +37,28 @@ export const alignElements = (
}); });
}; };
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const calculateTranslation = ( const calculateTranslation = (
group: ExcalidrawElement[], group: ExcalidrawElement[],
selectionBoundingBox: Box, selectionBoundingBox: Box,
@@ -60,3 +88,8 @@ 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 };
};
+5 -4
View File
@@ -96,9 +96,10 @@ 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)({ config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => 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 },
@@ -171,7 +172,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,
+5 -6
View File
@@ -58,8 +58,7 @@ export const copyToClipboard = async (
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
// select binded text elements when copying const selectedElements = getSelectedElements(elements, appState);
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,
@@ -75,7 +74,7 @@ export const copyToClipboard = async (
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
} catch (error: any) { } catch (error) {
PREFER_APP_CLIPBOARD = true; PREFER_APP_CLIPBOARD = true;
console.error(error); console.error(error);
} }
@@ -88,7 +87,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
try { try {
return JSON.parse(CLIPBOARD); return JSON.parse(CLIPBOARD);
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return {}; return {};
} }
@@ -180,7 +179,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: any) { } catch (error) {
console.error(error); console.error(error);
} }
} }
@@ -220,7 +219,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: any) { } catch (error) {
console.error(error); console.error(error);
} }
+179 -451
View File
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -3,22 +3,15 @@ import OpenColor from "open-color";
import "./Card.scss"; import "./Card.scss";
export const Card: React.FC<{ export const Card: React.FC<{
color: keyof OpenColor | "primary"; color: keyof OpenColor;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
className="Card" className="Card"
style={{ style={{
["--card-color" as any]: ["--card-color" as any]: OpenColor[color][7],
color === "primary" ? "var(--color-primary)" : OpenColor[color][7], ["--card-color-darker" as any]: OpenColor[color][8],
["--card-color-darker" as any]: ["--card-color-darkest" as any]: OpenColor[color][9],
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}
+7 -10
View File
@@ -6,19 +6,16 @@ import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{ export const CheckboxItem: React.FC<{
checked: boolean; checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void; onChange: (checked: boolean) => void;
className?: string; }> = ({ children, checked, onChange }) => {
}> = ({ children, checked, onChange, className }) => {
return ( return (
<div <div
className={clsx("Checkbox", className, { "is-checked": checked })} className={clsx("Checkbox", { "is-checked": checked })}
onClick={(event) => { onClick={(event) => {
onChange(!checked, event); onChange(!checked);
( ((event.currentTarget as HTMLDivElement).querySelector(
(event.currentTarget as HTMLDivElement).querySelector( ".Checkbox-box",
".Checkbox-box", ) as HTMLButtonElement).focus();
) as HTMLButtonElement
).focus();
}} }}
> >
<button className="Checkbox-box" role="checkbox" aria-checked={checked}> <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
@@ -1,21 +1,22 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.confirm-dialog { .clear-canvas {
&-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_type_button { .ToolIcon__icon {
margin-left: 0.8rem; min-width: 2.5rem;
padding: 0 0.5rem; width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1.5rem;
padding: 0 0.5rem;
}
} }
&__content { &__content {
@@ -33,5 +34,9 @@
color: $oc-white; color: $oc-white;
} }
} }
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-2;
}
} }
} }
+33 -9
View File
@@ -1,10 +1,11 @@
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 ConfirmDialog from "./ConfirmDialog"; import "./ClearCanvas.scss";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@@ -25,16 +26,39 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
/> />
{showDialog && ( {showDialog && (
<ConfirmDialog <Dialog
onConfirm={() => { onCloseRequest={toggleDialog}
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> <>
</ConfirmDialog> <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
<div className="clear-canvas-buttons">
<ToolButton
type="button"
title={t("buttons.clear")}
aria-label={t("buttons.clear")}
label={t("buttons.clear")}
onClick={() => {
onConfirm();
toggleDialog();
}}
data-testid="confirm-clear-canvas-button"
className="clear-canvas--confirm"
/>
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={toggleDialog}
data-testid="cancel-clear-canvas-button"
className="clear-canvas--cancel"
/>
</div>
</>
</Dialog>
)} )}
</> </>
); );
-52
View File
@@ -1,52 +0,0 @@
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;
+2 -6
View File
@@ -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 interface DialogProps { export const Dialog = (props: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
small?: boolean; small?: boolean;
@@ -18,10 +18,7 @@ export interface DialogProps {
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();
@@ -84,7 +81,6 @@ export const Dialog = (props: DialogProps) => {
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">
+1 -21
View File
@@ -154,7 +154,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut <Shortcut
label={t("toolBar.freedraw")} label={t("toolBar.freedraw")}
shortcuts={["Shift + P", "X", "7"]} shortcuts={["Shift+P", "7"]}
/> />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
@@ -260,18 +260,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.multiSelect")} label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]} shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/> />
<Shortcut
label={t("helpDialog.deepSelect")}
shortcuts={[
getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
]}
/>
<Shortcut
label={t("helpDialog.deepBoxSelect")}
shortcuts={[
getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
]}
/>
<Shortcut <Shortcut
label={t("labels.moveCanvas")} label={t("labels.moveCanvas")}
shortcuts={[ shortcuts={[
@@ -394,14 +382,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("G")]}
/> />
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
</Columns> </Columns>
+12 -39
View File
@@ -7,21 +7,18 @@ 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 HintViewerProps { interface Hint {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
} }
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { const getHints = ({ appState, elements }: Hint) => {
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");
@@ -42,7 +39,6 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
} }
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if ( if (
isResizing && isResizing &&
lastPointerDownWith === "mouse" && lastPointerDownWith === "mouse" &&
@@ -61,6 +57,15 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.rotate"); return t("hints.rotate");
} }
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.activePointIndex
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected"); return t("hints.text_selected");
} }
@@ -69,45 +74,13 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (elementType === "selection") {
if (
appState.draggingElement?.type === "selection" &&
!appState.editingElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
}
if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
}
}
if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (isTextBindableContainer(selectedElements[0])) {
return t("hints.bindTextToElement");
}
}
return null; return null;
}; };
export const HintViewer = ({ export const HintViewer = ({ appState, elements }: Hint) => {
appState,
elements,
isMobile,
}: HintViewerProps) => {
let hint = getHints({ let hint = getHints({
appState, appState,
elements, elements,
isMobile,
}); });
if (!hint) { if (!hint) {
return null; return null;
+2 -2
View File
@@ -22,7 +22,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:focus-visible { &:focus {
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-template-columns: repeat(3, auto); grid-auto-flow: column;
grid-gap: 0.5rem; grid-gap: 0.5rem;
border-radius: 4px; border-radius: 4px;
:root[dir="rtl"] & { :root[dir="rtl"] & {
+1 -1
View File
@@ -102,7 +102,7 @@ const ImageExportModal = ({
const { exportBackground, viewBackgroundColor } = appState; const { exportBackground, viewBackgroundColor } = appState;
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState)
: elements; : elements;
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -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: var(--border-radius-lg); border-radius: 4px;
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;
+36
View File
@@ -1,6 +1,42 @@
@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);
+356 -64
View File
@@ -1,15 +1,29 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback } from "react"; import 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 { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import {
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";
@@ -18,7 +32,10 @@ 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";
@@ -26,16 +43,13 @@ 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;
@@ -67,6 +81,302 @@ 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,
@@ -116,34 +426,34 @@ const LayerUI = ({
return null; return null;
} }
const createExporter = const createExporter = (type: ExportType): ExportCB => async (
(type: ExportType): ExportCB => exportedElements,
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
@@ -251,15 +561,12 @@ const LayerUI = ({
</Section> </Section>
); );
const closeLibrary = useCallback(() => { const closeLibrary = useCallback(
const isDialogOpen = !!document.querySelector(".Dialog"); (event) => {
setAppState({ isLibraryOpen: false });
// Prevent closing if any dialog is open },
if (isDialogOpen) { [setAppState],
return; );
}
setAppState({ isLibraryOpen: false });
}, [setAppState]);
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
setAppState({ setAppState({
@@ -270,8 +577,8 @@ const LayerUI = ({
const libraryMenu = appState.isLibraryOpen ? ( const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu <LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)} pendingElements={getSelectedElements(elements, appState)}
onClose={closeLibrary} onClickOutside={closeLibrary}
onInsertShape={onInsertElements} onInsertShape={onInsertElements}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
@@ -281,7 +588,6 @@ const LayerUI = ({
theme={appState.theme} theme={appState.theme}
files={files} files={files}
id={id} id={id}
appState={appState}
/> />
) : null; ) : null;
@@ -307,12 +613,7 @@ const LayerUI = ({
<Section heading="shapes"> <Section heading="shapes">
{(heading) => ( {(heading) => (
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row gap={1}>
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": zenModeEnabled,
})}
>
<LockButton <LockButton
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked} checked={appState.elementLocked}
@@ -321,15 +622,9 @@ const LayerUI = ({
/> />
<Island <Island
padding={1} padding={1}
className={clsx("App-toolbar", { className={clsx({ "zen-mode": zenModeEnabled })}
"zen-mode": zenModeEnabled,
})}
> >
<HintViewer <HintViewer appState={appState} elements={elements} />
appState={appState}
elements={elements}
isMobile={isMobile}
/>
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
@@ -410,8 +705,7 @@ 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": "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
zenModeEnabled,
})} })}
> >
{actionManager.renderAction("undo", { size: "small" })} {actionManager.renderAction("undo", { size: "small" })}
@@ -425,8 +719,7 @@ 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": "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
zenModeEnabled,
}, },
)} )}
> >
@@ -552,7 +845,6 @@ 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])
); );
}; };
+4 -4
View File
@@ -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"];
isMobile?: boolean; }> = ({ appState, setAppState }) => {
}> = ({ appState, setAppState, isMobile }) => {
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library", "ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_medium`, `ToolIcon_size_medium`,
{ {
"is-mobile": isMobile, "zen-mode-visibility--hidden": appState.zenModeEnabled,
}, },
)} )}
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"
-55
View File
@@ -1,55 +0,0 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.publish-library-success {
.Dialog__content {
display: flex;
flex-direction: column;
}
&-close.ToolIcon_type_button {
background-color: $oc-blue-6;
align-self: flex-end;
&:hover {
background-color: $oc-blue-8;
}
.ToolIcon__icon {
width: auto;
font-size: 1rem;
color: $oc-white;
padding: 0 0.5rem;
}
}
}
}
-326
View File
@@ -1,326 +0,0 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
LibraryItems,
LibraryItem,
AppState,
BinaryFiles,
ExcalidrawProps,
} from "../types";
import { Dialog } from "./Dialog";
import { Island } from "./Island";
import PublishLibrary from "./PublishLibrary";
import { ToolButton } from "./ToolButton";
import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
const getSelectedItems = (
libraryItems: LibraryItems,
selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id));
export const LibraryMenu = ({
onClose,
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
id,
appState,
}: {
pendingElements: LibraryItem["elements"];
onClose: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
appState: AppState;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
onClose();
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
onClose();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false);
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(async () => {
const items = await library.loadLibrary();
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
setLibraryItems(nextItems);
}, [library, setAppState, selectedItems, setSelectedItems]);
const resetLibrary = useCallback(() => {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}, [library, focusContainer]);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems: LibraryItems = [
{
status: "unpublished",
elements,
id: randomId(),
created: Date.now(),
},
...items,
];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
const renderPublishSuccess = useCallback(() => {
return (
<Dialog
onCloseRequest={() => setPublishLibSuccess(null)}
title={t("publishSuccessDialog.title")}
className="publish-library-success"
small={true}
>
<p>
{t("publishSuccessDialog.content", {
authorName: publishLibSuccess!.authorName,
})}{" "}
<a
href={publishLibSuccess?.url}
target="_blank"
rel="noopener noreferrer"
>
{t("publishSuccessDialog.link")}
</a>
</p>
<ToolButton
type="button"
title={t("buttons.close")}
aria-label={t("buttons.close")}
label={t("buttons.close")}
onClick={() => setPublishLibSuccess(null)}
data-testid="publish-library-success-close"
className="publish-library-success-close"
/>
</Dialog>
);
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
},
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
appState={appState}
onSuccess={onPublishLibSuccess}
onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setSelectedItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
);
};
-102
View File
@@ -1,102 +0,0 @@
@import "open-color/open-color";
.excalidraw {
.library-menu-items-container {
.library-actions {
display: flex;
button .library-actions-counter {
position: absolute;
right: 2px;
bottom: 2px;
border-radius: 50%;
width: 1em;
height: 1em;
padding: 1px;
font-size: 0.7rem;
background: #fff;
}
&--remove {
background-color: $oc-red-7;
&:hover {
background-color: $oc-red-8;
}
&:active {
background-color: $oc-red-9;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-red-7;
}
}
&--export {
background-color: $oc-lime-5;
&:hover {
background-color: $oc-lime-7;
}
&:active {
background-color: $oc-lime-8;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-lime-5;
}
}
&--publish {
background-color: $oc-cyan-6;
&:hover {
background-color: $oc-cyan-7;
}
&:active {
background-color: $oc-cyan-9;
}
svg {
color: $oc-white;
}
label {
margin-left: -0.2em;
margin-right: 1.1em;
color: $oc-white;
font-size: 0.86em;
}
.library-actions-counter {
color: $oc-cyan-6;
}
}
&--load {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-7;
}
&:active {
background-color: $oc-blue-9;
}
svg {
color: $oc-white;
}
}
}
&__items {
max-height: 50vh;
overflow: auto;
margin-top: 0.5rem;
}
.separator {
font-weight: 500;
font-size: 0.9rem;
margin: 0.6em 0.2em;
color: var(--text-primary-color);
}
}
}
-323
View File
@@ -1,323 +0,0 @@
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;
+15 -66
View File
@@ -1,5 +1,3 @@
@import "../css/variables.module";
.excalidraw { .excalidraw {
.library-unit { .library-unit {
align-items: center; align-items: center;
@@ -9,26 +7,10 @@
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 {
display: flex; display: flex;
align-items: center;
justify-content: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
@@ -40,9 +22,9 @@
max-width: 100%; max-width: 100%;
} }
.library-unit__checkbox-container, .library-unit__removeFromLibrary,
.library-unit__checkbox-container:hover, .library-unit__removeFromLibrary:hover,
.library-unit__checkbox-container:active { .library-unit__removeFromLibrary:active {
align-items: center; align-items: center;
background: none; background: none;
border: none; border: none;
@@ -50,35 +32,10 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 0; margin: 0;
padding: 0.5rem; padding: 0;
position: absolute; position: absolute;
left: 2rem; right: 5px;
bottom: 2rem; top: 5px;
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 {
@@ -86,37 +43,29 @@
width: 16px; width: 16px;
} }
.library-unit__adder { .library-unit__pulse {
transform: scale(1); transform: scale(1);
animation: library-unit__adder-animation 1s ease-in infinite; animation: library-unit__pulse-animation 1s ease-in infinite;
} }
.library-unit__adder { .library-unit__adder {
position: absolute; position: absolute;
left: 40%; left: 50%;
top: 40%; top: 50%;
width: 2rem; width: 20px;
height: 2rem; height: 20px;
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__adder-animation { @keyframes library-unit__pulse-animation {
0% { 0% {
transform: scale(0.85); transform: scale(0.95);
} }
50% { 50% {
@@ -124,7 +73,7 @@
} }
100% { 100% {
transform: scale(0.85); transform: scale(0.95);
} }
} }
} }
+27 -41
View File
@@ -1,42 +1,36 @@
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
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" fill="currentColor"
style={{ 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"
stroke: "#fff",
strokeWidth: 140,
}}
transform="translate(0 64)"
/> />
</svg> </svg>
); );
export const LibraryUnit = ({ export const LibraryUnit = ({
id,
elements, elements,
files, files,
isPending, pendingElements,
onRemoveFromLibrary,
onClick, onClick,
selected,
onToggle,
}: { }: {
id: LibraryItem["id"] | /** for pending item */ null; elements?: LibraryItem;
elements?: LibraryItem["elements"];
files: BinaryFiles; files: BinaryFiles;
isPending?: boolean; pendingElements?: LibraryItem;
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(() => {
@@ -46,11 +40,12 @@ export const LibraryUnit = ({
} }
(async () => { (async () => {
if (!elements) { const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return; return;
} }
const svg = await exportToSvg( const svg = await exportToSvg(
elements, elementsToRender,
{ {
exportBackground: false, exportBackground: false,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
@@ -63,41 +58,30 @@ export const LibraryUnit = ({
return () => { return () => {
node.innerHTML = ""; node.innerHTML = "";
}; };
}, [elements, files]); }, [elements, pendingElements, 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, "library-unit__active": elements || pendingElements,
"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": !!isPending, "library-unit__pulse": !!pendingElements,
})} })}
ref={ref} ref={ref}
draggable={!!elements} draggable={!!elements}
onClick={ onClick={!!elements || !!pendingElements ? onClick : undefined}
!!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(
@@ -107,12 +91,14 @@ export const LibraryUnit = ({
}} }}
/> />
{adder} {adder}
{id && elements && (isHovered || isMobile || selected) && ( {elements && (isHovered || isMobile) && (
<CheckboxItem <button
checked={selected} className="library-unit__removeFromLibrary"
onChange={(checked, event) => onToggle(id, event)} aria-label={t("labels.removeFromLibrary")}
className="library-unit__checkbox" onClick={onRemoveFromLibrary}
/> >
{close}
</button>
)} )}
</div> </div>
); );
+2 -3
View File
@@ -10,7 +10,6 @@ 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";
@@ -43,10 +42,10 @@ export const LockButton = (props: LockIconProps) => {
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating", "ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`, `ToolIcon_size_${DEFAULT_SIZE}`,
{ {
"is-mobile": props.isMobile, "zen-mode-visibility--hidden": props.zenModeEnabled,
}, },
)} )}
title={`${props.title} — Q`} title={`${props.title} — Q`}
+4 -9
View File
@@ -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} className="App-toolbar-container"> <Stack.Row gap={1}>
<Island padding={1} className="App-toolbar"> <Island padding={1}>
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
@@ -85,19 +85,14 @@ 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} isMobile={true} /> <HintViewer appState={appState} elements={elements} />
</FixedSideContainer> </FixedSideContainer>
); );
}; };
+2 -6
View File
@@ -15,9 +15,8 @@ export const Modal = (props: {
onCloseRequest(): void; onCloseRequest(): void;
labelledBy: string; labelledBy: string;
theme?: AppState["theme"]; theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}) => { }) => {
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props; const { theme = THEME.LIGHT } = props;
const modalRoot = useBodyRoot(theme); const modalRoot = useBodyRoot(theme);
if (!modalRoot) { if (!modalRoot) {
@@ -40,10 +39,7 @@ export const Modal = (props: {
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
> >
<div <div className="Modal__background" onClick={props.onCloseRequest}></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` }}
+1 -1
View File
@@ -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["elements"]) => void; onInsertChart: (elements: LibraryItem) => void;
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
-1
View File
@@ -42,7 +42,6 @@ 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}
-92
View File
@@ -1,92 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.publish-library {
&__fields {
display: flex;
flex-direction: column;
label {
padding: 1em;
display: flex;
justify-content: space-between;
align-items: center;
span {
font-weight: 500;
font-size: 1rem;
color: $oc-gray-6;
}
input,
textarea {
width: 70%;
padding: 0.6em;
font-family: var(--ui-font);
}
.required {
color: $oc-red-8;
margin: 0.2rem;
}
}
}
&__buttons {
display: flex;
padding: 0.2rem 0;
justify-content: flex-end;
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1rem;
padding: 0 0.5rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-8;
}
}
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-5;
&:hover {
background-color: $oc-gray-6;
}
}
.ToolIcon__icon {
color: $oc-white;
.Spinner {
--spinner-color: #fff;
svg {
padding: 0.5rem;
}
}
}
}
.selected-library-items {
display: flex;
padding: 0 0.8rem;
flex-wrap: wrap;
.single-library-item-wrapper {
width: 9rem;
}
}
&-note {
padding: 1em;
font-style: italic;
font-size: 14px;
display: block;
}
}
}
-455
View File
@@ -1,455 +0,0 @@
import { ReactNode, useCallback, useEffect, useState } from "react";
import OpenColor from "open-color";
import { Dialog } from "./Dialog";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas } from "../packages/utils";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils";
interface PublishLibraryDataParams {
authorName: string;
githubHandle: string;
name: string;
description: string;
twitterHandle: string;
website: string;
}
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
JSON.stringify(data),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importPublishLibDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
if (data) {
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128;
const BOX_PADDING = Math.round(BOX_SIZE / 16);
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
const canvas = document.createElement("canvas");
canvas.width =
rows[0].length * BOX_SIZE +
(rows[0].length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
canvas.height =
rows.length * BOX_SIZE +
(rows.length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
// draw item
// -------------------------------------------------------------------------
const rowOffset =
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
const colOffset =
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
ctx.drawImage(
itemCanvas,
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
);
// draw item border
// -------------------------------------------------------------------------
ctx.lineWidth = BORDER_WIDTH;
ctx.strokeStyle = OpenColor.gray[4];
ctx.strokeRect(
colOffset + BOX_PADDING / 2,
rowOffset + BOX_PADDING / 2,
BOX_SIZE + BOX_PADDING,
BOX_SIZE + BOX_PADDING,
);
}
return await resizeImageFile(
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
{
outputType: MIME_TYPES.jpg,
maxWidthOrHeight: 5000,
},
);
};
const PublishLibrary = ({
onClose,
libraryItems,
appState,
onSuccess,
onError,
updateItemsInStorage,
onRemove,
}: {
onClose: () => void;
libraryItems: LibraryItems;
appState: AppState;
onSuccess: (data: {
url: string;
authorName: string;
items: LibraryItems;
}) => void;
onError: (error: Error) => void;
updateItemsInStorage: (items: LibraryItems) => void;
onRemove: (id: string) => void;
}) => {
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
authorName: "",
githubHandle: "",
name: "",
description: "",
twitterHandle: "",
website: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const data = importPublishLibDataFromStorage();
if (data) {
setLibraryData(data);
}
}, []);
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
libraryItems.slice(),
);
useEffect(() => {
setClonedLibItems(libraryItems.slice());
}, [libraryItems]);
const onInputChange = (event: any) => {
setLibraryData({
...libraryData,
[event.target.name]: event.target.value,
});
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
const erroredLibItems: LibraryItem[] = [];
let isError = false;
clonedLibItems.forEach((libItem) => {
let error = "";
if (!libItem.name) {
error = t("publishDialog.errors.required");
isError = true;
}
erroredLibItems.push({ ...libItem, error });
});
if (isError) {
setClonedLibItems(erroredLibItems);
setIsSubmitting(false);
return;
}
const previewImage = await generatePreviewImage(clonedLibItems);
const libContent: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
source: EXPORT_SOURCE,
libraryItems: clonedLibItems,
};
const content = JSON.stringify(libContent, null, 2);
const lib = new Blob([content], { type: "application/json" });
const formData = new FormData();
formData.append("excalidrawLib", lib);
formData.append("previewImage", previewImage);
formData.append("previewImageType", previewImage.type);
formData.append("title", libraryData.name);
formData.append("authorName", libraryData.authorName);
formData.append("githubHandle", libraryData.githubHandle);
formData.append("name", libraryData.name);
formData.append("description", libraryData.description);
formData.append("twitterHandle", libraryData.twitterHandle);
formData.append("website", libraryData.website);
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
method: "post",
body: formData,
})
.then(
(response) => {
if (response.ok) {
return response.json().then(({ url }) => {
// flush data from local storage
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
onSuccess({
url,
authorName: libraryData.authorName,
items: clonedLibItems,
});
});
}
return response
.json()
.catch(() => {
throw new Error(response.statusText || "something went wrong");
})
.then((error) => {
throw new Error(
error.message || response.statusText || "something went wrong",
);
});
},
(err) => {
console.error(err);
onError(err);
setIsSubmitting(false);
},
)
.catch((err) => {
console.error(err);
onError(err);
setIsSubmitting(false);
});
};
const renderLibraryItems = () => {
const items: ReactNode[] = [];
clonedLibItems.forEach((libItem, index) => {
items.push(
<div className="single-library-item-wrapper" key={index}>
<SingleLibraryItem
libItem={libItem}
appState={appState}
index={index}
onChange={(val, index) => {
const items = clonedLibItems.slice();
items[index].name = val;
setClonedLibItems(items);
}}
onRemove={onRemove}
/>
</div>,
);
});
return <div className="selected-library-items">{items}</div>;
};
const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems);
savePublishLibDataToStorage(libraryData);
onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
const shouldRenderForm = !!libraryItems.length;
return (
<Dialog
onCloseRequest={onDialogClose}
title={t("publishDialog.title")}
className="publish-library"
>
{shouldRenderForm ? (
<form onSubmit={onSubmit}>
<div className="publish-library-note">
{t("publishDialog.noteDescription.pre")}
<a
href="https://libraries.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteDescription.link")}
</a>{" "}
{t("publishDialog.noteDescription.post")}
</div>
<span className="publish-library-note">
{t("publishDialog.noteGuidelines.pre")}
<a
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteGuidelines.link")}
</a>
{t("publishDialog.noteGuidelines.post")}
</span>
<div className="publish-library-note">
{t("publishDialog.noteItems")}
</div>
{renderLibraryItems()}
<div className="publish-library__fields">
<label>
<div>
<span>{t("publishDialog.libraryName")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
name="name"
required
value={libraryData.name}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.libraryName")}
/>
</label>
<label style={{ alignItems: "flex-start" }}>
<div>
<span>{t("publishDialog.libraryDesc")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<textarea
name="description"
rows={4}
required
value={libraryData.description}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.libraryDesc")}
/>
</label>
<label>
<div>
<span>{t("publishDialog.authorName")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
name="authorName"
required
value={libraryData.authorName}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.authorName")}
/>
</label>
<label>
<span>{t("publishDialog.githubUsername")}</span>
<input
type="text"
name="githubHandle"
value={libraryData.githubHandle}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.githubHandle")}
/>
</label>
<label>
<span>{t("publishDialog.twitterUsername")}</span>
<input
type="text"
name="twitterHandle"
value={libraryData.twitterHandle}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.twitterHandle")}
/>
</label>
<label>
<span>{t("publishDialog.website")}</span>
<input
type="text"
name="website"
pattern="https?://.+"
title={t("publishDialog.errors.website")}
value={libraryData.website}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.website")}
/>
</label>
<span className="publish-library-note">
{t("publishDialog.noteLicense.pre")}
<a
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteLicense.link")}
</a>
{t("publishDialog.noteLicense.post")}
</span>
</div>
<div className="publish-library__buttons">
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={onDialogClose}
data-testid="cancel-clear-canvas-button"
className="publish-library__buttons--cancel"
/>
<ToolButton
type="submit"
title={t("buttons.submit")}
aria-label={t("buttons.submit")}
label={t("buttons.submit")}
className="publish-library__buttons--confirm"
isLoading={isSubmitting}
/>
</div>
</form>
) : (
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
{t("publishDialog.atleastOneLibItem")}
</p>
)}
</Dialog>
);
};
export default PublishLibrary;
-66
View File
@@ -1,66 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.single-library-item {
position: relative;
&__svg {
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
margin: 0.3rem;
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1.3rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
}
-99
View File
@@ -1,99 +0,0 @@
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;
+18
View File
@@ -2,6 +2,24 @@
.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);
}
} }
} }
+4 -20
View File
@@ -25,7 +25,6 @@ type ToolButtonBaseProps = {
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
className?: string; className?: string;
isLoading?: boolean;
}; };
type ToolButtonProps = type ToolButtonProps =
@@ -34,11 +33,6 @@ 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;
@@ -67,11 +61,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else {
console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
@@ -90,14 +82,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const lastPointerTypeRef = useRef<PointerType | null>(null); const lastPointerTypeRef = useRef<PointerType | null>(null);
if ( if (props.type === "button" || props.type === "icon") {
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(
@@ -117,10 +102,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={type} type="button"
onClick={onClick} onClick={onClick}
ref={innerRef} ref={innerRef}
disabled={isLoading || props.isLoading} disabled={isLoading}
> >
{(props.icon || props.label) && ( {(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true"> <div className="ToolIcon__icon" aria-hidden="true">
@@ -130,7 +115,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
{props.keyBindingLabel} {props.keyBindingLabel}
</span> </span>
)} )}
{props.isLoading && <Spinner />}
</div> </div>
)} )}
{props.showAriaLabel && ( {props.showAriaLabel && (
+29 -21
View File
@@ -6,9 +6,20 @@
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 {
@@ -19,20 +30,6 @@
} }
} }
.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;
@@ -42,11 +39,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: var(--border-radius-lg); border-radius: var(--space-factor);
& + .ToolIcon__label {
margin-inline-start: 0;
}
svg { svg {
position: relative; position: relative;
@@ -54,6 +47,10 @@
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 {
@@ -83,7 +80,7 @@
margin: 0; margin: 0;
font-size: inherit; font-size: inherit;
&:focus-visible { &:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 2px var(--focus-highlight-color);
} }
@@ -125,7 +122,7 @@
} }
} }
&:focus-visible + .ToolIcon__icon { &:focus + .ToolIcon__icon {
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 2px var(--focus-highlight-color);
} }
@@ -145,6 +142,10 @@
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 {
@@ -159,6 +160,13 @@
} }
} }
.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;
-112
View File
@@ -1,112 +0,0 @@
@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};
}
}
+1
View File
@@ -29,6 +29,7 @@
// 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 {
+5 -10
View File
@@ -34,8 +34,10 @@ const updateTooltip = (
width: itemWidth, width: itemWidth,
} = item.getBoundingClientRect(); } = item.getBoundingClientRect();
const { width: labelWidth, height: labelHeight } = const {
tooltip.getBoundingClientRect(); width: labelWidth,
height: labelHeight,
} = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -62,15 +64,9 @@ type TooltipProps = {
children: React.ReactNode; children: React.ReactNode;
label: string; label: string;
long?: boolean; long?: boolean;
style?: React.CSSProperties;
}; };
export const Tooltip = ({ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
children,
label,
long = false,
style,
}: TooltipProps) => {
useEffect(() => { useEffect(() => {
return () => return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible"); getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
@@ -90,7 +86,6 @@ export const Tooltip = ({
onPointerLeave={() => onPointerLeave={() =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible") getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
} }
style={style}
> >
{children} {children}
</div> </div>
+3 -3
View File
@@ -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: any) { } catch (error) {
_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: any) { } catch (error) {
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: any) { } catch (error) {
console.error(error); console.error(error);
} }
}} }}
-4
View File
@@ -7,10 +7,6 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
&:empty {
display: none;
}
} }
.UserList > * { .UserList > * {
+4 -33
View File
@@ -15,9 +15,8 @@ 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) =>
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)"; theme === THEME.LIGHT ? oc.black : oc.gray[4];
const handlerColor = (theme: Theme) => const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e"; theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@@ -31,12 +30,8 @@ export const createIcon = (
d: string | React.ReactNode, d: string | React.ReactNode,
opts: number | Opts = 512, opts: number | Opts = 512,
) => { ) => {
const { const { width = 512, height = width, mirror, style } =
width = 512, typeof opts === "number" ? ({ width: opts } as Opts) : opts;
height = width,
mirror,
style,
} = typeof opts === "number" ? ({ width: opts } as Opts) : opts;
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -86,7 +81,6 @@ 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 },
); );
@@ -758,21 +752,6 @@ 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
@@ -884,11 +863,3 @@ 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 },
);
+4 -9
View File
@@ -106,6 +106,10 @@ export const EXPORT_DATA_TYPES = {
export const EXPORT_SOURCE = window.location.origin; export const EXPORT_SOURCE = window.location.origin;
export const STORAGE_KEYS = {
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
@@ -170,12 +174,3 @@ 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 BOUND_TEXT_PADDING = 5;
+8 -31
View File
@@ -180,7 +180,7 @@
} }
.buttonList label:focus-within, .buttonList label:focus-within,
input:focus-visible { input:focus {
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: var(--border-radius-md); border-radius: 4px;
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-visible { &:focus {
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,16 +217,14 @@
.active, .active,
.buttonList label.active { .buttonList label.active {
background-color: var(--color-primary); background-color: var(--button-gray-2);
--icon-fill-color: #{$oc-white};
&:hover { &:hover {
background-color: var(--color-primary-darker); background-color: var(--button-gray-2);
} }
&:active { &:active {
background-color: var(--color-primary-darkest); background-color: var(--button-gray-3);
} }
} }
@@ -236,7 +234,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
svg { svg {
width: 35px; width: 36px;
height: 14px; height: 14px;
padding: 2px; padding: 2px;
opacity: 0.6; opacity: 0.6;
@@ -313,7 +311,7 @@
} }
.App-menu_top { .App-menu_top {
grid-template-columns: auto max-content auto; grid-template-columns: 1fr auto 1fr;
grid-gap: 4px; grid-gap: 4px;
align-items: flex-start; align-items: flex-start;
cursor: default; cursor: default;
@@ -519,27 +517,6 @@
} }
} }
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,
+4 -21
View File
@@ -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-gray-9}; --icon-fill-color: #{$oc-black};
--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-4}; --input-border-color: #{$oc-gray-3};
--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,20 +32,10 @@
--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 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%); --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--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;
@@ -74,20 +64,13 @@
--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: 1px 1px 5px #{transparentize($oc-black, 0.7)}; --shadow-island: 0 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;
} }
} }
+20 -27
View File
@@ -11,7 +11,6 @@ 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";
@@ -25,7 +24,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: any) { } catch (error) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new DOMException(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
@@ -59,7 +58,7 @@ const parseFileContents = async (blob: Blob | File) => {
).decodeSvgMetadata({ ).decodeSvgMetadata({
svg: contents, svg: contents,
}); });
} catch (error: any) { } catch (error) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new DOMException(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
@@ -157,7 +156,7 @@ export const loadFromBlob = async (
); );
return result; return result;
} catch (error: any) { } catch (error) {
console.error(error.message); console.error(error.message);
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
@@ -188,7 +187,7 @@ export const canvasToBlob = async (
} }
resolve(blob); resolve(blob);
}); });
} catch (error: any) { } catch (error) {
reject(error); reject(error);
} }
}); });
@@ -196,18 +195,26 @@ 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): Promise<FileId> => { export const generateIdFromFile = async (file: File) => {
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(),
); );
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId; id =
} catch (error: any) { // convert buffer to byte array
Array.from(new Uint8Array(hashBuffer))
// convert to hex string
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("") as FileId;
} catch (error) {
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)
return nanoid(40) as FileId; id = nanoid(40) as FileId;
} }
return id;
}; };
export const getDataURL = async (file: Blob | File): Promise<DataURL> => { export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
@@ -237,11 +244,7 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
export const resizeImageFile = async ( export const resizeImageFile = async (
file: File, file: File,
opts: { maxWidthOrHeight: number,
/** 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) {
@@ -261,26 +264,16 @@ export const resizeImageFile = async (
pica: pica({ features: ["js", "wasm"] }), pica: pica({ features: ["js", "wasm"] }),
}); });
if (opts.outputType) { const fileType = file.type;
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: opts.maxWidthOrHeight })], [await reduce.toBlob(file, { max: maxWidthOrHeight })],
file.name, file.name,
{ { type: fileType },
type: opts.outputType || file.type,
},
); );
}; };
+3 -15
View File
@@ -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: any) { } catch (error) {
console.error("encode: cannot deflate", error); console.error("encode: cannot deflate", error);
} }
} }
@@ -234,19 +234,7 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
let cursor = 0; let cursor = 0;
// first chunk is the version // first chunk is the version (ignored for now)
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) {
@@ -379,7 +367,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: any) { } catch (error) {
console.error( console.error(
`Error during decompressing and decrypting the file.`, `Error during decompressing and decrypting the file.`,
encodingMetadata, encodingMetadata,
+8 -21
View File
@@ -1,5 +1,3 @@
import { ENCRYPTION_KEY_BITS } from "../constants";
export const IV_LENGTH_BYTES = 12; export const IV_LENGTH_BYTES = 12;
export const createIV = () => { export const createIV = () => {
@@ -7,27 +5,19 @@ 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: ENCRYPTION_KEY_BITS, length: 128,
}, },
true, // extractable true, // extractable
["encrypt", "decrypt"], ["encrypt", "decrypt"],
); );
return ( return (await window.crypto.subtle.exportKey("jwk", key)).k;
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
}; };
export const getCryptoKey = (key: string, usage: KeyUsage) => export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey( window.crypto.subtle.importKey(
"jwk", "jwk",
{ {
@@ -39,18 +29,17 @@ export const getCryptoKey = (key: string, usage: KeyUsage) =>
}, },
{ {
name: "AES-GCM", name: "AES-GCM",
length: ENCRYPTION_KEY_BITS, length: 128,
}, },
false, // extractable false, // extractable
[usage], [usage],
); );
export const encryptData = async ( export const encryptData = async (
key: string | CryptoKey, key: string,
data: Uint8Array | ArrayBuffer | Blob | File | string, data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => { ): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = const importedKey = await getImportedKey(key, "encrypt");
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"
@@ -61,8 +50,6 @@ 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",
@@ -80,7 +67,7 @@ export const decryptData = async (
encrypted: Uint8Array | ArrayBuffer, encrypted: Uint8Array | ArrayBuffer,
privateKey: string, privateKey: string,
): Promise<ArrayBuffer> => { ): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt"); const key = await getImportedKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt( return window.crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
+3 -3
View File
@@ -4,7 +4,7 @@ import {
fileSave as _fileSave, fileSave as _fileSave,
FileSystemHandle, FileSystemHandle,
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "browser-fs-access"; } from "@dwelle/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
View File
@@ -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: any) { } catch (error) {
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: any) { } catch (error) {
console.error(error); console.error(error);
throw new Error("FAILED"); throw new Error("FAILED");
} }
+1 -3
View File
@@ -54,7 +54,6 @@ 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,
@@ -87,7 +86,6 @@ export const exportCanvas = async (
} }
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG",
name, name,
extension: "png", extension: "png",
fileHandle, fileHandle,
@@ -95,7 +93,7 @@ export const exportCanvas = async (
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
await copyBlobToClipboardAsPng(blob); await copyBlobToClipboardAsPng(blob);
} catch (error: any) { } catch (error) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error; throw error;
} }
+8 -12
View File
@@ -1,14 +1,9 @@
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import { import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
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, LibraryItems } from "../types"; import { AppState, BinaryFiles } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob"; import { isImageFileHandle, loadFromBlob } from "./blob";
import { import {
@@ -47,7 +42,7 @@ export const serializeAsJSON = (
): string => { ): string => {
const data: ExportedDataState = { const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw, type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw, version: 2,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
elements: elements:
type === "local" type === "local"
@@ -119,16 +114,17 @@ 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 === 2) json.version === 1
); );
}; };
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => { export const saveLibraryAsJSON = async (library: Library) => {
const libraryItems = await library.loadLibrary();
const data: ExportedLibraryData = { const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary, type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary, version: 1,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
libraryItems, library: libraryItems,
}; };
const serialized = JSON.stringify(data, null, 2); const serialized = JSON.stringify(data, null, 2);
await fileSave( await fileSave(
+17 -24
View File
@@ -1,6 +1,6 @@
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreElements, restoreLibraryItems } from "./restore"; import { restoreElements } from "./restore";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type App from "../components/App"; import type App from "../components/App";
@@ -18,16 +18,14 @@ class Library {
}; };
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements( const elements = getNonDeletedElements(restoreElements(libraryItem, null));
restoreElements(libraryItem.elements, null), return elements.length ? elements : null;
);
return elements.length ? { ...libraryItem, elements } : null;
}; };
/** imports library (currently merges, removing duplicates) */ /** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob, defaultStatus = "unpublished") { async importLibrary(blob: Blob) {
const libraryFile = await loadLibraryFromBlob(blob); const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) { if (!libraryFile || !libraryFile.library) {
return; return;
} }
@@ -39,17 +37,17 @@ class Library {
targetLibraryItem: LibraryItem, targetLibraryItem: LibraryItem,
) => { ) => {
return !existingLibraryItems.find((libraryItem) => { return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) { if (libraryItem.length !== targetLibraryItem.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.elements.every((libItemExcalidrawItem, idx) => { return libraryItem.every((libItemExcalidrawItem, idx) => {
return ( return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id && libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
libItemExcalidrawItem.versionNonce === libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce targetLibraryItem[idx].versionNonce
); );
}); });
}); });
@@ -57,20 +55,15 @@ class Library {
const existingLibraryItems = await this.loadLibrary(); const existingLibraryItems = await this.loadLibrary();
const library = libraryFile.libraryItems || libraryFile.library || []; const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
const restoredLibItems = restoreLibraryItems( const restoredItem = this.restoreLibraryItem(libraryItem);
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)) {
filteredItems.push(restoredItem); acc.push(restoredItem);
} }
} return acc;
}, [] as Mutable<LibraryItems>);
await this.saveLibrary([...filteredItems, ...existingLibraryItems]); await this.saveLibrary([...existingLibraryItems, ...filtered]);
} }
loadLibrary = (): Promise<LibraryItems> => { loadLibrary = (): Promise<LibraryItems> => {
@@ -97,7 +90,7 @@ class Library {
this.libraryCache = JSON.parse(JSON.stringify(items)); this.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items); resolve(items);
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
resolve([]); resolve([]);
} }
@@ -112,7 +105,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: any) { } catch (error) {
this.libraryCache = prevLibraryItems; this.libraryCache = prevLibraryItems;
throw error; throw error;
} }
+17 -54
View File
@@ -3,14 +3,13 @@ import {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
FontFamilyValues, FontFamilyValues,
} from "../element/types"; } from "../element/types";
import { import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element"; import {
getElementMap,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
@@ -22,8 +21,6 @@ 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,
@@ -62,12 +59,9 @@ 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.
@@ -101,17 +95,14 @@ const restoreElementWithProperties = <
strokeSharpness: strokeSharpness:
element.strokeSharpness ?? element.strokeSharpness ??
(isLinearElementType(element.type) ? "round" : "sharp"), (isLinearElementType(element.type) ? "round" : "sharp"),
boundElements: element.boundElementIds boundElementIds: 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 = (
@@ -122,9 +113,10 @@ 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]: [string, string] = ( const [fontPx, _fontFamily]: [
element as any string,
).font.split(" "); string,
] = (element as any).font.split(" ");
fontSize = parseInt(fontPx, 10); fontSize = parseInt(fontPx, 10);
fontFamily = getFontFamilyByName(_fontFamily); fontFamily = getFontFamilyByName(_fontFamily);
} }
@@ -135,8 +127,6 @@ 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 || element.text,
}); });
case "freedraw": { case "freedraw": {
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@@ -210,14 +200,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 ? arrayToMap(localElements) : null; const localElementsMap = localElements ? getElementMap(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?.get(element.id); const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) { if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version); migratedElement = bumpVersion(migratedElement, localElement.version);
} }
@@ -284,30 +274,3 @@ 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;
};
+5 -9
View File
@@ -1,7 +1,6 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types"; import { AppState, BinaryFiles, LibraryItems } 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;
@@ -19,18 +18,15 @@ 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_v1; libraryItems?: LibraryItems;
files?: BinaryFiles; files?: BinaryFiles;
} }
export interface ExportedLibraryData { export interface ExportedLibraryData {
type: string; type: string;
version: typeof VERSIONS.excalidrawLibrary; version: number;
source: string; source: string;
libraryItems: LibraryItems; library: LibraryItems;
} }
export interface ImportedLibraryData extends Partial<ExportedLibraryData> { export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
/** @deprecated v1 */
library?: LibraryItems;
}
+48 -2
View File
@@ -1,7 +1,17 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { getMaximumGroups } from "./groups"; import { getCommonBounds } from "./element";
import { getCommonBoundingBox } from "./element/bounds";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
midX: number;
midY: number;
width: number;
height: number;
}
export interface Distribution { export interface Distribution {
space: "between"; space: "between";
@@ -88,3 +98,39 @@ export const distributeElements = (
); );
}); });
}; };
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
midX: (minX + maxX) / 2,
midY: (minY + maxY) / 2,
};
};
+85 -113
View File
@@ -8,11 +8,7 @@ import {
} from "./types"; } from "./types";
import { getElementAtPosition } from "../scene"; import { getElementAtPosition } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { import { isBindableElement, isBindingElement } from "./typeChecks";
isBindableElement,
isBindingElement,
isLinearElement,
} from "./typeChecks";
import { import {
bindingBorderTest, bindingBorderTest,
distanceToBindableElement, distanceToBindableElement,
@@ -24,7 +20,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 { arrayToMap, tupleToCoors } from "../utils"; import { tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
export type SuggestedBinding = export type SuggestedBinding =
@@ -78,9 +74,8 @@ export const bindOrUnbindLinearElement = (
.getNonDeletedElements(onlyUnbound) .getNonDeletedElements(onlyUnbound)
.forEach((element) => { .forEach((element) => {
mutateElement(element, { mutateElement(element, {
boundElements: element.boundElements?.filter( boundElementIds: element.boundElementIds?.filter(
(element) => (id) => id !== linearElement.id,
element.type !== "arrow" || element.id !== linearElement.id,
), ),
}); });
}); });
@@ -142,13 +137,14 @@ export const bindOrUnbindSelectedElements = (
const maybeBindBindableElement = ( const maybeBindBindableElement = (
bindableElement: NonDeleted<ExcalidrawBindableElement>, bindableElement: NonDeleted<ExcalidrawBindableElement>,
): void => { ): void => {
getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( getElligibleElementsForBindableElementAndWhere(
([linearElement, where]) => bindableElement,
bindOrUnbindLinearElement( ).forEach(([linearElement, where]) =>
linearElement, bindOrUnbindLinearElement(
where === "end" ? "keep" : bindableElement, linearElement,
where === "start" ? "keep" : bindableElement, where === "end" ? "keep" : bindableElement,
), where === "start" ? "keep" : bindableElement,
),
); );
}; };
@@ -185,16 +181,11 @@ const bindLinearElement = (
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd), ...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
} as PointBinding, } as PointBinding,
}); });
mutateElement(hoveredElement, {
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); boundElementIds: Array.from(
if (!boundElementsMap.has(linearElement.id)) { new Set([...(hoveredElement.boundElementIds ?? []), 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
@@ -294,56 +285,50 @@ export const updateBoundElements = (
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
}, },
) => { ) => {
const boundLinearElements = (changedElement.boundElements ?? []).filter( const boundElementIds = changedElement.boundElementIds ?? [];
(el) => el.type === "arrow", if (boundElementIds.length === 0) {
);
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)! (Scene.getScene(changedElement)!.getNonDeletedElements(
.getNonDeletedElements(boundLinearElements.map((el) => el.id)) boundElementIds,
.forEach((element) => { ) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
if (!isLinearElement(element)) { const bindableElement = changedElement as ExcalidrawBindableElement;
return; // In case the boundElementIds are stale
} if (!doesNeedUpdate(linearElement, bindableElement)) {
return;
const bindableElement = changedElement as ExcalidrawBindableElement; }
// In case the boundElements are stale const startBinding = maybeCalculateNewGapWhenScaling(
if (!doesNeedUpdate(element, bindableElement)) { bindableElement,
return; linearElement.startBinding,
} newSize,
const startBinding = maybeCalculateNewGapWhenScaling( );
bindableElement, const endBinding = maybeCalculateNewGapWhenScaling(
element.startBinding, bindableElement,
newSize, linearElement.endBinding,
); newSize,
const endBinding = maybeCalculateNewGapWhenScaling( );
bindableElement, // `linearElement` is being moved/scaled already, just update the binding
element.endBinding, if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
newSize, mutateElement(linearElement, { startBinding, endBinding });
); return;
// `linearElement` is being moved/scaled already, just update the binding }
if (simultaneouslyUpdatedElementIds.has(element.id)) { updateBoundPoint(
mutateElement(element, { startBinding, endBinding }); linearElement,
return; "start",
} startBinding,
updateBoundPoint( changedElement as ExcalidrawBindableElement,
element, );
"start", updateBoundPoint(
startBinding, linearElement,
changedElement as ExcalidrawBindableElement, "end",
); endBinding,
updateBoundPoint( changedElement as ExcalidrawBindableElement,
element, );
"end", });
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
}; };
const doesNeedUpdate = ( const doesNeedUpdate = (
@@ -415,17 +400,10 @@ const updateBoundPoint = (
newEdgePoint = intersections[0]; newEdgePoint = intersections[0];
} }
} }
LinearElementEditor.movePoints( LinearElementEditor.movePoint(
linearElement, linearElement,
[ edgePointIndex,
{ LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
index: edgePointIndex,
point: LinearElementEditor.pointFromAbsoluteCoords(
linearElement,
newEdgePoint,
),
},
],
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding }, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
); );
}; };
@@ -573,11 +551,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 { boundElements } = oldElement; const { boundElementIds } = oldElement;
if (boundElements != null && boundElements.length > 0) { if (boundElementIds != null && boundElementIds.length > 0) {
boundElements.forEach((boundElement) => { boundElementIds.forEach((boundElementId) => {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) { if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
allBoundElementIds.add(boundElement.id); allBoundElementIds.add(boundElementId);
} }
}); });
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!); allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
@@ -602,11 +580,9 @@ export const fixBindingsAfterDuplication = (
}); });
// Update the linear elements // Update the linear elements
( (sceneElements.filter(({ id }) =>
sceneElements.filter(({ id }) => allBoundElementIds.has(id),
allBoundElementIds.has(id), ) as ExcalidrawLinearElement[]).forEach((element) => {
) as ExcalidrawLinearElement[]
).forEach((element) => {
const { startBinding, endBinding } = element; const { startBinding, endBinding } = element;
mutateElement(element, { mutateElement(element, {
startBinding: newBindingAfterDuplication( startBinding: newBindingAfterDuplication(
@@ -621,16 +597,12 @@ export const fixBindingsAfterDuplication = (
sceneElements sceneElements
.filter(({ id }) => allBindableElementIds.has(id)) .filter(({ id }) => allBindableElementIds.has(id))
.forEach((bindableElement) => { .forEach((bindableElement) => {
const { boundElements } = bindableElement; const { boundElementIds } = bindableElement;
if (boundElements != null && boundElements.length > 0) { if (boundElementIds != null && boundElementIds.length > 0) {
mutateElement(bindableElement, { mutateElement(bindableElement, {
boundElements: boundElements.map((boundElement) => boundElementIds: boundElementIds.map(
oldIdToDuplicatedId.has(boundElement.id) (boundElementId) =>
? { oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
id: oldIdToDuplicatedId.get(boundElement.id)!,
type: boundElement.type,
}
: boundElement,
), ),
}); });
} }
@@ -663,24 +635,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.boundElements?.forEach((element) => { deletedElement.boundElementIds?.forEach((id) => {
if (!deletedElementIds.has(element.id)) { if (!deletedElementIds.has(id)) {
boundElementIds.add(element.id); boundElementIds.add(id);
} }
}); });
} }
}); });
( (sceneElements.filter(({ id }) =>
sceneElements.filter(({ id }) => boundElementIds.has(id),
boundElementIds.has(id), ) as ExcalidrawLinearElement[]).forEach(
) as ExcalidrawLinearElement[] (element: ExcalidrawLinearElement) => {
).forEach((element: ExcalidrawLinearElement) => { const { startBinding, endBinding } = element;
const { startBinding, endBinding } = element; mutateElement(element, {
mutateElement(element, { startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds), endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds), });
}); },
}); );
}; };
const newBindingAfterDeletion = ( const newBindingAfterDeletion = (
+2 -32
View File
@@ -3,7 +3,6 @@ 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";
@@ -79,7 +78,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") {
@@ -228,7 +227,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]];
} }
@@ -259,7 +258,6 @@ 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;
@@ -296,7 +294,6 @@ 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
@@ -514,30 +511,3 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement); return getElementBounds(closestElement);
}; };
export interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
midX: number;
midY: number;
width: number;
height: number;
}
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
midX: (minX + maxX) / 2,
midY: (minY + maxY) / 2,
};
};
+12 -12
View File
@@ -31,9 +31,7 @@ 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 { hasBoundTextElement, isImageElement } from "./typeChecks"; import { isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@@ -45,8 +43,9 @@ const isElementDraggableFromInside = (
if (element.type === "freedraw") { if (element.type === "freedraw") {
return true; return true;
} }
const isDraggableFromInside =
!isTransparent(element.backgroundColor) || hasBoundTextElement(element); const isDraggableFromInside = element.backgroundColor !== "transparent";
if (element.type === "line") { if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points); return isDraggableFromInside && isPathALoop(element.points);
} }
@@ -84,18 +83,19 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
); );
}; };
export const isHittingElementNotConsideringBoundingBox = ( 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 = isTextElement(element) const check =
? isStrictlyInside element.type === "text"
: isElementDraggableFromInside(element) ? isStrictlyInside
? isInsideCheck : isElementDraggableFromInside(element)
: isNearCheck; ? isInsideCheck
: isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({ element, point, threshold, check });
}; };
@@ -866,7 +866,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") {
+19 -60
View File
@@ -5,86 +5,45 @@ import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { NonDeletedExcalidrawElement } from "./types"; import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types"; import { PointerDownState } from "../types";
import { getBoundTextElementId } from "./textElement";
import { isSelectedViaGroup } from "../groups";
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,
appState: AppState,
) => { ) => {
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) => {
updateElementCoords( let x: number;
lockDirection, let y: number;
distanceX, if (lockDirection) {
distanceY, const lockX = lockDirection && distanceX < distanceY;
pointerDownState, const lockY = lockDirection && distanceX > distanceY;
element, const original = pointerDownState.originalElements.get(element.id);
offset, x = lockX && original ? original.x : element.x + offset.x;
); y = lockY && original ? original.y : element.y + offset.y;
// update coords of bound text only if we're dragging the container directly } else {
// (we don't drag the group that it's part of) x = element.x + offset.x;
if ( y = element.y + offset.y;
// container isn't part of any group
// (perf optim so we don't check `isSelectedViaGroup()` in every case)
!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
) {
const boundTextElementId = getBoundTextElementId(element);
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,
+1 -1
View File
@@ -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: any) { } catch (error) {
erroredFiles.set(fileId, true); erroredFiles.set(fileId, true);
} }
})(), })(),
+9
View File
@@ -59,6 +59,15 @@ 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);
+105 -417
View File
@@ -25,19 +25,11 @@ export class LinearElementEditor {
public elementId: ExcalidrawElement["id"] & { public elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
/** indices */ public activePointIndex: number | null;
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: Readonly<{ x: number; y: number }>; public pointerOffset: { x: number; y: number };
public startBindingElement: ExcalidrawBindableElement | null | "keep"; public startBindingElement: ExcalidrawBindableElement | null | "keep";
public endBindingElement: ExcalidrawBindableElement | null | "keep"; public endBindingElement: ExcalidrawBindableElement | null | "keep";
@@ -48,16 +40,12 @@ export class LinearElementEditor {
Scene.mapElementToScene(this.elementId, scene); Scene.mapElementToScene(this.elementId, scene);
LinearElementEditor.normalizePoints(element); LinearElementEditor.normalizePoints(element);
this.selectedPointsIndices = null; this.activePointIndex = 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,
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -78,58 +66,6 @@ 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,
@@ -138,27 +74,21 @@ export class LinearElementEditor {
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: ( maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[], startOrEnd: "start" | "end",
) => void, ) => void,
): boolean { ): boolean {
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return false; return false;
} }
const { editingLinearElement } = appState; const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId, isDragging } = const { activePointIndex, elementId, isDragging } = editingLinearElement;
editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return false; return false;
} }
// point that's being dragged (out of all selected points) if (activePointIndex != null && activePointIndex > -1) {
const draggingPoint = element.points[
editingLinearElement.pointerDownState.lastClickedPoint
] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) {
if (isDragging === false) { if (isDragging === false) {
setState({ setState({
editingLinearElement: { editingLinearElement: {
@@ -168,79 +98,18 @@ export class LinearElementEditor {
}); });
} }
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newPoint = 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)) {
const coords: { x: number; y: number }[] = []; maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
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;
} }
@@ -249,79 +118,46 @@ export class LinearElementEditor {
editingLinearElement: LinearElementEditor, editingLinearElement: LinearElementEditor,
appState: AppState, appState: AppState,
): LinearElementEditor { ): LinearElementEditor {
const { elementId, selectedPointsIndices, isDragging, pointerDownState } = const { elementId, activePointIndex, isDragging } = editingLinearElement;
editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return editingLinearElement; return editingLinearElement;
} }
const bindings: Partial< let binding = {};
Pick< if (
InstanceType<typeof LinearElementEditor>, isDragging &&
"startBindingElement" | "endBindingElement" (activePointIndex === 0 || activePointIndex === element.points.length - 1)
> ) {
> = {}; if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoint(
if (isDragging && selectedPointsIndices) { element,
for (const selectedPoint of selectedPointsIndices) { activePointIndex,
if ( activePointIndex === 0
selectedPoint === 0 || ? element.points[element.points.length - 1]
selectedPoint === element.points.length - 1 : element.points[0],
) { );
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,
...bindings, ...binding,
// 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 },
}; };
@@ -371,12 +207,7 @@ export class LinearElementEditor {
setState({ setState({
editingLinearElement: { editingLinearElement: {
...appState.editingLinearElement, ...appState.editingLinearElement,
pointerDownState: { activePointIndex: element.points.length - 1,
prevSelectedPointsIndices:
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: -1,
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null, lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding( endBindingElement: getHoveredElementForBinding(
scenePointer, scenePointer,
@@ -405,8 +236,10 @@ 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 { startBindingElement, endBindingElement } = const {
appState.editingLinearElement; startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
if (isBindingEnabled(appState) && isBindingElement(element)) { if (isBindingEnabled(appState) && isBindingElement(element)) {
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
element, element,
@@ -429,28 +262,10 @@ 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,
pointerDownState: { activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
prevSelectedPointsIndices:
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint pointerOffset: targetPoint
? { ? {
x: scenePointer.x - targetPoint[0], x: scenePointer.x - targetPoint[0],
@@ -480,7 +295,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]); LinearElementEditor.movePoint(element, points.length - 1, "delete");
} }
return { ...editingLinearElement, lastUncommittedPoint: null }; return { ...editingLinearElement, lastUncommittedPoint: null };
} }
@@ -493,14 +308,13 @@ export class LinearElementEditor {
); );
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoint(
{ element,
index: element.points.length - 1, element.points.length - 1,
point: newPoint, newPoint,
}, );
]);
} else { } else {
LinearElementEditor.addPoints(element, [{ point: newPoint }]); LinearElementEditor.movePoint(element, "new", newPoint);
} }
return { return {
@@ -509,21 +323,6 @@ 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>,
) { ) {
@@ -643,122 +442,22 @@ export class LinearElementEditor {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
} }
static duplicateSelectedPoints(appState: AppState) { static movePointByOffset(
if (!appState.editingLinearElement) {
return false;
}
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
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 deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[], pointIndex: number,
offset: { x: number; y: number },
) { ) {
let offsetX = 0; const [x, y] = element.points[pointIndex];
let offsetY = 0; LinearElementEditor.movePoint(element, pointIndex, [
x + offset.x,
const isDeletingOriginPoint = pointIndices.includes(0); y + offset.y,
]);
// 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( static movePoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: Point }[], pointIndex: number | "new",
) { targetPosition: Point | "delete",
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;
@@ -771,50 +470,49 @@ export class LinearElementEditor {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
const selectedOriginPoint = targetPoints.find(({ index }) => index === 0); let nextPoints: (readonly [number, number])[];
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;
if (selectedOriginPoint) { return [point[0] + deltaX, point[1] + deltaY] as const;
offsetX = }
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0]; return offsetX || offsetY
offsetY = ? ([point[0] - offsetX, point[1] - offsetY] as const)
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1]; : point;
});
} }
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,
@@ -822,7 +520,7 @@ export class LinearElementEditor {
); );
const prevCoords = getElementPointsCoords( const prevCoords = getElementPointsCoords(
element, element,
element.points, points,
element.strokeSharpness || "round", element.strokeSharpness || "round",
); );
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@@ -841,13 +539,3 @@ 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;
};
+1 -5
View File
@@ -4,7 +4,6 @@ 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>,
@@ -93,7 +92,6 @@ 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();
@@ -128,14 +126,13 @@ 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, bumping `version`, `versionNonce`, and `updated`. * Mutates element and updates `version` & `versionNonce`.
* *
* NOTE: does not trigger re-render. * NOTE: does not trigger re-render.
*/ */
@@ -145,6 +142,5 @@ 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;
}; };
+36 -89
View File
@@ -11,27 +11,23 @@ import {
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
FontFamilyValues, FontFamilyValues,
ExcalidrawRectangleElement,
} from "../element/types"; } from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement"; import { 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 { getContainerElement, measureText } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { BOUND_TEXT_PADDING } from "../constants";
type ElementConstructorOpts = MarkOptional< type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
| "width" | "width"
| "height" | "height"
| "angle" | "angle"
| "groupIds" | "groupIds"
| "boundElements" | "boundElementIds"
| "seed" | "seed"
| "version" | "version"
| "versionNonce" | "versionNonce"
@@ -54,36 +50,32 @@ const _newElementBase = <T extends ExcalidrawElement>(
angle = 0, angle = 0,
groupIds = [], groupIds = [],
strokeSharpness, strokeSharpness,
boundElements = null, boundElementIds = null,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => ({
const element = { id: rest.id || randomId(),
id: rest.id || randomId(), type,
type, x,
x, y,
y, width,
width, height,
height, angle,
angle, strokeColor,
strokeColor, backgroundColor,
backgroundColor, fillStyle,
fillStyle, strokeWidth,
strokeWidth, strokeStyle,
strokeStyle, roughness,
roughness, opacity,
opacity, groupIds,
groupIds, strokeSharpness,
strokeSharpness, seed: rest.seed ?? randomInteger(),
seed: rest.seed ?? randomInteger(), version: rest.version || 1,
version: rest.version || 1, versionNonce: rest.versionNonce ?? 0,
versionNonce: rest.versionNonce ?? 0, isDeleted: false as false,
isDeleted: false as false, boundElementIds,
boundElements, });
updated: getUpdatedTimestamp(),
};
return element;
};
export const newElement = ( export const newElement = (
opts: { opts: {
@@ -121,7 +113,6 @@ 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));
@@ -139,8 +130,6 @@ 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,
}, },
{}, {},
); );
@@ -157,29 +146,18 @@ const getAdjustedDimensions = (
height: number; height: number;
baseline: number; baseline: number;
} => { } => {
let maxWidth = null;
const container = getContainerElement(element);
if (container) {
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
}
const { const {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseline, baseline: nextBaseline,
} = measureText(nextText, getFontString(element), maxWidth); } = measureText(nextText, getFontString(element));
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
if (
textAlign === "center" && if (textAlign === "center" && verticalAlign === "middle") {
verticalAlign === "middle" && const prevMetrics = measureText(element.text, getFontString(element));
!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,
@@ -216,22 +194,6 @@ const getAdjustedDimensions = (
); );
} }
// make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions
if (isBoundToContainer(element)) {
const container = getContainerElement(element)!;
let height = container.height;
let width = container.width;
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
height = nextHeight + BOUND_TEXT_PADDING * 2;
}
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
width = nextWidth + BOUND_TEXT_PADDING * 2;
}
if (height !== container.height || width !== container.width) {
mutateElement(container, { height, width });
}
}
return { return {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
@@ -243,26 +205,12 @@ 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 },
isSubmit: boolean,
): ExcalidrawTextElement => { ): ExcalidrawTextElement => {
const boundToContainer = isBoundToContainer(element);
// Don't update dimensions and text value for bounded text unless submitted
const dimensions =
boundToContainer && !isSubmit
? undefined
: getAdjustedDimensions(element, text);
return newElementWith(element, { return newElementWith(element, {
text, text,
originalText,
isDeleted: isDeleted ?? element.isDeleted, isDeleted: isDeleted ?? element.isDeleted,
...dimensions, ...getAdjustedDimensions(element, text),
}); });
}; };
@@ -376,7 +324,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
overrides?: Partial<TElement>, overrides?: Partial<TElement>,
): TElement => { ): TElement => {
let copy: TElement = deepCopyElement(element); let copy: TElement = deepCopyElement(element);
if (isTestEnv()) { if (process.env.NODE_ENV === "test") {
copy.id = `${copy.id}_copy`; copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests // `window.h` may not be defined in some unit tests
if ( if (
@@ -389,7 +337,6 @@ 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,
+13 -49
View File
@@ -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 { getFontString } from "../utils"; import { measureText, getFontString } from "../utils";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { import {
TransformHandleType, TransformHandleType,
@@ -33,13 +33,6 @@ 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) {
@@ -139,7 +132,6 @@ export const transformElements = (
pointerX, pointerX,
pointerY, pointerY,
); );
handleBindTextResize(selectedElements, transformHandleType);
return true; return true;
} }
} }
@@ -162,11 +154,6 @@ 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
@@ -285,7 +272,6 @@ 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,
@@ -427,9 +413,6 @@ 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;
@@ -481,20 +464,19 @@ export const resizeSingleElement = (
} }
} }
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = const [
getResizedElementAbsoluteCoords( newBoundsX1,
stateAtResizeStart, newBoundsY1,
eleNewWidth, newBoundsX2,
eleNewHeight, newBoundsY2,
); ] = 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)) {
@@ -587,16 +569,9 @@ 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 > minWidth && resizedElement.width !== 0 &&
resizedElement.height !== 0 && resizedElement.height !== 0 &&
Number.isFinite(resizedElement.x) && Number.isFinite(resizedElement.x) &&
Number.isFinite(resizedElement.y) Number.isFinite(resizedElement.y)
@@ -605,7 +580,6 @@ 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);
} }
}; };
@@ -677,7 +651,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 (isTextElement(element)) { if (element.type === "text") {
const nextFont = measureFontSizeFromWH(element, width, height); const nextFont = measureFontSizeFromWH(element, width, height);
if (nextFont === null) { if (nextFont === null) {
return null; return null;
@@ -758,16 +732,6 @@ 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),
});
}
}); });
}; };
+10 -6
View File
@@ -36,8 +36,10 @@ export const resizeTest = (
return false; return false;
} }
const { rotation: rotationTransformHandle, ...transformHandles } = const {
getTransformHandles(element, zoom, pointerType); rotation: rotationTransformHandle,
...transformHandles
} = getTransformHandles(element, zoom, pointerType);
if ( if (
rotationTransformHandle && rotationTransformHandle &&
@@ -47,8 +49,9 @@ export const resizeTest = (
} }
const filter = Object.keys(transformHandles).filter((key) => { const filter = Object.keys(transformHandles).filter((key) => {
const transformHandle = const transformHandle = transformHandles[
transformHandles[key as Exclude<TransformHandleType, "rotation">]!; key as Exclude<TransformHandleType, "rotation">
]!;
if (!transformHandle) { if (!transformHandle) {
return false; return false;
} }
@@ -102,8 +105,9 @@ export const getTransformHandleTypeFromCoords = (
); );
const found = Object.keys(transformHandles).find((key) => { const found = Object.keys(transformHandles).find((key) => {
const transformHandle = const transformHandle = transformHandles[
transformHandles[key as Exclude<TransformHandleType, "rotation">]!; key as Exclude<TransformHandleType, "rotation">
]!;
return ( return (
transformHandle && transformHandle &&
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY) isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)

Some files were not shown because too many files have changed in this diff Show More