mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-12-28 23:46:31 +01:00
Compare commits
2 Commits
improve_pn
...
random_use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88691b1c3c | ||
|
|
146c510faa |
13
.env
13
.env
@@ -1,8 +1,5 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
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"}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,4 +5,3 @@ package-lock.json
|
||||
firebase/
|
||||
dist/
|
||||
public/workbox
|
||||
src/packages/excalidraw/types
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||
"rules": {
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"no-restricted-globals": "off"
|
||||
"import/no-anonymous-default-export": "off"
|
||||
}
|
||||
}
|
||||
|
||||
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
@@ -10,7 +10,6 @@ updates:
|
||||
- lipis
|
||||
assignees:
|
||||
- lipis
|
||||
open-pull-requests-limit: 20
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/packages/excalidraw/
|
||||
@@ -22,7 +21,6 @@ updates:
|
||||
- ad1992
|
||||
assignees:
|
||||
- ad1992
|
||||
open-pull-requests-limit: 20
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/packages/utils/
|
||||
@@ -34,4 +32,3 @@ updates:
|
||||
- ad1992
|
||||
assignees:
|
||||
- ad1992
|
||||
open-pull-requests-limit: 20
|
||||
|
||||
2
.github/workflows/publish-docker.yml
vendored
2
.github/workflows/publish-docker.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/build-push-action@v2
|
||||
- uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
yarn lint-staged
|
||||
58
package.json
58
package.json
@@ -19,28 +19,25 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/browser-fs-access": "0.21.1",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.15.0",
|
||||
"@testing-library/react": "12.1.2",
|
||||
"@tldraw/vec": "0.1.3",
|
||||
"@types/jest": "27.0.2",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "17.0.34",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@testing-library/jest-dom": "5.11.10",
|
||||
"@testing-library/react": "11.2.6",
|
||||
"@tldraw/vec": "0.0.106",
|
||||
"@types/jest": "26.0.22",
|
||||
"@types/react": "17.0.3",
|
||||
"@types/react-dom": "17.0.3",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.21.1",
|
||||
"clsx": "1.1.1",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.2",
|
||||
"idb-keyval": "6.0.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"i18next-browser-languagedetector": "6.1.0",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.30",
|
||||
"open-color": "1.9.1",
|
||||
"nanoid": "3.1.22",
|
||||
"open-color": "1.8.0",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.0.16",
|
||||
"perfect-freehand": "1.0.15",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
@@ -49,39 +46,39 @@
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.0",
|
||||
"sass": "1.43.4",
|
||||
"roughjs": "4.4.1",
|
||||
"sass": "1.32.10",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.5.2"
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@excalidraw/eslint-config": "1.0.0",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@types/chai": "4.2.22",
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.2",
|
||||
"@types/resize-observer-browser": "0.1.6",
|
||||
"chai": "4.3.4",
|
||||
"@types/pako": "1.0.1",
|
||||
"@types/resize-observer-browser": "0.1.5",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.22.0",
|
||||
"husky": "7.0.4",
|
||||
"firebase-tools": "9.9.0",
|
||||
"husky": "4.3.8",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.0.1",
|
||||
"lint-staged": "10.5.4",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.4.1",
|
||||
"prettier": "2.2.1",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/typescript-estree": "5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"homepage": ".",
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
@@ -100,7 +97,6 @@
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "react-scripts start",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
|
||||
<meta name="theme-color" content="#000" />
|
||||
|
||||
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
|
||||
/>
|
||||
|
||||
<!-- File Handling (https://web.dev/file-handling/) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new-client",
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
|
||||
@@ -15,8 +15,8 @@ const publish = () => {
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,11 +31,9 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||
|
||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||
return (
|
||||
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
||||
!filesToIgnoreRegex.test(file)
|
||||
);
|
||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
||||
});
|
||||
|
||||
if (!excalidrawPackageFiles.length) {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -48,5 +46,6 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
// update readme
|
||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
|
||||
publish();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
const { readdirSync, writeFileSync } = require("fs");
|
||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||
|
||||
const flatten = (object = {}, result = {}, extraKey = "") => {
|
||||
for (const key in object) {
|
||||
if (typeof object[key] !== "object") {
|
||||
result[extraKey + key] = object[key];
|
||||
} else {
|
||||
flatten(object[key], result, `${extraKey}${key}.`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const flatten = (object) =>
|
||||
Object.keys(object).reduce(
|
||||
(initial, current) => ({ ...initial, ...object[current] }),
|
||||
{},
|
||||
);
|
||||
|
||||
const locales = files.filter(
|
||||
(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 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(
|
||||
|
||||
@@ -5,9 +5,7 @@ const THRESSHOLD = 85;
|
||||
const crowdinMap = {
|
||||
"ar-SA": "en-ar",
|
||||
"bg-BG": "en-bg",
|
||||
"bn-BD": "en-bn",
|
||||
"ca-ES": "en-ca",
|
||||
"da-DK": "en-da",
|
||||
"de-DE": "en-de",
|
||||
"el-GR": "en-el",
|
||||
"es-ES": "en-es",
|
||||
@@ -33,14 +31,11 @@ const crowdinMap = {
|
||||
"pt-PT": "en-pt",
|
||||
"ro-RO": "en-ro",
|
||||
"ru-RU": "en-ru",
|
||||
"si-LK": "en-silk",
|
||||
"sk-SK": "en-sk",
|
||||
"sv-SE": "en-sv",
|
||||
"ta-IN": "en-ta",
|
||||
"tr-TR": "en-tr",
|
||||
"uk-UA": "en-uk",
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-HK": "en-zhhk",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "en-cs",
|
||||
@@ -50,10 +45,7 @@ const crowdinMap = {
|
||||
const flags = {
|
||||
"ar-SA": "🇸🇦",
|
||||
"bg-BG": "🇧🇬",
|
||||
"bn-BD": "🇧🇩",
|
||||
"ca-ES": "🏳",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"da-DK": "🇩🇰",
|
||||
"de-DE": "🇩🇪",
|
||||
"el-GR": "🇬🇷",
|
||||
"es-ES": "🇪🇸",
|
||||
@@ -67,9 +59,7 @@ const flags = {
|
||||
"it-IT": "🇮🇹",
|
||||
"ja-JP": "🇯🇵",
|
||||
"kab-KAB": "🏳",
|
||||
"kk-KZ": "🇰🇿",
|
||||
"ko-KR": "🇰🇷",
|
||||
"lv-LV": "🇱🇻",
|
||||
"my-MM": "🇲🇲",
|
||||
"nb-NO": "🇳🇴",
|
||||
"nl-NL": "🇳🇱",
|
||||
@@ -81,24 +71,21 @@ const flags = {
|
||||
"pt-PT": "🇵🇹",
|
||||
"ro-RO": "🇷🇴",
|
||||
"ru-RU": "🇷🇺",
|
||||
"si-LK": "🇱🇰",
|
||||
"sk-SK": "🇸🇰",
|
||||
"sv-SE": "🇸🇪",
|
||||
"ta-IN": "🇮🇳",
|
||||
"tr-TR": "🇹🇷",
|
||||
"uk-UA": "🇺🇦",
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-HK": "🇭🇰",
|
||||
"zh-TW": "🇹🇼",
|
||||
"lv-LV": "🇱🇻",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"kk-KZ": "🇰🇿",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
"ar-SA": "العربية",
|
||||
"bg-BG": "Български",
|
||||
"bn-BD": "Bengali",
|
||||
"ca-ES": "Català",
|
||||
"cs-CZ": "Česky",
|
||||
"da-DK": "Dansk",
|
||||
"de-DE": "Deutsch",
|
||||
"el-GR": "Ελληνικά",
|
||||
"es-ES": "Español",
|
||||
@@ -112,9 +99,7 @@ const languages = {
|
||||
"it-IT": "Italiano",
|
||||
"ja-JP": "日本語",
|
||||
"kab-KAB": "Taqbaylit",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
"ko-KR": "한국어",
|
||||
"lv-LV": "Latviešu",
|
||||
"my-MM": "Burmese",
|
||||
"nb-NO": "Norsk bokmål",
|
||||
"nl-NL": "Nederlands",
|
||||
@@ -126,15 +111,15 @@ const languages = {
|
||||
"pt-PT": "Português",
|
||||
"ro-RO": "Română",
|
||||
"ru-RU": "Русский",
|
||||
"si-LK": "සිංහල",
|
||||
"sk-SK": "Slovenčina",
|
||||
"sv-SE": "Svenska",
|
||||
"ta-IN": "Tamil",
|
||||
"tr-TR": "Türkçe",
|
||||
"uk-UA": "Українська",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-HK": "繁體中文 (香港)",
|
||||
"zh-TW": "繁體中文",
|
||||
"lv-LV": "Latviešu",
|
||||
"cs-CZ": "Česky",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
||||
@@ -25,8 +25,8 @@ const release = async (nextVersion) => {
|
||||
);
|
||||
/* eslint-disable no-console */
|
||||
console.log("Done!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => {
|
||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||
);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,56 +2,22 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState, _, app) => {
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
},
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
return app.library
|
||||
.loadLibrary()
|
||||
.then((items) => {
|
||||
return app.library.saveLibrary([
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
elements: getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
).map(deepCopyElement),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
toastMessage: t("toast.addedToLibrary"),
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
});
|
||||
app.library.loadLibrary().then((items) => {
|
||||
app.library.saveLibrary([
|
||||
...items,
|
||||
selectedElements.map(deepCopyElement),
|
||||
]);
|
||||
});
|
||||
return false;
|
||||
},
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@@ -14,9 +17,6 @@ import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@@ -47,15 +47,13 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
perform: (elements, appState: AppState) => {
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
exportBackground: appState.exportBackground,
|
||||
@@ -67,8 +65,21 @@ export const actionClearCanvas = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.clearReset"))) {
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
|
||||
@@ -9,8 +9,8 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
perform: (elements, appState, _, app) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||
perform: (elements, appState) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -50,13 +50,12 @@ export const actionCopyAsSvg = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
@@ -89,7 +88,6 @@ export const actionCopyAsPng = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
@@ -106,7 +104,7 @@ export const actionCopyAsPng = register({
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
|
||||
@@ -110,8 +110,10 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
}
|
||||
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
let {
|
||||
elements: nextElements,
|
||||
appState: nextAppState,
|
||||
} = deleteSelectedElements(elements, appState);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
|
||||
@@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
perform: async (elements, appState, value, app) => {
|
||||
perform: async (elements, appState, value) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
? await resaveAsImageWithScene(elements, appState)
|
||||
: await saveAsJSON(elements, appState);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -151,11 +151,9 @@ export const actionSaveToActiveFile = register({
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
@@ -172,22 +170,16 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
perform: async (elements, appState, value, app) => {
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
);
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
@@ -210,28 +202,24 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
perform: async (elements, appState, _, app) => {
|
||||
perform: async (elements, appState) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
} = await loadFromJSON(appState, elements);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@ export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const {
|
||||
elementId,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (element) {
|
||||
@@ -46,11 +49,6 @@ export const actionFinalize = register({
|
||||
}
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
if (appState.pendingImageElement) {
|
||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
}
|
||||
@@ -154,7 +152,6 @@ export const actionFinalize = register({
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
};
|
||||
|
||||
@@ -93,13 +93,13 @@ const flipElements = (
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
elements.forEach((element) => {
|
||||
flipElement(element, appState);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
flipElement(elements[i], appState);
|
||||
// If vertical flip, rotate an extra 180
|
||||
if (flipDirection === "vertical") {
|
||||
rotateElement(element, Math.PI);
|
||||
rotateElement(elements[i], Math.PI);
|
||||
}
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
|
||||
@@ -99,8 +99,9 @@ export const actionGroup = register({
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex =
|
||||
updatedElements.lastIndexOf(lastElementInGroup);
|
||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
||||
lastElementInGroup,
|
||||
);
|
||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = updatedElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ArrowheadArrowIcon,
|
||||
ArrowheadBarIcon,
|
||||
ArrowheadDotIcon,
|
||||
ArrowheadTriangleIcon,
|
||||
ArrowheadNoneIcon,
|
||||
EdgeRoundIcon,
|
||||
EdgeSharpIcon,
|
||||
@@ -60,7 +59,6 @@ import {
|
||||
getTargetElements,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { register } from "./register";
|
||||
|
||||
const changeProperty = (
|
||||
@@ -105,13 +103,11 @@ export const actionChangeStrokeColor = register({
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemStrokeColor && {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
return hasStrokeColor(el.type)
|
||||
? newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
})
|
||||
: el;
|
||||
}),
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -739,14 +735,6 @@ export const actionChangeArrowhead = register({
|
||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: (
|
||||
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
|
||||
),
|
||||
keyBinding: "t",
|
||||
},
|
||||
]}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
@@ -789,14 +777,6 @@ export const actionChangeArrowhead = register({
|
||||
keyBinding: "r",
|
||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: (
|
||||
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
|
||||
),
|
||||
keyBinding: "t",
|
||||
},
|
||||
]}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
|
||||
@@ -8,8 +8,18 @@ import {
|
||||
PanelComponentProps,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { AppProps, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
import Library from "../data/library";
|
||||
|
||||
// This is the <App> component, but for now we don't care about anything but its
|
||||
// `canvas` state.
|
||||
type App = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer: () => void;
|
||||
props: AppProps;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
@@ -18,13 +28,13 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
app: AppClassProperties;
|
||||
app: App;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
app: App,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
if (actionResult && "then" in actionResult) {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import Library from "../data/library";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
@@ -16,18 +12,22 @@ export type ActionResult =
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
type AppAPI = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer(): void;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: AppClassProperties,
|
||||
app: AppAPI,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
|
||||
14
src/align.ts
14
src/align.ts
@@ -1,6 +1,13 @@
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||
import { getCommonBounds } from "./element";
|
||||
|
||||
interface Box {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@@ -81,3 +88,8 @@ const calculateTranslation = (
|
||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return { minX, minY, maxX, maxY };
|
||||
};
|
||||
|
||||
157
src/appState.ts
157
src/appState.ts
@@ -79,7 +79,6 @@ export const getDefaultAppState = (): Omit<
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
viewModeEnabled: false,
|
||||
pendingImageElement: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -93,86 +92,78 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
browser: boolean;
|
||||
/** whether to keep when exporting to file/database */
|
||||
export: boolean;
|
||||
/** server (shareLink/collab/...) */
|
||||
server: boolean;
|
||||
},
|
||||
T extends Record<keyof AppState, Values>,
|
||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||
config)({
|
||||
theme: { browser: true, export: false, server: false },
|
||||
collaborators: { browser: false, export: false, server: false },
|
||||
currentChartType: { browser: true, export: false, server: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||
currentItemFontSize: { browser: true, export: false, server: false },
|
||||
currentItemLinearStrokeSharpness: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemOpacity: { browser: true, export: false, server: false },
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
elementLocked: { browser: true, export: false, server: false },
|
||||
elementType: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
exportBackground: { browser: true, export: false, server: false },
|
||||
exportEmbedScene: { browser: true, export: false, server: false },
|
||||
exportScale: { browser: true, export: false, server: false },
|
||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||
fileHandle: { browser: false, export: false, server: false },
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isLibraryOpen: { browser: false, export: false, server: false },
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||
multiElement: { browser: false, export: false, server: false },
|
||||
name: { browser: true, export: false, server: false },
|
||||
offsetLeft: { browser: false, export: false, server: false },
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, export: false, server: false },
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
pasteDialog: { browser: false, export: false, server: false },
|
||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||
resizingElement: { browser: false, export: false, server: false },
|
||||
scrolledOutside: { browser: true, export: false, server: false },
|
||||
scrollX: { browser: true, export: false, server: false },
|
||||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { browser: true, export: false, server: false },
|
||||
selectedGroupIds: { browser: true, export: false, server: false },
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showHelpDialog: { browser: false, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
toastMessage: { browser: false, export: false, server: false },
|
||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||
width: { browser: false, export: false, server: false },
|
||||
zenModeEnabled: { browser: true, export: false, server: false },
|
||||
zoom: { browser: true, export: false, server: false },
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElement: { browser: false, export: false, server: false },
|
||||
T extends Record<keyof AppState, Values>
|
||||
>(
|
||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||
) => config)({
|
||||
theme: { browser: true, export: false },
|
||||
collaborators: { browser: false, export: false },
|
||||
currentChartType: { browser: true, export: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false },
|
||||
currentItemFillStyle: { browser: true, export: false },
|
||||
currentItemFontFamily: { browser: true, export: false },
|
||||
currentItemFontSize: { browser: true, export: false },
|
||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||
currentItemOpacity: { browser: true, export: false },
|
||||
currentItemRoughness: { browser: true, export: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false },
|
||||
currentItemStrokeColor: { browser: true, export: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false },
|
||||
currentItemTextAlign: { browser: true, export: false },
|
||||
cursorButton: { browser: true, export: false },
|
||||
draggingElement: { browser: false, export: false },
|
||||
editingElement: { browser: false, export: false },
|
||||
editingGroupId: { browser: true, export: false },
|
||||
editingLinearElement: { browser: false, export: false },
|
||||
elementLocked: { browser: true, export: false },
|
||||
elementType: { browser: true, export: false },
|
||||
errorMessage: { browser: false, export: false },
|
||||
exportBackground: { browser: true, export: false },
|
||||
exportEmbedScene: { browser: true, export: false },
|
||||
exportScale: { browser: true, export: false },
|
||||
exportWithDarkMode: { browser: true, export: false },
|
||||
fileHandle: { browser: false, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
height: { browser: false, export: false },
|
||||
isBindingEnabled: { browser: false, export: false },
|
||||
isLibraryOpen: { browser: false, export: false },
|
||||
isLoading: { browser: false, export: false },
|
||||
isResizing: { browser: false, export: false },
|
||||
isRotating: { browser: false, export: false },
|
||||
lastPointerDownWith: { browser: true, export: false },
|
||||
multiElement: { browser: false, export: false },
|
||||
name: { browser: true, export: false },
|
||||
offsetLeft: { browser: false, export: false },
|
||||
offsetTop: { browser: false, export: false },
|
||||
openMenu: { browser: true, export: false },
|
||||
openPopup: { browser: false, export: false },
|
||||
pasteDialog: { browser: false, export: false },
|
||||
previousSelectedElementIds: { browser: true, export: false },
|
||||
resizingElement: { browser: false, export: false },
|
||||
scrolledOutside: { browser: true, export: false },
|
||||
scrollX: { browser: true, export: false },
|
||||
scrollY: { browser: true, export: false },
|
||||
selectedElementIds: { browser: true, export: false },
|
||||
selectedGroupIds: { browser: true, export: false },
|
||||
selectionElement: { browser: false, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showHelpDialog: { browser: false, export: false },
|
||||
showStats: { browser: true, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
suggestedBindings: { browser: false, export: false },
|
||||
toastMessage: { browser: false, export: false },
|
||||
viewBackgroundColor: { browser: true, export: true },
|
||||
width: { browser: false, export: false },
|
||||
zenModeEnabled: { browser: true, export: false },
|
||||
zoom: { browser: true, export: false },
|
||||
viewModeEnabled: { browser: false, export: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
ExportType extends "export" | "browser" | "server",
|
||||
>(
|
||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
appState: Partial<AppState>,
|
||||
exportType: ExportType,
|
||||
) => {
|
||||
@@ -185,10 +176,8 @@ const _clearAppStateForStorage = <
|
||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||
if (propConfig?.[exportType]) {
|
||||
const nextValue = appState[key];
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/31445
|
||||
(stateForExport as any)[key] = nextValue;
|
||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||
stateForExport[key] = appState[key];
|
||||
}
|
||||
}
|
||||
return stateForExport;
|
||||
@@ -201,7 +190,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "export");
|
||||
};
|
||||
|
||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "server");
|
||||
};
|
||||
|
||||
@@ -3,22 +3,19 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { AppState } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { EXPORT_DATA_TYPES } from "./constants";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
|
||||
export interface ClipboardData {
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -40,7 +37,7 @@ export const probablySupportsClipboardBlob =
|
||||
|
||||
const clipboardContainsElements = (
|
||||
contents: any,
|
||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||
): contents is { elements: ExcalidrawElement[] } => {
|
||||
if (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
@@ -56,25 +53,17 @@ const clipboardContainsElements = (
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: selectedElements,
|
||||
files: selectedElements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles),
|
||||
elements: getSelectedElements(elements, appState),
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
CLIPBOARD = json;
|
||||
try {
|
||||
PREFER_APP_CLIPBOARD = false;
|
||||
await copyTextToSystemClipboard(json);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
PREFER_APP_CLIPBOARD = true;
|
||||
console.error(error);
|
||||
}
|
||||
@@ -87,7 +76,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
|
||||
|
||||
try {
|
||||
return JSON.parse(CLIPBOARD);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
@@ -149,10 +138,7 @@ export const parseClipboard = async (
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
files: systemClipboardData.files,
|
||||
};
|
||||
return { elements: systemClipboardData.elements };
|
||||
}
|
||||
return appClipboardData;
|
||||
} catch {
|
||||
@@ -167,7 +153,7 @@ export const parseClipboard = async (
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -179,7 +165,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
copied = true;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +205,7 @@ const copyTextViaExecCommand = (text: string) => {
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
success = document.execCommand("copy");
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ import { AppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@@ -49,22 +48,9 @@ export const SelectedShapeActions = ({
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
if (element.type !== commonSelectedType) {
|
||||
commonSelectedType = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
@@ -169,20 +155,18 @@ export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
elementType,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||
: `${index + 1}`;
|
||||
const letter = typeof key === "string" ? key : key[0];
|
||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
@@ -196,16 +180,14 @@ export const ShapesSwitcher = ({
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onChange={({ pointerType }) => {
|
||||
onChange={() => {
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
setAppState({});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,10 +48,6 @@
|
||||
.ToolIcon__label {
|
||||
color: $oc-white;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,15 @@ import "./CheckboxItem.scss";
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
}> = ({ children, checked, onChange, className }) => {
|
||||
}> = ({ children, checked, onChange }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||
className={clsx("Checkbox", { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked);
|
||||
(
|
||||
(event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
) as HTMLButtonElement
|
||||
).focus();
|
||||
((event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
) as HTMLButtonElement).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const toggleDialog = () => {
|
||||
setShowDialog(!showDialog);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
||||
{showDialog && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
onConfirm();
|
||||
toggleDialog();
|
||||
}}
|
||||
onCancel={toggleDialog}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearCanvas;
|
||||
@@ -1,37 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.confirm-dialog {
|
||||
&-buttons {
|
||||
display: flex;
|
||||
padding: 0.2rem 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ToolIcon__icon {
|
||||
min-width: 2.5rem;
|
||||
width: auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ToolIcon_type_button {
|
||||
margin-left: 0.8rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&--confirm.ToolIcon_type_button {
|
||||
background-color: $oc-red-6;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -10,7 +10,7 @@ import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export interface DialogProps {
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
small?: boolean;
|
||||
@@ -18,10 +18,7 @@ export interface DialogProps {
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
theme?: AppState["theme"];
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
export const Dialog = (props: DialogProps) => {
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
@@ -84,7 +81,6 @@ export const Dialog = (props: DialogProps) => {
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
onCloseRequest={onClose}
|
||||
theme={props.theme}
|
||||
closeOnClickOutside={props.closeOnClickOutside}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||
|
||||
@@ -157,8 +157,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
|
||||
@@ -4,23 +4,17 @@ import { getSelectedElements } from "../scene";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface HintViewerProps {
|
||||
interface Hint {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const getHints = ({ appState, elements }: Hint) => {
|
||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (elementType === "arrow" || elementType === "line") {
|
||||
if (!multiMode) {
|
||||
return t("hints.linearElement");
|
||||
@@ -36,12 +30,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (
|
||||
isResizing &&
|
||||
lastPointerDownWith === "mouse" &&
|
||||
@@ -51,9 +40,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle");
|
||||
}
|
||||
return isImageElement(targetElement)
|
||||
? t("hints.resizeImage")
|
||||
: t("hints.resize");
|
||||
return t("hints.resize");
|
||||
}
|
||||
|
||||
if (isRotating && lastPointerDownWith === "mouse") {
|
||||
@@ -77,22 +64,13 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (elementType === "selection" && !selectedElements.length && !isMobile) {
|
||||
return t("hints.canvasPanning");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const HintViewer = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
}: HintViewerProps) => {
|
||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
});
|
||||
if (!hint) {
|
||||
return null;
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
.picker-content {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, auto);
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 0.5rem;
|
||||
border-radius: 4px;
|
||||
:root[dir="rtl"] & {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { clipboard, exportImage } from "./icons";
|
||||
import Stack from "./Stack";
|
||||
@@ -79,7 +79,6 @@ const ExportButton: React.FC<{
|
||||
const ImageExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
@@ -88,7 +87,6 @@ const ImageExportModal = ({
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
@@ -114,25 +112,29 @@ const ImageExportModal = ({
|
||||
if (!previewNode) {
|
||||
return;
|
||||
}
|
||||
exportToCanvas(exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
})
|
||||
.then((canvas) => {
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
return canvasToBlob(canvas).then(() => {
|
||||
renderPreview(canvas, previewNode);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
try {
|
||||
const canvas = exportToCanvas(exportedElements, appState, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
});
|
||||
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
canvasToBlob(canvas)
|
||||
.then(() => {
|
||||
renderPreview(canvas, previewNode);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
}
|
||||
}, [
|
||||
appState,
|
||||
files,
|
||||
exportedElements,
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
@@ -218,7 +220,6 @@ const ImageExportModal = ({
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
@@ -227,7 +228,6 @@ export const ImageExportDialog = ({
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
@@ -258,7 +258,6 @@ export const ImageExportDialog = ({
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
exportPadding={exportPadding}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={onExportToPng}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { AppState, ExportOpts } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
@@ -21,13 +21,11 @@ export type ExportCB = (
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onCloseRequest: () => void;
|
||||
@@ -70,14 +68,12 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
onClick={() => onExportToBackend(elements, appState, canvas)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{exportOpts.renderCustomUI &&
|
||||
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -86,14 +82,12 @@ const JSONExportModal = ({
|
||||
export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
@@ -122,7 +116,6 @@ export const JSONExportDialog = ({
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onCloseRequest={handleClose}
|
||||
exportOpts={exportOpts}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0;
|
||||
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
a {
|
||||
margin-inline-start: auto;
|
||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||
padding-inline-end: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.layer-ui__library-items {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback } from "react";
|
||||
import React, {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
@@ -18,8 +31,10 @@ import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, trash } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
@@ -27,18 +42,17 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@@ -51,10 +65,7 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
@@ -62,13 +73,295 @@ interface LayerUIProps {
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
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,
|
||||
id,
|
||||
}: {
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
theme: AppState["theme"];
|
||||
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]}
|
||||
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,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
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<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = 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) => {
|
||||
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}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
canvas,
|
||||
elements,
|
||||
@@ -88,7 +381,6 @@ const LayerUI = ({
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -101,7 +393,6 @@ const LayerUI = ({
|
||||
<JSONExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
@@ -114,40 +405,33 @@ const LayerUI = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const createExporter =
|
||||
(type: ExportType): ExportCB =>
|
||||
async (exportedElements) => {
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
},
|
||||
)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
) => {
|
||||
const fileHandle = await exportCanvas(type, exportedElements, appState, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
|
||||
if (
|
||||
appState.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
setAppState({ fileHandle });
|
||||
}
|
||||
};
|
||||
if (
|
||||
appState.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
setAppState({ fileHandle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
@@ -181,7 +465,6 @@ const LayerUI = ({
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => (
|
||||
<Section
|
||||
heading="canvasActions"
|
||||
@@ -249,15 +532,12 @@ const LayerUI = ({
|
||||
</Section>
|
||||
);
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ isLibraryOpen: false });
|
||||
}, [setAppState]);
|
||||
const closeLibrary = useCallback(
|
||||
(event) => {
|
||||
setAppState({ isLibraryOpen: false });
|
||||
},
|
||||
[setAppState],
|
||||
);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
@@ -269,7 +549,7 @@ const LayerUI = ({
|
||||
const libraryMenu = appState.isLibraryOpen ? (
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState)}
|
||||
onClose={closeLibrary}
|
||||
onClickOutside={closeLibrary}
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
@@ -277,9 +557,7 @@ const LayerUI = ({
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
appState={appState}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -316,22 +594,13 @@ const LayerUI = ({
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
@@ -401,8 +670,7 @@ const LayerUI = ({
|
||||
{!viewModeEnabled && (
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
zenModeEnabled,
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
@@ -416,8 +684,7 @@ const LayerUI = ({
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
zenModeEnabled,
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
@@ -494,8 +761,6 @@ const LayerUI = ({
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -543,7 +808,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
prev.files === next.files &&
|
||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const LibraryButton: React.FC<{
|
||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||
>
|
||||
<input
|
||||
@@ -38,7 +38,7 @@ export const LibraryButton: React.FC<{
|
||||
}}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="0"
|
||||
aria-keyshortcuts="9"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||
</label>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +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";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
(ref.current.contains(event.target) ||
|
||||
!document.body.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cb(event);
|
||||
};
|
||||
document.addEventListener("pointerdown", listener, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", listener);
|
||||
};
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
appState,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: AppState;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useOnClickOutside(ref, (event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||
|
||||
const [loadingState, setIsLoading] = useState<
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
]).then((data) => {
|
||||
if (data === "loading") {
|
||||
setIsLoading("loading");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
|
||||
const removeFromLibrary = useCallback(async () => {
|
||||
const items = await library.loadLibrary();
|
||||
|
||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setLibraryItems(nextItems);
|
||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"]) => {
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
});
|
||||
}
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
elements,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
const renderPublishSuccess = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||
title={t("publishSuccessDialog.title")}
|
||||
className="publish-library-success"
|
||||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
</a>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
label={t("buttons.close")}
|
||||
onClick={() => setPublishLibSuccess(null)}
|
||||
data-testid="publish-library-success-close"
|
||||
className="publish-library-success-close"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
nextLibItems.forEach((libItem) => {
|
||||
if (selectedItems.includes(libItem.id)) {
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.saveLibrary(nextLibItems);
|
||||
setLibraryItems(nextLibItems);
|
||||
},
|
||||
[
|
||||
setShowPublishLibraryDialog,
|
||||
setPublishLibSuccess,
|
||||
libraryItems,
|
||||
selectedItems,
|
||||
library,
|
||||
],
|
||||
);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||
appState={appState}
|
||||
onSuccess={onPublishLibSuccess}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
|
||||
{loadingState === "loading" ? (
|
||||
<div className="layer-ui__library-message">
|
||||
{t("labels.libraryLoadingMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id) => {
|
||||
if (!selectedItems.includes(id)) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
} else {
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,322 +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";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onToggle,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onToggle: (id: LibraryItem["id"]) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
: t("alerts.resetLibrary");
|
||||
const title = selectedItems.length
|
||||
? t("confirmDialog.removeItemsFromLib")
|
||||
: t("confirmDialog.resetLibrary");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (selectedItems.length) {
|
||||
onRemoveFromLibrary();
|
||||
} else {
|
||||
resetLibrary();
|
||||
}
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||
: libraryItems;
|
||||
const resetLabel = itemsSelected
|
||||
? t("buttons.remove")
|
||||
: t("buttons.resetLibrary");
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{(!itemsSelected || !isMobile) && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportToFileIcon}
|
||||
onClick={async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.loadLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
className="library-actions--export"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
icon={trash}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
className="library-actions--remove"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && !isPublished && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
aria-label={t("buttons.publishLibrary")}
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
className="library-actions--publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
const isPublished = selectedItems.some(
|
||||
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||
);
|
||||
|
||||
const createLibraryItemCompo = (params: {
|
||||
item:
|
||||
| LibraryItem
|
||||
| /* pending library item */ {
|
||||
id: null;
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
}
|
||||
| null;
|
||||
onClick?: () => void;
|
||||
key: string;
|
||||
}) => {
|
||||
return (
|
||||
<Stack.Col key={params.key}>
|
||||
<LibraryUnit
|
||||
elements={params.item?.elements}
|
||||
files={files}
|
||||
isPending={!params.item?.id && !!params.item?.elements}
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||
onToggle={() => {
|
||||
if (params.item?.id) {
|
||||
onToggle(params.item.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibrarySection = (
|
||||
items: (
|
||||
| LibraryItem
|
||||
| /* pending library item */ {
|
||||
id: null;
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
}
|
||||
)[],
|
||||
) => {
|
||||
const _items = items.map((item) => {
|
||||
if (item.id) {
|
||||
return createLibraryItemCompo({
|
||||
item,
|
||||
onClick: () => onInsertShape(item.elements),
|
||||
key: item.id,
|
||||
});
|
||||
}
|
||||
return createLibraryItemCompo({
|
||||
key: "__pending__item__",
|
||||
item,
|
||||
onClick: () => onAddToLibrary(pendingElements),
|
||||
});
|
||||
});
|
||||
|
||||
// ensure we render all empty cells if no items are present
|
||||
let rows = chunk(_items, CELLS_PER_ROW);
|
||||
if (!rows.length) {
|
||||
rows = [[]];
|
||||
}
|
||||
|
||||
return rows.map((rowItems, index, rows) => {
|
||||
if (index === rows.length - 1) {
|
||||
// pad row with empty cells
|
||||
rowItems = rowItems.concat(
|
||||
new Array(CELLS_PER_ROW - rowItems.length)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
return createLibraryItemCompo({
|
||||
key: `empty_${index}`,
|
||||
item: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack.Row align="center" gap={1} key={index}>
|
||||
{rowItems}
|
||||
</Stack.Row>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const publishedItems = libraryItems.filter(
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
const unpublishedItems = [
|
||||
// append pending library item
|
||||
...(pendingElements.length
|
||||
? [{ id: null, elements: pendingElements }]
|
||||
: []),
|
||||
...libraryItems.filter((item) => item.status !== "published"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="library-menu-items-container">
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
gap={1}
|
||||
>
|
||||
<>
|
||||
<div className="separator">{t("labels.personalLib")}</div>
|
||||
{renderLibrarySection(unpublishedItems)}
|
||||
</>
|
||||
|
||||
<>
|
||||
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||
|
||||
{renderLibrarySection(publishedItems)}
|
||||
</>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryMenuItems;
|
||||
@@ -1,5 +1,3 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.library-unit {
|
||||
align-items: center;
|
||||
@@ -9,20 +7,6 @@
|
||||
position: relative;
|
||||
width: 63px;
|
||||
height: 63px; // match width
|
||||
|
||||
&--hover {
|
||||
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
|
||||
border-color: $oc-blue-5;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
|
||||
border-color: $oc-blue-8;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark .library-unit {
|
||||
border-color: rgb(48, 48, 48);
|
||||
}
|
||||
|
||||
.library-unit__dragger {
|
||||
@@ -38,9 +22,9 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.library-unit__checkbox-container,
|
||||
.library-unit__checkbox-container:hover,
|
||||
.library-unit__checkbox-container:active {
|
||||
.library-unit__removeFromLibrary,
|
||||
.library-unit__removeFromLibrary:hover,
|
||||
.library-unit__removeFromLibrary:active {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -48,35 +32,10 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
left: 2rem;
|
||||
bottom: 2rem;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.library-unit__checkbox {
|
||||
position: absolute;
|
||||
left: 2.3rem;
|
||||
bottom: 2.3rem;
|
||||
|
||||
.Checkbox-box {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
margin: 0.5em 0.5em 0.2em 0.2em;
|
||||
background-color: $oc-blue-1;
|
||||
}
|
||||
|
||||
&.Checkbox:hover {
|
||||
.Checkbox-box {
|
||||
background-color: $oc-blue-2;
|
||||
}
|
||||
}
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.library-unit__removeFromLibrary > svg {
|
||||
@@ -84,32 +43,29 @@
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.library-unit__adder {
|
||||
.library-unit__pulse {
|
||||
transform: scale(1);
|
||||
animation: library-unit__adder-animation 1s ease-in infinite;
|
||||
animation: library-unit__pulse-animation 1s ease-in infinite;
|
||||
}
|
||||
|
||||
.library-unit__adder {
|
||||
position: absolute;
|
||||
left: 40%;
|
||||
top: 40%;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.library-unit--hover .library-unit__adder {
|
||||
color: $oc-blue-7;
|
||||
}
|
||||
|
||||
.library-unit__active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes library-unit__adder-animation {
|
||||
@keyframes library-unit__pulse-animation {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
50% {
|
||||
@@ -117,7 +73,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.85);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { close } from "../components/icons";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
// fa-plus
|
||||
const PLUS_ICON = (
|
||||
@@ -19,72 +20,68 @@ const PLUS_ICON = (
|
||||
);
|
||||
|
||||
export const LibraryUnit = ({
|
||||
id,
|
||||
elements,
|
||||
files,
|
||||
isPending,
|
||||
pendingElements,
|
||||
onRemoveFromLibrary,
|
||||
onClick,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
files: BinaryFiles;
|
||||
isPending?: boolean;
|
||||
elements?: LibraryItem;
|
||||
pendingElements?: LibraryItem;
|
||||
onRemoveFromLibrary: () => void;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) {
|
||||
const elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
let svg: SVGSVGElement;
|
||||
const current = ref.current!;
|
||||
|
||||
(async () => {
|
||||
if (!elements) {
|
||||
return;
|
||||
svg = await exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
current!.removeChild(child);
|
||||
}
|
||||
const svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
files,
|
||||
);
|
||||
node.innerHTML = svg.outerHTML;
|
||||
current!.appendChild(svg);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
if (svg) {
|
||||
current.removeChild(svg);
|
||||
}
|
||||
};
|
||||
}, [elements, files]);
|
||||
}, [elements, pendingElements]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const adder = isPending && (
|
||||
|
||||
const adder = (isHovered || isMobile) && pendingElements && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("library-unit", {
|
||||
"library-unit__active": elements,
|
||||
"library-unit--hover": elements && isHovered,
|
||||
"library-unit--selected": selected,
|
||||
"library-unit__active": elements || pendingElements,
|
||||
})}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("library-unit__dragger", {
|
||||
"library-unit__pulse": !!isPending,
|
||||
"library-unit__pulse": !!pendingElements,
|
||||
})}
|
||||
ref={ref}
|
||||
draggable={!!elements}
|
||||
onClick={!!elements || !!isPending ? onClick : undefined}
|
||||
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
||||
onDragStart={(event) => {
|
||||
setIsHovered(false);
|
||||
event.dataTransfer.setData(
|
||||
@@ -94,12 +91,14 @@ export const LibraryUnit = ({
|
||||
}}
|
||||
/>
|
||||
{adder}
|
||||
{id && elements && (isHovered || isMobile || selected) && (
|
||||
<CheckboxItem
|
||||
checked={selected}
|
||||
onChange={() => onToggle(id)}
|
||||
className="library-unit__checkbox"
|
||||
/>
|
||||
{elements && (isHovered || isMobile) && (
|
||||
<button
|
||||
className="library-unit__removeFromLibrary"
|
||||
aria-label={t("labels.removeFromLibrary")}
|
||||
onClick={onRemoveFromLibrary}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,11 +33,6 @@ type MobileMenuProps = {
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@@ -55,8 +50,6 @@ export const MobileMenu = ({
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@@ -72,15 +65,9 @@ export const MobileMenu = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
@@ -92,7 +79,7 @@ export const MobileMenu = ({
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,9 +15,8 @@ export const Modal = (props: {
|
||||
onCloseRequest(): void;
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
closeOnClickOutside?: boolean;
|
||||
}) => {
|
||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||
const { theme = THEME.LIGHT } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
if (!modalRoot) {
|
||||
@@ -40,10 +39,7 @@ export const Modal = (props: {
|
||||
onKeyDown={handleKeydown}
|
||||
aria-labelledby={props.labelledBy}
|
||||
>
|
||||
<div
|
||||
className="Modal__background"
|
||||
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
|
||||
></div>
|
||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
||||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
|
||||
@@ -38,14 +38,10 @@ const ChartPreviewBtn = (props: {
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
svg = await exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
@@ -82,7 +78,7 @@ export const PasteChartDialog = ({
|
||||
appState: AppState;
|
||||
onClose: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
||||
onInsertChart: (elements: LibraryItem) => void;
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
|
||||
@@ -42,7 +42,6 @@ export const ProjectName = (props: Props) => {
|
||||
</label>
|
||||
{props.isNameEditable ? (
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import oc from "open-color";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { exportToBlob } from "../packages/utils";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
|
||||
import { ExportedLibraryData } from "../data/types";
|
||||
|
||||
import "./PublishLibrary.scss";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import SingleLibraryItem from "./SingleLibraryItem";
|
||||
|
||||
interface PublishLibraryDataParams {
|
||||
authorName: string;
|
||||
githubHandle: string;
|
||||
name: string;
|
||||
description: string;
|
||||
twitterHandle: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
|
||||
|
||||
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
|
||||
JSON.stringify(data),
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const importPublishLibDataFromStorage = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const PublishLibrary = ({
|
||||
onClose,
|
||||
libraryItems,
|
||||
appState,
|
||||
onSuccess,
|
||||
onError,
|
||||
updateItemsInStorage,
|
||||
onRemove,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
libraryItems: LibraryItems;
|
||||
appState: AppState;
|
||||
onSuccess: (data: {
|
||||
url: string;
|
||||
authorName: string;
|
||||
items: LibraryItems;
|
||||
}) => void;
|
||||
|
||||
onError: (error: Error) => void;
|
||||
updateItemsInStorage: (items: LibraryItems) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) => {
|
||||
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
|
||||
authorName: "",
|
||||
githubHandle: "",
|
||||
name: "",
|
||||
description: "",
|
||||
twitterHandle: "",
|
||||
website: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const data = importPublishLibDataFromStorage();
|
||||
if (data) {
|
||||
setLibraryData(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
|
||||
libraryItems.slice(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setClonedLibItems(libraryItems.slice());
|
||||
}, [libraryItems]);
|
||||
|
||||
const onInputChange = (event: any) => {
|
||||
setLibraryData({
|
||||
...libraryData,
|
||||
[event.target.name]: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const erroredLibItems: LibraryItem[] = [];
|
||||
let isError = false;
|
||||
clonedLibItems.forEach((libItem) => {
|
||||
let error = "";
|
||||
if (!libItem.name) {
|
||||
error = t("publishDialog.errors.required");
|
||||
isError = true;
|
||||
}
|
||||
erroredLibItems.push({ ...libItem, error });
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
setClonedLibItems(erroredLibItems);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
clonedLibItems.forEach((libItem) => {
|
||||
const boundingBox = getCommonBoundingBox(libItem.elements);
|
||||
const width = boundingBox.maxX - boundingBox.minX + 30;
|
||||
const height = boundingBox.maxY - boundingBox.minY + 30;
|
||||
const offset = {
|
||||
x: prevBoundingBox.maxX - boundingBox.minX,
|
||||
y: prevBoundingBox.maxY - boundingBox.minY,
|
||||
};
|
||||
|
||||
const itemsWithUpdatedCoords = libItem.elements.map((element) => {
|
||||
element = mutateElement(element, {
|
||||
x: element.x + offset.x + 15,
|
||||
y: element.y + offset.y + 15,
|
||||
});
|
||||
return element;
|
||||
});
|
||||
const items = [
|
||||
...itemsWithUpdatedCoords,
|
||||
newElement({
|
||||
type: "rectangle",
|
||||
width,
|
||||
height,
|
||||
x: prevBoundingBox.maxX,
|
||||
y: prevBoundingBox.maxY,
|
||||
strokeColor: "#ced4da",
|
||||
backgroundColor: "transparent",
|
||||
strokeStyle: "solid",
|
||||
opacity: 100,
|
||||
roughness: 0,
|
||||
strokeSharpness: "sharp",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
}),
|
||||
];
|
||||
elements.push(...items);
|
||||
prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
|
||||
});
|
||||
const png = await exportToBlob({
|
||||
elements,
|
||||
mimeType: "image/png",
|
||||
appState: {
|
||||
...appState,
|
||||
viewBackgroundColor: oc.white,
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
});
|
||||
|
||||
const libContent: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 2,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems: clonedLibItems,
|
||||
};
|
||||
const content = JSON.stringify(libContent, null, 2);
|
||||
const lib = new Blob([content], { type: "application/json" });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("excalidrawLib", lib);
|
||||
formData.append("excalidrawPng", png!);
|
||||
formData.append("title", libraryData.name);
|
||||
formData.append("authorName", libraryData.authorName);
|
||||
formData.append("githubHandle", libraryData.githubHandle);
|
||||
formData.append("name", libraryData.name);
|
||||
formData.append("description", libraryData.description);
|
||||
formData.append("twitterHandle", libraryData.twitterHandle);
|
||||
formData.append("website", libraryData.website);
|
||||
|
||||
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
||||
method: "post",
|
||||
body: formData,
|
||||
})
|
||||
.then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
return response.json().then(({ url }) => {
|
||||
// flush data from local storage
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||
onSuccess({
|
||||
url,
|
||||
authorName: libraryData.authorName,
|
||||
items: clonedLibItems,
|
||||
});
|
||||
});
|
||||
}
|
||||
return response
|
||||
.json()
|
||||
.catch(() => {
|
||||
throw new Error(response.statusText || "something went wrong");
|
||||
})
|
||||
.then((error) => {
|
||||
throw new Error(
|
||||
error.message || response.statusText || "something went wrong",
|
||||
);
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
onError(err);
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
onError(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renderLibraryItems = () => {
|
||||
const items: ReactNode[] = [];
|
||||
clonedLibItems.forEach((libItem, index) => {
|
||||
items.push(
|
||||
<div className="single-library-item-wrapper" key={index}>
|
||||
<SingleLibraryItem
|
||||
libItem={libItem}
|
||||
appState={appState}
|
||||
index={index}
|
||||
onChange={(val, index) => {
|
||||
const items = clonedLibItems.slice();
|
||||
items[index].name = val;
|
||||
setClonedLibItems(items);
|
||||
}}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
return <div className="selected-library-items">{items}</div>;
|
||||
};
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
updateItemsInStorage(clonedLibItems);
|
||||
savePublishLibDataToStorage(libraryData);
|
||||
onClose();
|
||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||
|
||||
const shouldRenderForm = !!libraryItems.length;
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onDialogClose}
|
||||
title={t("publishDialog.title")}
|
||||
className="publish-library"
|
||||
>
|
||||
{shouldRenderForm ? (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteDescription.pre")}
|
||||
<a
|
||||
href="https://libraries.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteDescription.link")}
|
||||
</a>{" "}
|
||||
{t("publishDialog.noteDescription.post")}
|
||||
</div>
|
||||
<span className="publish-library-note">
|
||||
{t("publishDialog.noteGuidelines.pre")}
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteGuidelines.link")}
|
||||
</a>
|
||||
{t("publishDialog.noteGuidelines.post")}
|
||||
</span>
|
||||
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteItems")}
|
||||
</div>
|
||||
{renderLibraryItems()}
|
||||
<div className="publish-library__fields">
|
||||
<label>
|
||||
<div>
|
||||
<span>{t("publishDialog.libraryName")}</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
value={libraryData.name}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.libraryName")}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<span>{t("publishDialog.libraryDesc")}</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={4}
|
||||
required
|
||||
value={libraryData.description}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.libraryDesc")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div>
|
||||
<span>{t("publishDialog.authorName")}</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="authorName"
|
||||
required
|
||||
value={libraryData.authorName}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.authorName")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("publishDialog.githubUsername")}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="githubHandle"
|
||||
value={libraryData.githubHandle}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.githubHandle")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("publishDialog.twitterUsername")}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="twitterHandle"
|
||||
value={libraryData.twitterHandle}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.twitterHandle")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("publishDialog.website")}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
pattern="https?://.+"
|
||||
title={t("publishDialog.errors.website")}
|
||||
value={libraryData.website}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.website")}
|
||||
/>
|
||||
</label>
|
||||
<span className="publish-library-note">
|
||||
{t("publishDialog.noteLicense.pre")}
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteLicense.link")}
|
||||
</a>
|
||||
{t("publishDialog.noteLicense.post")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="publish-library__buttons">
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.cancel")}
|
||||
aria-label={t("buttons.cancel")}
|
||||
label={t("buttons.cancel")}
|
||||
onClick={onDialogClose}
|
||||
data-testid="cancel-clear-canvas-button"
|
||||
className="publish-library__buttons--cancel"
|
||||
/>
|
||||
<ToolButton
|
||||
type="submit"
|
||||
title={t("buttons.submit")}
|
||||
aria-label={t("buttons.submit")}
|
||||
label={t("buttons.submit")}
|
||||
className="publish-library__buttons--confirm"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
|
||||
{t("publishDialog.atleastOneLibItem")}
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishLibrary;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,48 +0,0 @@
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
$duration: 1.6s;
|
||||
|
||||
.excalidraw {
|
||||
.Spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
--spinner-color: var(--icon-fill-color);
|
||||
|
||||
svg {
|
||||
animation: rotate $duration linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
circle {
|
||||
stroke: var(--spinner-color);
|
||||
animation: dash $duration linear 0s infinite;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 300;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 150, 300;
|
||||
stroke-dashoffset: -200;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1, 300;
|
||||
stroke-dashoffset: -280;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import "./Spinner.scss";
|
||||
|
||||
const Spinner = ({
|
||||
size = "1em",
|
||||
circleWidth = 8,
|
||||
}: {
|
||||
size?: string | number;
|
||||
circleWidth?: number;
|
||||
}) => {
|
||||
return (
|
||||
<div className="Spinner">
|
||||
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={50 - circleWidth / 2}
|
||||
strokeWidth={circleWidth}
|
||||
fill="none"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
.excalidraw {
|
||||
.TextInput {
|
||||
color: var(--text-primary-color);
|
||||
display: inline-block;
|
||||
border: 1.5px solid var(--button-gray-1);
|
||||
line-height: 1;
|
||||
padding: 0.75rem;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--space-factor);
|
||||
background-color: var(--input-bg-color);
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: var(--input-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { PointerType } from "../element/types";
|
||||
|
||||
export type ToolButtonSize = "small" | "medium";
|
||||
|
||||
@@ -25,19 +22,13 @@ type ToolButtonBaseProps = {
|
||||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type ToolButtonProps =
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "button";
|
||||
children?: React.ReactNode;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "submit";
|
||||
children?: React.ReactNode;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
onClick?(): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "icon";
|
||||
@@ -47,7 +38,7 @@ type ToolButtonProps =
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "radio";
|
||||
checked: boolean;
|
||||
onChange?(data: { pointerType: PointerType | null }): void;
|
||||
onChange?(): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
@@ -56,48 +47,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (ret && "then" in ret) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||
|
||||
if (
|
||||
props.type === "button" ||
|
||||
props.type === "icon" ||
|
||||
props.type === "submit"
|
||||
) {
|
||||
const type = (props.type === "icon" ? "button" : props.type) as
|
||||
| "button"
|
||||
| "submit";
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -117,10 +67,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
ref={innerRef}
|
||||
disabled={isLoading || props.isLoading}
|
||||
>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
@@ -130,13 +79,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
{props.isLoading && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
{props.showAriaLabel && (
|
||||
<div className="ToolIcon__label">
|
||||
{props["aria-label"]} {isLoading && <Spinner />}
|
||||
</div>
|
||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||
)}
|
||||
{props.children}
|
||||
</button>
|
||||
@@ -144,18 +90,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className={clsx("ToolIcon", props.className)}
|
||||
title={props.title}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
lastPointerTypeRef.current = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className={clsx("ToolIcon", props.className)} title={props.title}>
|
||||
<input
|
||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||
type="radio"
|
||||
@@ -164,9 +99,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||
data-testid={props["data-testid"]}
|
||||
id={`${excalId}-${props.id}`}
|
||||
onChange={() => {
|
||||
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||
}}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
font-family: Cascadia;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border-radius: var(--space-factor);
|
||||
@@ -53,16 +54,10 @@
|
||||
}
|
||||
|
||||
.ToolIcon__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--icon-fill-color);
|
||||
font-family: var(--ui-font);
|
||||
margin: 0 0.8em;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.Spinner {
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_size_small .ToolIcon__icon {
|
||||
|
||||
@@ -34,8 +34,10 @@ const updateTooltip = (
|
||||
width: itemWidth,
|
||||
} = item.getBoundingClientRect();
|
||||
|
||||
const { width: labelWidth, height: labelHeight } =
|
||||
tooltip.getBoundingClientRect();
|
||||
const {
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
} = tooltip.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
@@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component<
|
||||
for (const [key, value] of Object.entries({ ...localStorage })) {
|
||||
try {
|
||||
_localStorage[key] = JSON.parse(value);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
_localStorage[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component<
|
||||
)
|
||||
).default;
|
||||
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component<
|
||||
try {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -30,12 +30,8 @@ export const createIcon = (
|
||||
d: string | React.ReactNode,
|
||||
opts: number | Opts = 512,
|
||||
) => {
|
||||
const {
|
||||
width = 512,
|
||||
height = width,
|
||||
mirror,
|
||||
style,
|
||||
} = typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
||||
const { width = 512, height = width, mirror, style } =
|
||||
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -85,7 +81,6 @@ export const clipboard = createIcon(
|
||||
|
||||
export const trash = createIcon(
|
||||
"M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
|
||||
|
||||
{ width: 448, height: 512 },
|
||||
);
|
||||
|
||||
@@ -757,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 }) =>
|
||||
createIcon(
|
||||
<path
|
||||
@@ -883,11 +863,3 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const publishIcon = createIcon(
|
||||
<path
|
||||
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
{ width: 640, height: 512 },
|
||||
);
|
||||
|
||||
@@ -90,12 +90,6 @@ export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
export const MIME_TYPES = {
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
binary: "application/octet-stream",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
@@ -111,7 +105,6 @@ export const STORAGE_KEYS = {
|
||||
} as const;
|
||||
|
||||
// time in milliseconds
|
||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
@@ -161,19 +154,3 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG = 10000;
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER = 1440;
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
MIME_TYPES.png,
|
||||
MIME_TYPES.jpg,
|
||||
MIME_TYPES.svg,
|
||||
MIME_TYPES.gif,
|
||||
] as const;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export const ENCRYPTION_KEY_BITS = 128;
|
||||
|
||||
@@ -517,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 {
|
||||
.App-bottom-bar,
|
||||
.FixedSideContainer,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
--input-border-color: #{$oc-gray-4};
|
||||
--input-border-color: #{$oc-gray-3};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||
@@ -64,7 +64,6 @@
|
||||
--input-label-color: #{$oc-gray-2};
|
||||
--island-bg-color: rgba(30, 30, 30, 0.98);
|
||||
--keybinding-color: #{$oc-gray-6};
|
||||
--link-color: #{$oc-blue-4};
|
||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||
--popup-bg-color: #2c2c2c;
|
||||
--popup-secondary-bg-color: #222;
|
||||
|
||||
160
src/data/blob.ts
160
src/data/blob.ts
@@ -1,17 +1,11 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { EXPORT_DATA_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL } from "../types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { AppState } from "../types";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
@@ -20,22 +14,16 @@ import { ImportedLibraryData } from "./types";
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
let contents: string;
|
||||
|
||||
if (blob.type === MIME_TYPES.png) {
|
||||
if (blob.type === "image/png") {
|
||||
try {
|
||||
return await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).decodePngMetadata(blob);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error.message === "INVALID") {
|
||||
throw new DOMException(
|
||||
t("alerts.imageDoesNotContainScene"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
||||
} else {
|
||||
throw new DOMException(
|
||||
t("alerts.cannotRestoreFromImage"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -52,24 +40,18 @@ const parseFileContents = async (blob: Blob | File) => {
|
||||
};
|
||||
});
|
||||
}
|
||||
if (blob.type === MIME_TYPES.svg) {
|
||||
if (blob.type === "image/svg+xml") {
|
||||
try {
|
||||
return await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).decodeSvgMetadata({
|
||||
svg: contents,
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error.message === "INVALID") {
|
||||
throw new DOMException(
|
||||
t("alerts.imageDoesNotContainScene"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
||||
} else {
|
||||
throw new DOMException(
|
||||
t("alerts.cannotRestoreFromImage"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,13 +70,13 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
name = blob.name || "";
|
||||
}
|
||||
if (/\.(excalidraw|json)$/.test(name)) {
|
||||
return MIME_TYPES.json;
|
||||
return "application/json";
|
||||
} else if (/\.png$/.test(name)) {
|
||||
return MIME_TYPES.png;
|
||||
return "image/png";
|
||||
} else if (/\.jpe?g$/.test(name)) {
|
||||
return MIME_TYPES.jpg;
|
||||
return "image/jpeg";
|
||||
} else if (/\.svg$/.test(name)) {
|
||||
return MIME_TYPES.svg;
|
||||
return "image/svg+xml";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -118,15 +100,6 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
|
||||
export const isSupportedImageFile = (
|
||||
blob: Blob | null | undefined,
|
||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
||||
const { type } = blob || {};
|
||||
return (
|
||||
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
||||
);
|
||||
};
|
||||
|
||||
export const loadFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
@@ -150,14 +123,13 @@ export const loadFromBlob = async (
|
||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
@@ -188,106 +160,8 @@ export const canvasToBlob = async (
|
||||
}
|
||||
resolve(blob);
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** generates SHA-1 digest from supplied file (if not supported, falls back
|
||||
to a 40-char base64 random id) */
|
||||
export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
||||
try {
|
||||
const hashBuffer = await window.crypto.subtle.digest(
|
||||
"SHA-1",
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
|
||||
return nanoid(40) as FileId;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataURL = reader.result as DataURL;
|
||||
resolve(dataURL);
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
||||
const dataIndexStart = dataURL.indexOf(",");
|
||||
const byteString = atob(dataURL.slice(dataIndexStart + 1));
|
||||
const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
|
||||
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
return new File([ab], filename, { type: mimeType });
|
||||
};
|
||||
|
||||
export const resizeImageFile = async (
|
||||
file: File,
|
||||
opts: {
|
||||
/** undefined indicates auto */
|
||||
outputType?: typeof MIME_TYPES["jpg"];
|
||||
maxWidthOrHeight: number;
|
||||
},
|
||||
): Promise<File> => {
|
||||
// SVG files shouldn't a can't be resized
|
||||
if (file.type === MIME_TYPES.svg) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const [pica, imageBlobReduce] = await Promise.all([
|
||||
import("pica").then((res) => res.default),
|
||||
// a wrapper for pica for better API
|
||||
import("image-blob-reduce").then((res) => res.default),
|
||||
]);
|
||||
|
||||
// CRA's minification settings break pica in WebWorkers, so let's disable
|
||||
// them for now
|
||||
// https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
|
||||
const reduce = imageBlobReduce({
|
||||
pica: pica({ features: ["js", "wasm"] }),
|
||||
});
|
||||
|
||||
if (opts.outputType) {
|
||||
const { outputType } = opts;
|
||||
reduce._create_blob = function (env) {
|
||||
return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
|
||||
env.out_blob = blob;
|
||||
return env;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const fileType = file.type;
|
||||
|
||||
if (!isSupportedImageFile(file)) {
|
||||
throw new Error(t("errors.unsupportedFileType"));
|
||||
}
|
||||
|
||||
return new File(
|
||||
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
||||
file.name,
|
||||
{
|
||||
type: fileType,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
||||
return new File([new TextEncoder().encode(SVGString)], filename, {
|
||||
type: MIME_TYPES.svg,
|
||||
}) as File & { type: typeof MIME_TYPES.svg };
|
||||
};
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { deflate, inflate } from "pako";
|
||||
import { encryptData, decryptData } from "./encryption";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// byte (binary) strings
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// fast, Buffer-compatible implem
|
||||
export const toByteString = (
|
||||
data: string | Uint8Array | ArrayBuffer,
|
||||
): Promise<string> => {
|
||||
export const toByteString = (data: string | Uint8Array): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob =
|
||||
typeof data === "string"
|
||||
? new Blob([new TextEncoder().encode(data)])
|
||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
||||
: new Blob([data]);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (!event.target || typeof event.target.result !== "string") {
|
||||
@@ -47,14 +44,12 @@ const byteStringToString = (byteString: string) => {
|
||||
* due to reencoding
|
||||
*/
|
||||
export const stringToBase64 = async (str: string, isByteString = false) => {
|
||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
||||
return isByteString ? btoa(str) : btoa(await toByteString(str));
|
||||
};
|
||||
|
||||
// async to align with stringToBase64
|
||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
||||
return isByteString
|
||||
? window.atob(base64)
|
||||
: byteStringToString(window.atob(base64));
|
||||
return isByteString ? atob(base64) : byteStringToString(atob(base64));
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -85,7 +80,7 @@ export const encode = async ({
|
||||
if (compress !== false) {
|
||||
try {
|
||||
deflated = await toByteString(deflate(text));
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error("encode: cannot deflate", error);
|
||||
}
|
||||
}
|
||||
@@ -119,261 +114,3 @@ export const decode = async (data: EncodedData): Promise<string> => {
|
||||
|
||||
return decoded;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// binary encoding
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type FileEncodingInfo = {
|
||||
/* version 2 is the version we're shipping the initial image support with.
|
||||
version 1 was a PR version that a lot of people were using anyway.
|
||||
Thus, if there are issues we can check whether they're not using the
|
||||
unoffic version */
|
||||
version: 1 | 2;
|
||||
compression: "pako@1" | null;
|
||||
encryption: "AES-GCM" | null;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
const CONCAT_BUFFERS_VERSION = 1;
|
||||
/** how many bytes we use to encode how many bytes the next chunk has.
|
||||
* Corresponds to DataView setter methods (setUint32, setUint16, etc).
|
||||
*
|
||||
* NOTE ! values must not be changed, which would be backwards incompatible !
|
||||
*/
|
||||
const VERSION_DATAVIEW_BYTES = 4;
|
||||
const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
|
||||
|
||||
// getter
|
||||
function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
|
||||
// setter
|
||||
function dataView(
|
||||
buffer: Uint8Array,
|
||||
bytes: 1 | 2 | 4,
|
||||
offset: number,
|
||||
value: number,
|
||||
): Uint8Array;
|
||||
/**
|
||||
* abstraction over DataView that serves as a typed getter/setter in case
|
||||
* you're using constants for the byte size and want to ensure there's no
|
||||
* discrepenancy in the encoding across refactors.
|
||||
*
|
||||
* DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
|
||||
*/
|
||||
function dataView(
|
||||
buffer: Uint8Array,
|
||||
bytes: 1 | 2 | 4,
|
||||
offset: number,
|
||||
value?: number,
|
||||
): Uint8Array | number {
|
||||
if (value != null) {
|
||||
if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
|
||||
throw new Error(
|
||||
`attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
|
||||
);
|
||||
}
|
||||
const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
|
||||
new DataView(buffer.buffer)[method](offset, value);
|
||||
return buffer;
|
||||
}
|
||||
const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
|
||||
return new DataView(buffer.buffer)[method](offset);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resulting concatenated buffer has this format:
|
||||
*
|
||||
* [
|
||||
* VERSION chunk (4 bytes)
|
||||
* LENGTH chunk 1 (4 bytes)
|
||||
* DATA chunk 1 (up to 2^32 bits)
|
||||
* LENGTH chunk 2 (4 bytes)
|
||||
* DATA chunk 2 (up to 2^32 bits)
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
|
||||
*/
|
||||
const concatBuffers = (...buffers: Uint8Array[]) => {
|
||||
const bufferView = new Uint8Array(
|
||||
VERSION_DATAVIEW_BYTES +
|
||||
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
|
||||
buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
|
||||
);
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
// as the first chunk we'll encode the version for backwards compatibility
|
||||
dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
|
||||
cursor += VERSION_DATAVIEW_BYTES;
|
||||
|
||||
for (const buffer of buffers) {
|
||||
dataView(
|
||||
bufferView,
|
||||
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
|
||||
cursor,
|
||||
buffer.byteLength,
|
||||
);
|
||||
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
|
||||
|
||||
bufferView.set(buffer, cursor);
|
||||
cursor += buffer.byteLength;
|
||||
}
|
||||
|
||||
return bufferView;
|
||||
};
|
||||
|
||||
/** can only be used on buffers created via `concatBuffers()` */
|
||||
const splitBuffers = (concatenatedBuffer: Uint8Array) => {
|
||||
const buffers = [];
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
// first chunk is the version (ignored for now)
|
||||
cursor += VERSION_DATAVIEW_BYTES;
|
||||
|
||||
while (true) {
|
||||
const chunkSize = dataView(
|
||||
concatenatedBuffer,
|
||||
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
|
||||
cursor,
|
||||
);
|
||||
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
|
||||
|
||||
buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
|
||||
cursor += chunkSize;
|
||||
if (cursor >= concatenatedBuffer.byteLength) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return buffers;
|
||||
};
|
||||
|
||||
// helpers for (de)compressing data with JSON metadata including encryption
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** @private */
|
||||
const _encryptAndCompress = async (
|
||||
data: Uint8Array | string,
|
||||
encryptionKey: string,
|
||||
) => {
|
||||
const { encryptedBuffer, iv } = await encryptData(
|
||||
encryptionKey,
|
||||
deflate(data),
|
||||
);
|
||||
|
||||
return { iv, buffer: new Uint8Array(encryptedBuffer) };
|
||||
};
|
||||
|
||||
/**
|
||||
* The returned buffer has following format:
|
||||
* `[]` refers to a buffers wrapper (see `concatBuffers`)
|
||||
*
|
||||
* [
|
||||
* encodingMetadataBuffer,
|
||||
* iv,
|
||||
* [
|
||||
* contentsMetadataBuffer
|
||||
* contentsBuffer
|
||||
* ]
|
||||
* ]
|
||||
*/
|
||||
export const compressData = async <T extends Record<string, any> = never>(
|
||||
dataBuffer: Uint8Array,
|
||||
options: {
|
||||
encryptionKey: string;
|
||||
} & ([T] extends [never]
|
||||
? {
|
||||
metadata?: T;
|
||||
}
|
||||
: {
|
||||
metadata: T;
|
||||
}),
|
||||
): Promise<Uint8Array> => {
|
||||
const fileInfo: FileEncodingInfo = {
|
||||
version: 2,
|
||||
compression: "pako@1",
|
||||
encryption: "AES-GCM",
|
||||
};
|
||||
|
||||
const encodingMetadataBuffer = new TextEncoder().encode(
|
||||
JSON.stringify(fileInfo),
|
||||
);
|
||||
|
||||
const contentsMetadataBuffer = new TextEncoder().encode(
|
||||
JSON.stringify(options.metadata || null),
|
||||
);
|
||||
|
||||
const { iv, buffer } = await _encryptAndCompress(
|
||||
concatBuffers(contentsMetadataBuffer, dataBuffer),
|
||||
options.encryptionKey,
|
||||
);
|
||||
|
||||
return concatBuffers(encodingMetadataBuffer, iv, buffer);
|
||||
};
|
||||
|
||||
/** @private */
|
||||
const _decryptAndDecompress = async (
|
||||
iv: Uint8Array,
|
||||
decryptedBuffer: Uint8Array,
|
||||
decryptionKey: string,
|
||||
isCompressed: boolean,
|
||||
) => {
|
||||
decryptedBuffer = new Uint8Array(
|
||||
await decryptData(iv, decryptedBuffer, decryptionKey),
|
||||
);
|
||||
|
||||
if (isCompressed) {
|
||||
return inflate(decryptedBuffer);
|
||||
}
|
||||
|
||||
return decryptedBuffer;
|
||||
};
|
||||
|
||||
export const decompressData = async <T extends Record<string, any>>(
|
||||
bufferView: Uint8Array,
|
||||
options: { decryptionKey: string },
|
||||
) => {
|
||||
// first chunk is encoding metadata (ignored for now)
|
||||
const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
|
||||
|
||||
const encodingMetadata: FileEncodingInfo = JSON.parse(
|
||||
new TextDecoder().decode(encodingMetadataBuffer),
|
||||
);
|
||||
|
||||
try {
|
||||
const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
|
||||
await _decryptAndDecompress(
|
||||
iv,
|
||||
buffer,
|
||||
options.decryptionKey,
|
||||
!!encodingMetadata.compression,
|
||||
),
|
||||
);
|
||||
|
||||
const metadata = JSON.parse(
|
||||
new TextDecoder().decode(contentsMetadataBuffer),
|
||||
) as T;
|
||||
|
||||
return {
|
||||
/** metadata source is always JSON so we can decode it here */
|
||||
metadata,
|
||||
/** data can be anything so the caller must decode it */
|
||||
data: contentsBuffer,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error during decompressing and decrypting the file.`,
|
||||
encodingMetadata,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { ENCRYPTION_KEY_BITS } from "../constants";
|
||||
|
||||
export const IV_LENGTH_BYTES = 12;
|
||||
|
||||
export const createIV = () => {
|
||||
const arr = new Uint8Array(IV_LENGTH_BYTES);
|
||||
return window.crypto.getRandomValues(arr);
|
||||
};
|
||||
|
||||
export const generateEncryptionKey = async <
|
||||
T extends "string" | "cryptoKey" = "string",
|
||||
>(
|
||||
returnAs?: T,
|
||||
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: ENCRYPTION_KEY_BITS,
|
||||
},
|
||||
true, // extractable
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
return (
|
||||
returnAs === "cryptoKey"
|
||||
? key
|
||||
: (await window.crypto.subtle.exportKey("jwk", key)).k
|
||||
) as T extends "cryptoKey" ? CryptoKey : string;
|
||||
};
|
||||
|
||||
export const getCryptoKey = (key: string, usage: KeyUsage) =>
|
||||
window.crypto.subtle.importKey(
|
||||
"jwk",
|
||||
{
|
||||
alg: "A128GCM",
|
||||
ext: true,
|
||||
k: key,
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
kty: "oct",
|
||||
},
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: ENCRYPTION_KEY_BITS,
|
||||
},
|
||||
false, // extractable
|
||||
[usage],
|
||||
);
|
||||
|
||||
export const encryptData = async (
|
||||
key: string | CryptoKey,
|
||||
data: Uint8Array | ArrayBuffer | Blob | File | string,
|
||||
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
|
||||
const importedKey =
|
||||
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
|
||||
const iv = createIV();
|
||||
const buffer: ArrayBuffer | Uint8Array =
|
||||
typeof data === "string"
|
||||
? new TextEncoder().encode(data)
|
||||
: data instanceof Uint8Array
|
||||
? data
|
||||
: data instanceof Blob
|
||||
? await data.arrayBuffer()
|
||||
: data;
|
||||
|
||||
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
||||
// includes checks that the ciphertext has not been modified by an attacker.
|
||||
const encryptedBuffer = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
buffer as ArrayBuffer | Uint8Array,
|
||||
);
|
||||
|
||||
return { encryptedBuffer, iv };
|
||||
};
|
||||
|
||||
export const decryptData = async (
|
||||
iv: Uint8Array,
|
||||
encrypted: Uint8Array | ArrayBuffer,
|
||||
privateKey: string,
|
||||
): Promise<ArrayBuffer> => {
|
||||
const key = await getCryptoKey(privateKey, "decrypt");
|
||||
return window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
encrypted,
|
||||
);
|
||||
};
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
fileSave as _fileSave,
|
||||
FileSystemHandle,
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "browser-fs-access";
|
||||
} from "@dwelle/browser-fs-access";
|
||||
import { EVENT, MIME_TYPES } from "../constants";
|
||||
import { AbortError } from "../errors";
|
||||
import { debounce } from "../utils";
|
||||
|
||||
type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "svg"
|
||||
@@ -18,11 +17,20 @@ type FILE_EXTENSION =
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
||||
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
svg: "image/svg+xml",
|
||||
json: "application/json",
|
||||
excalidraw: MIME_TYPES.excalidraw,
|
||||
excalidrawlib: MIME_TYPES.excalidrawlib,
|
||||
};
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
description: string;
|
||||
description?: string;
|
||||
multiple?: M;
|
||||
}): Promise<
|
||||
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
||||
@@ -33,7 +41,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
: FileWithHandle[];
|
||||
|
||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||
mimeTypes.push(MIME_TYPES[type]);
|
||||
mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
|
||||
|
||||
return mimeTypes;
|
||||
}, [] as string[]);
|
||||
@@ -94,7 +102,7 @@ export const fileSave = (
|
||||
name: string;
|
||||
/** file extension */
|
||||
extension: FILE_EXTENSION;
|
||||
description: string;
|
||||
description?: string;
|
||||
/** existing FileSystemHandle */
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
},
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import extractPngChunks from "png-chunks-extract";
|
||||
import decodePng from "png-chunks-extract";
|
||||
import tEXt from "png-chunk-text";
|
||||
import encodePng from "png-chunks-encode";
|
||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { PngChunk } from "../types";
|
||||
|
||||
export { extractPngChunks };
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PNG
|
||||
@@ -31,9 +28,7 @@ const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
|
||||
export const getTEXtChunk = async (
|
||||
blob: Blob,
|
||||
): Promise<{ keyword: string; text: string } | null> => {
|
||||
const chunks = extractPngChunks(
|
||||
new Uint8Array(await blobToArrayBuffer(blob)),
|
||||
);
|
||||
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
|
||||
const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt");
|
||||
if (metadataChunk) {
|
||||
return tEXt.decode(metadataChunk.data);
|
||||
@@ -41,28 +36,6 @@ export const getTEXtChunk = async (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findPngChunk = (
|
||||
chunks: PngChunk[],
|
||||
name: PngChunk["name"],
|
||||
/** this makes the search stop before IDAT chunk (before which most
|
||||
* metadata chunks reside). This is a perf optim. */
|
||||
breakBeforeIDAT = true,
|
||||
) => {
|
||||
let i = 0;
|
||||
const len = chunks.length;
|
||||
while (i <= len) {
|
||||
const chunk = chunks[i];
|
||||
if (chunk.name === name) {
|
||||
return chunk;
|
||||
}
|
||||
if (breakBeforeIDAT && chunk.name === "IDAT") {
|
||||
return null;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const encodePngMetadata = async ({
|
||||
blob,
|
||||
metadata,
|
||||
@@ -70,9 +43,7 @@ export const encodePngMetadata = async ({
|
||||
blob: Blob;
|
||||
metadata: string;
|
||||
}) => {
|
||||
const chunks = extractPngChunks(
|
||||
new Uint8Array(await blobToArrayBuffer(blob)),
|
||||
);
|
||||
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
|
||||
|
||||
const metadataChunk = tEXt.encode(
|
||||
MIME_TYPES.excalidraw,
|
||||
@@ -86,7 +57,7 @@ export const encodePngMetadata = async ({
|
||||
// insert metadata before last chunk (iEND)
|
||||
chunks.splice(-1, 0, metadataChunk);
|
||||
|
||||
return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
|
||||
return new Blob([encodePng(chunks)], { type: "image/png" });
|
||||
};
|
||||
|
||||
export const decodePngMetadata = async (blob: Blob) => {
|
||||
@@ -105,7 +76,7 @@ export const decodePngMetadata = async (blob: Blob) => {
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
return await decode(encodedData);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
@@ -156,7 +127,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
return await decode(encodedData);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
@@ -19,7 +19,6 @@ export const exportCanvas = async (
|
||||
type: ExportType,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
@@ -38,23 +37,18 @@ export const exportCanvas = async (
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (type === "svg" || type === "clipboard-svg") {
|
||||
const tempSvg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportScale: appState.exportScale,
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
},
|
||||
files,
|
||||
);
|
||||
const tempSvg = await exportToSvg(elements, {
|
||||
exportBackground,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportScale: appState.exportScale,
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
});
|
||||
if (type === "svg") {
|
||||
return await fileSave(
|
||||
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
|
||||
new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
|
||||
{
|
||||
description: "Export to SVG",
|
||||
name,
|
||||
extension: "svg",
|
||||
fileHandle,
|
||||
@@ -66,7 +60,7 @@ export const exportCanvas = async (
|
||||
}
|
||||
}
|
||||
|
||||
const tempCanvas = await exportToCanvas(elements, appState, files, {
|
||||
const tempCanvas = exportToCanvas(elements, appState, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
@@ -82,12 +76,11 @@ export const exportCanvas = async (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).encodePngMetadata({
|
||||
blob,
|
||||
metadata: serializeAsJSON(elements, appState, files, "local"),
|
||||
metadata: serializeAsJSON(elements, appState),
|
||||
});
|
||||
}
|
||||
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
extension: "png",
|
||||
fileHandle,
|
||||
@@ -95,7 +88,7 @@ export const exportCanvas = async (
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
await copyBlobToClipboardAsPng(blob);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fileOpen, fileSave } from "./filesystem";
|
||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||
|
||||
import {
|
||||
@@ -13,50 +13,16 @@ import {
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
/**
|
||||
* Strips out files which are only referenced by deleted elements
|
||||
*/
|
||||
const filterOutDeletedFiles = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const nextFiles: BinaryFiles = {};
|
||||
for (const element of elements) {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
"fileId" in element &&
|
||||
element.fileId &&
|
||||
files[element.fileId]
|
||||
) {
|
||||
nextFiles[element.fileId] = files[element.fileId];
|
||||
}
|
||||
}
|
||||
return nextFiles;
|
||||
};
|
||||
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
type: "local" | "database",
|
||||
): string => {
|
||||
const data: ExportedDataState = {
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
version: 2,
|
||||
source: EXPORT_SOURCE,
|
||||
elements:
|
||||
type === "local"
|
||||
? clearElementsForExport(elements)
|
||||
: clearElementsForDatabase(elements),
|
||||
appState:
|
||||
type === "local"
|
||||
? cleanAppStateForExport(appState)
|
||||
: clearAppStateForDatabase(appState),
|
||||
files:
|
||||
type === "local"
|
||||
? filterOutDeletedFiles(elements, files)
|
||||
: // will be stripped from JSON
|
||||
undefined,
|
||||
elements: clearElementsForExport(elements),
|
||||
appState: cleanAppStateForExport(appState),
|
||||
};
|
||||
|
||||
return JSON.stringify(data, null, 2);
|
||||
@@ -65,9 +31,8 @@ export const serializeAsJSON = (
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
const serialized = serializeAsJSON(elements, appState);
|
||||
const blob = new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
});
|
||||
@@ -91,7 +56,15 @@ export const loadFromJSON = async (
|
||||
description: "Excalidraw files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||
/*
|
||||
extensions: [".json", ".excalidraw", ".png", ".svg"],
|
||||
mimeTypes: [
|
||||
MIME_TYPES.excalidraw,
|
||||
"application/json",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
],
|
||||
*/
|
||||
});
|
||||
return loadFromBlob(blob, localAppState, localElements);
|
||||
};
|
||||
@@ -114,16 +87,17 @@ export const isValidLibrary = (json: any) => {
|
||||
typeof json === "object" &&
|
||||
json &&
|
||||
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 = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 2,
|
||||
version: 1,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems,
|
||||
library: libraryItems,
|
||||
};
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
await fileSave(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements, restoreLibraryItems } from "./restore";
|
||||
import { restoreElements } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import type App from "../components/App";
|
||||
|
||||
@@ -18,16 +18,14 @@ class Library {
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(
|
||||
restoreElements(libraryItem.elements, null),
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
|
||||
return elements.length ? elements : null;
|
||||
};
|
||||
|
||||
/** imports library (currently merges, removing duplicates) */
|
||||
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
||||
async importLibrary(blob: Blob) {
|
||||
const libraryFile = await loadLibraryFromBlob(blob);
|
||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
||||
if (!libraryFile || !libraryFile.library) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,17 +37,17 @@ class Library {
|
||||
targetLibraryItem: LibraryItem,
|
||||
) => {
|
||||
return !existingLibraryItems.find((libraryItem) => {
|
||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||
if (libraryItem.length !== targetLibraryItem.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// detect z-index difference by checking the excalidraw elements
|
||||
// are in order
|
||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||
return libraryItem.every((libItemExcalidrawItem, idx) => {
|
||||
return (
|
||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||
libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
|
||||
libItemExcalidrawItem.versionNonce ===
|
||||
targetLibraryItem.elements[idx].versionNonce
|
||||
targetLibraryItem[idx].versionNonce
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -57,20 +55,15 @@ class Library {
|
||||
|
||||
const existingLibraryItems = await this.loadLibrary();
|
||||
|
||||
const library = libraryFile.libraryItems || libraryFile.library || [];
|
||||
const restoredLibItems = restoreLibraryItems(
|
||||
library,
|
||||
defaultStatus as "published" | "unpublished",
|
||||
);
|
||||
const filteredItems = [];
|
||||
for (const item of restoredLibItems) {
|
||||
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
|
||||
const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
|
||||
const restoredItem = this.restoreLibraryItem(libraryItem);
|
||||
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> => {
|
||||
@@ -97,7 +90,7 @@ class Library {
|
||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
||||
|
||||
resolve(items);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
resolve([]);
|
||||
}
|
||||
@@ -112,7 +105,7 @@ class Library {
|
||||
// immediately
|
||||
this.libraryCache = JSON.parse(serializedItems);
|
||||
await this.app.props.onLibraryChange?.(items);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
this.libraryCache = prevLibraryItems;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { exportCanvas } from ".";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
@@ -7,7 +7,6 @@ import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
export const resaveAsImageWithScene = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
|
||||
|
||||
@@ -27,7 +26,6 @@ export const resaveAsImageWithScene = async (
|
||||
fileHandleType,
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
|
||||
@@ -3,12 +3,7 @@ import {
|
||||
ExcalidrawSelectionElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
LibraryItem,
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getElementMap,
|
||||
@@ -42,7 +37,6 @@ export const AllowedExcalidrawElementTypes: Record<
|
||||
diamond: true,
|
||||
ellipse: true,
|
||||
line: true,
|
||||
image: true,
|
||||
arrow: true,
|
||||
freedraw: true,
|
||||
};
|
||||
@@ -50,7 +44,6 @@ export const AllowedExcalidrawElementTypes: Record<
|
||||
export type RestoredDataState = {
|
||||
elements: ExcalidrawElement[];
|
||||
appState: RestoredAppState;
|
||||
files: BinaryFiles;
|
||||
};
|
||||
|
||||
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
@@ -64,19 +57,16 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends ExcalidrawElement,
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
K extends keyof Omit<
|
||||
Required<T>,
|
||||
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
|
||||
>
|
||||
>(
|
||||
element: Required<T>,
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
// @ts-ignore TS complains here but type checks the call sites fine.
|
||||
keyof K
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||
extra: Pick<T, K>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: extra.type || element.type,
|
||||
type: (extra as Partial<T>).type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
version: element.version || 1,
|
||||
@@ -89,8 +79,8 @@ const restoreElementWithProperties = <
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
x: (extra as Partial<T>).x ?? element.x ?? 0,
|
||||
y: (extra as Partial<T>).y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor,
|
||||
backgroundColor: element.backgroundColor,
|
||||
width: element.width || 0,
|
||||
@@ -103,24 +93,25 @@ const restoreElementWithProperties = <
|
||||
boundElementIds: element.boundElementIds ?? [],
|
||||
};
|
||||
|
||||
return {
|
||||
return ({
|
||||
...base,
|
||||
...getNormalizedDimensions(base),
|
||||
...extra,
|
||||
} as unknown as T;
|
||||
} as unknown) as T;
|
||||
};
|
||||
|
||||
const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
): typeof element | null => {
|
||||
): typeof element => {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
let fontSize = element.fontSize;
|
||||
let fontFamily = element.fontFamily;
|
||||
if ("font" in element) {
|
||||
const [fontPx, _fontFamily]: [string, string] = (
|
||||
element as any
|
||||
).font.split(" ");
|
||||
const [fontPx, _fontFamily]: [
|
||||
string,
|
||||
string,
|
||||
] = (element as any).font.split(" ");
|
||||
fontSize = parseInt(fontPx, 10);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
@@ -140,12 +131,6 @@ const restoreElement = (
|
||||
pressures: element.pressures,
|
||||
});
|
||||
}
|
||||
case "image":
|
||||
return restoreElementWithProperties(element, {
|
||||
status: element.status || "pending",
|
||||
fileId: element.fileId,
|
||||
scale: element.scale || [1, 1],
|
||||
});
|
||||
case "line":
|
||||
// @ts-ignore LEGACY type
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
@@ -209,7 +194,7 @@ export const restoreElements = (
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
let migratedElement: ExcalidrawElement = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.[element.id];
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
@@ -275,33 +260,5 @@ export const restore = (
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements),
|
||||
appState: restoreAppState(data?.appState, localAppState || null),
|
||||
files: data?.files || {},
|
||||
};
|
||||
};
|
||||
|
||||
export const restoreLibraryItems = (
|
||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
||||
defaultStatus: LibraryItem["status"],
|
||||
) => {
|
||||
const restoredItems: LibraryItem[] = [];
|
||||
for (const item of libraryItems) {
|
||||
// migrate older libraries
|
||||
if (Array.isArray(item)) {
|
||||
restoredItems.push({
|
||||
status: defaultStatus,
|
||||
elements: item,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
});
|
||||
} else {
|
||||
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
|
||||
restoredItems.push({
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return restoredItems;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
|
||||
import { AppState, LibraryItems } from "../types";
|
||||
import type { cleanAppStateForExport } from "../appState";
|
||||
|
||||
export interface ExportedDataState {
|
||||
@@ -8,7 +8,6 @@ export interface ExportedDataState {
|
||||
source: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: ReturnType<typeof cleanAppStateForExport>;
|
||||
files: BinaryFiles | undefined;
|
||||
}
|
||||
|
||||
export interface ImportedDataState {
|
||||
@@ -18,18 +17,14 @@ export interface ImportedDataState {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems | LibraryItems_v1;
|
||||
files?: BinaryFiles;
|
||||
libraryItems?: LibraryItems;
|
||||
}
|
||||
|
||||
export interface ExportedLibraryData {
|
||||
type: string;
|
||||
version: 2;
|
||||
version: number;
|
||||
source: string;
|
||||
libraryItems: LibraryItems;
|
||||
library: LibraryItems;
|
||||
}
|
||||
|
||||
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
|
||||
/** @deprecated v1 */
|
||||
library?: LibraryItems;
|
||||
}
|
||||
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
|
||||
|
||||
@@ -137,13 +137,14 @@ export const bindOrUnbindSelectedElements = (
|
||||
const maybeBindBindableElement = (
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
): void => {
|
||||
getElligibleElementsForBindableElementAndWhere(bindableElement).forEach(
|
||||
([linearElement, where]) =>
|
||||
bindOrUnbindLinearElement(
|
||||
linearElement,
|
||||
where === "end" ? "keep" : bindableElement,
|
||||
where === "start" ? "keep" : bindableElement,
|
||||
),
|
||||
getElligibleElementsForBindableElementAndWhere(
|
||||
bindableElement,
|
||||
).forEach(([linearElement, where]) =>
|
||||
bindOrUnbindLinearElement(
|
||||
linearElement,
|
||||
where === "end" ? "keep" : bindableElement,
|
||||
where === "start" ? "keep" : bindableElement,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -292,11 +293,9 @@ export const updateBoundElements = (
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
(
|
||||
Scene.getScene(changedElement)!.getNonDeletedElements(
|
||||
boundElementIds,
|
||||
) as NonDeleted<ExcalidrawLinearElement>[]
|
||||
).forEach((linearElement) => {
|
||||
(Scene.getScene(changedElement)!.getNonDeletedElements(
|
||||
boundElementIds,
|
||||
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
|
||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||
// In case the boundElementIds are stale
|
||||
if (!doesNeedUpdate(linearElement, bindableElement)) {
|
||||
@@ -581,11 +580,9 @@ export const fixBindingsAfterDuplication = (
|
||||
});
|
||||
|
||||
// Update the linear elements
|
||||
(
|
||||
sceneElements.filter(({ id }) =>
|
||||
allBoundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]
|
||||
).forEach((element) => {
|
||||
(sceneElements.filter(({ id }) =>
|
||||
allBoundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]).forEach((element) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDuplication(
|
||||
@@ -645,17 +642,17 @@ export const fixBindingsAfterDeletion = (
|
||||
});
|
||||
}
|
||||
});
|
||||
(
|
||||
sceneElements.filter(({ id }) =>
|
||||
boundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]
|
||||
).forEach((element: ExcalidrawLinearElement) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||
});
|
||||
});
|
||||
(sceneElements.filter(({ id }) =>
|
||||
boundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]).forEach(
|
||||
(element: ExcalidrawLinearElement) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const newBindingAfterDeletion = (
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
@@ -79,7 +78,7 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
// move, bcurveTo, lineTo, and curveTo
|
||||
if (op === "move") {
|
||||
// change starting point
|
||||
currentP = data as unknown as Point;
|
||||
currentP = (data as unknown) as Point;
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
@@ -228,7 +227,7 @@ export const getArrowheadPoints = (
|
||||
const prevOp = ops[index - 1];
|
||||
let p0: Point = [0, 0];
|
||||
if (prevOp.op === "move") {
|
||||
p0 = prevOp.data as unknown as Point;
|
||||
p0 = (prevOp.data as unknown) as Point;
|
||||
} else if (prevOp.op === "bcurveTo") {
|
||||
p0 = [prevOp.data[4], prevOp.data[5]];
|
||||
}
|
||||
@@ -259,7 +258,6 @@ export const getArrowheadPoints = (
|
||||
arrow: 30,
|
||||
bar: 15,
|
||||
dot: 15,
|
||||
triangle: 15,
|
||||
}[arrowhead]; // pixels (will differ for each arrowhead)
|
||||
|
||||
let length = 0;
|
||||
@@ -296,7 +294,6 @@ export const getArrowheadPoints = (
|
||||
const angle = {
|
||||
arrow: 20,
|
||||
bar: 90,
|
||||
triangle: 25,
|
||||
}[arrowhead]; // degrees
|
||||
|
||||
// Return points
|
||||
@@ -514,17 +511,3 @@ export const getClosestElementBounds = (
|
||||
|
||||
return getElementBounds(closestElement);
|
||||
};
|
||||
|
||||
export interface Box {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): Box => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return { minX, minY, maxX, maxY };
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
ExcalidrawEllipseElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
@@ -31,7 +30,6 @@ import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { isImageElement } from "./typeChecks";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@@ -49,7 +47,8 @@ const isElementDraggableFromInside = (
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
|
||||
return isDraggableFromInside;
|
||||
};
|
||||
|
||||
export const hitTest = (
|
||||
@@ -162,7 +161,6 @@ type HitTestArgs = {
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
switch (args.element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
@@ -197,7 +195,6 @@ export const distanceToBindableElement = (
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
@@ -227,8 +224,7 @@ const distanceToRectangle = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawFreeDrawElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@@ -490,7 +486,6 @@ export const determineFocusDistance = (
|
||||
const nabs = Math.abs(n);
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
@@ -521,7 +516,6 @@ export const determineFocusPoint = (
|
||||
let point;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
@@ -571,7 +565,6 @@ const getSortedElementLineIntersections = (
|
||||
let intersections: GA.Point[];
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
const corners = getCorners(element);
|
||||
@@ -605,7 +598,6 @@ const getSortedElementLineIntersections = (
|
||||
const getCorners = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
scale: number = 1,
|
||||
@@ -614,7 +606,6 @@ const getCorners = (
|
||||
const hy = (scale * element.height) / 2;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
@@ -756,7 +747,6 @@ export const findFocusPointForEllipse = (
|
||||
export const findFocusPointForRectangulars = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
@@ -866,7 +856,7 @@ const hitTestRoughShape = (
|
||||
// move, bcurveTo, lineTo, and curveTo
|
||||
if (op === "move") {
|
||||
// change starting point
|
||||
currentP = data as unknown as Point;
|
||||
currentP = (data as unknown) as Point;
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
|
||||
@@ -62,32 +62,25 @@ export const dragNewElement = (
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
isResizeWithSidesSameLength: boolean,
|
||||
isResizeCenterPoint: boolean,
|
||||
) => {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
if (isResizeWithSidesSameLength) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
if (shouldResizeFromCenter) {
|
||||
if (isResizeCenterPoint) {
|
||||
width += width;
|
||||
height += height;
|
||||
newX = originX - width / 2;
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExcalidrawImageElement & related helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { MIME_TYPES, SVG_NS } from "../constants";
|
||||
import { getDataURL } from "../data/blob";
|
||||
import { t } from "../i18n";
|
||||
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
|
||||
import { isInitializedImageElement } from "./typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "./types";
|
||||
|
||||
export const loadHTMLImageElement = (dataURL: DataURL) => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
image.src = dataURL;
|
||||
});
|
||||
};
|
||||
|
||||
/** NOTE: updates cache even if already populated with given image. Thus,
|
||||
* you should filter out the images upstream if you want to optimize this. */
|
||||
export const updateImageCache = async ({
|
||||
fileIds,
|
||||
files,
|
||||
imageCache,
|
||||
}: {
|
||||
fileIds: FileId[];
|
||||
files: BinaryFiles;
|
||||
imageCache: AppClassProperties["imageCache"];
|
||||
}) => {
|
||||
const updatedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
await Promise.all(
|
||||
fileIds.reduce((promises, fileId) => {
|
||||
const fileData = files[fileId as string];
|
||||
if (fileData && !updatedFiles.has(fileId)) {
|
||||
updatedFiles.set(fileId, true);
|
||||
return promises.concat(
|
||||
(async () => {
|
||||
try {
|
||||
if (fileData.mimeType === MIME_TYPES.binary) {
|
||||
throw new Error("Only images can be added to ImageCache");
|
||||
}
|
||||
|
||||
const imagePromise = loadHTMLImageElement(fileData.dataURL);
|
||||
const data = {
|
||||
image: imagePromise,
|
||||
mimeType: fileData.mimeType,
|
||||
} as const;
|
||||
// store the promise immediately to indicate there's an in-progress
|
||||
// initialization
|
||||
imageCache.set(fileId, data);
|
||||
|
||||
const image = await imagePromise;
|
||||
|
||||
imageCache.set(fileId, { ...data, image });
|
||||
} catch (error: any) {
|
||||
erroredFiles.set(fileId, true);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return promises;
|
||||
}, [] as Promise<any>[]),
|
||||
);
|
||||
|
||||
return {
|
||||
imageCache,
|
||||
/** includes errored files because they cache was updated nonetheless */
|
||||
updatedFiles,
|
||||
/** files that failed when creating HTMLImageElement */
|
||||
erroredFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export const getInitializedImageElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) =>
|
||||
elements.filter((element) =>
|
||||
isInitializedImageElement(element),
|
||||
) as InitializedExcalidrawImageElement[];
|
||||
|
||||
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
|
||||
// lower-casing due to XML/HTML convention differences
|
||||
// https://johnresig.com/blog/nodename-case-sensitivity
|
||||
return node?.nodeName.toLowerCase() === "svg";
|
||||
};
|
||||
|
||||
export const normalizeSVG = async (SVGString: string) => {
|
||||
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
|
||||
const svg = doc.querySelector("svg");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode || !isHTMLSVGElement(svg)) {
|
||||
throw new Error(t("errors.invalidSVGString"));
|
||||
} else {
|
||||
if (!svg.hasAttribute("xmlns")) {
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* To improve perf, uses `createImageBitmap` is available. But there are
|
||||
* quality issues across browsers, so don't use this API where quality matters.
|
||||
*/
|
||||
export const speedyImageToCanvas = async (imageFile: Blob | File) => {
|
||||
let imageSrc: HTMLImageElement | ImageBitmap;
|
||||
if (
|
||||
typeof ImageBitmap !== "undefined" &&
|
||||
ImageBitmap.prototype &&
|
||||
ImageBitmap.prototype.close &&
|
||||
window.createImageBitmap
|
||||
) {
|
||||
imageSrc = await window.createImageBitmap(imageFile);
|
||||
} else {
|
||||
imageSrc = await loadHTMLImageElement(await getDataURL(imageFile));
|
||||
}
|
||||
const { width, height } = imageSrc;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.height = height;
|
||||
canvas.width = width;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(imageSrc, 0, 0, width, height);
|
||||
|
||||
if (typeof ImageBitmap !== "undefined" && imageSrc instanceof ImageBitmap) {
|
||||
imageSrc.close();
|
||||
}
|
||||
|
||||
return { canvas, context, width, height };
|
||||
};
|
||||
|
||||
/**
|
||||
* Does its best at figuring out if an image (PNG) has any (semi)transparent
|
||||
* pixels. If not PNG, always returns false.
|
||||
*/
|
||||
export const hasTransparentPixels = async (imageFile: Blob | File) => {
|
||||
if (imageFile.type !== MIME_TYPES.png) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { findPngChunk, extractPngChunks } = await import("../data/image");
|
||||
|
||||
const buffer = await imageFile.arrayBuffer();
|
||||
const chunks = extractPngChunks(new Uint8Array(buffer));
|
||||
|
||||
// early exit if tRNS not found and IHDR states no support for alpha
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const IHDR = findPngChunk(chunks, "IHDR");
|
||||
|
||||
if (
|
||||
IHDR &&
|
||||
IHDR.data[9] !== 4 &&
|
||||
IHDR.data[9] !== 6 &&
|
||||
!findPngChunk(chunks, "tRNS")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// otherwise loop through pixels to check if there's any actually
|
||||
// (semi)transparent pixel
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const { width, height, context } = await speedyImageToCanvas(imageFile);
|
||||
{
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
const len = data.byteLength;
|
||||
let i = 3;
|
||||
while (i <= len) {
|
||||
if (data[i] !== 255) {
|
||||
return true;
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -11,7 +11,6 @@ export {
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
newLinearElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
export {
|
||||
@@ -94,10 +93,6 @@ const _clearElements = (
|
||||
: element,
|
||||
);
|
||||
|
||||
export const clearElementsForDatabase = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForExport = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
@@ -150,8 +150,9 @@ export class LinearElementEditor {
|
||||
)
|
||||
: null;
|
||||
binding = {
|
||||
[activePointIndex === 0 ? "startBindingElement" : "endBindingElement"]:
|
||||
bindingElement,
|
||||
[activePointIndex === 0
|
||||
? "startBindingElement"
|
||||
: "endBindingElement"]: bindingElement,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -235,8 +236,10 @@ export class LinearElementEditor {
|
||||
// from the end points of the `linearElement` - this is to allow disabling
|
||||
// binding (which needs to happen at the point the user finishes moving
|
||||
// the point).
|
||||
const { startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const {
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
|
||||
@@ -17,13 +17,12 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
informMutation = true,
|
||||
): TElement => {
|
||||
) => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fileId } = updates as any;
|
||||
const { points } = updates as any;
|
||||
|
||||
if (typeof points !== "undefined") {
|
||||
updates = { ...getSizeFromPoints(points), ...updates };
|
||||
@@ -34,23 +33,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update because its attrs could have changed
|
||||
// (except for specific keys we handle below)
|
||||
(typeof value !== "object" ||
|
||||
value === null ||
|
||||
key === "groupIds" ||
|
||||
key === "scale")
|
||||
// if object, always update in case its deep prop was mutated
|
||||
(typeof value !== "object" || value === null || key === "groupIds")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "scale") {
|
||||
const prevScale = (element as any)[key];
|
||||
const nextScale = value;
|
||||
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
|
||||
continue;
|
||||
}
|
||||
} else if (key === "points") {
|
||||
if (key === "points") {
|
||||
const prevPoints = (element as any)[key];
|
||||
const nextPoints = value;
|
||||
if (prevPoints.length === nextPoints.length) {
|
||||
@@ -77,14 +66,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return element;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updates.height !== "undefined" ||
|
||||
typeof updates.width !== "undefined" ||
|
||||
typeof fileId != "undefined" ||
|
||||
typeof points !== "undefined"
|
||||
) {
|
||||
invalidateShapeForElement(element);
|
||||
@@ -92,12 +81,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
}
|
||||
|
||||
return element;
|
||||
Scene.getScene(element)?.informMutation();
|
||||
};
|
||||
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
@@ -110,8 +94,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update because its attrs could have changed
|
||||
(typeof value !== "object" || value === null)
|
||||
// if object, always update in case its deep prop was mutated
|
||||
(typeof value !== "object" || value === null || key === "groupIds")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawGenericElement,
|
||||
@@ -249,22 +248,6 @@ export const newLinearElement = (
|
||||
};
|
||||
};
|
||||
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: "pending",
|
||||
fileId: null,
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||
//
|
||||
|
||||
@@ -47,9 +47,9 @@ export const transformElements = (
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
isResizeCenterPoint: boolean,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
centerX: number,
|
||||
@@ -62,7 +62,7 @@ export const transformElements = (
|
||||
element,
|
||||
pointerX,
|
||||
pointerY,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
isRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element);
|
||||
} else if (
|
||||
@@ -76,7 +76,7 @@ export const transformElements = (
|
||||
reshapeSingleTwoPointElement(
|
||||
element,
|
||||
resizeArrowDirection,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
isRotateWithDiscreteAngle,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -90,7 +90,7 @@ export const transformElements = (
|
||||
resizeSingleTextElement(
|
||||
element,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -98,10 +98,10 @@ export const transformElements = (
|
||||
} else if (transformHandleType) {
|
||||
resizeSingleElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldKeepSidesRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -115,7 +115,7 @@ export const transformElements = (
|
||||
selectedElements,
|
||||
pointerX,
|
||||
pointerY,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
isRotateWithDiscreteAngle,
|
||||
centerX,
|
||||
centerY,
|
||||
);
|
||||
@@ -142,13 +142,13 @@ const rotateSingleElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
@@ -187,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
|
||||
export const reshapeSingleTwoPointElement = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
@@ -212,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
|
||||
element.x + element.points[1][0] - rotatedX,
|
||||
element.y + element.points[1][1] - rotatedY,
|
||||
];
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
[width, height] = getPerfectElementSizeWithRotation(
|
||||
element.type,
|
||||
width,
|
||||
@@ -281,28 +281,28 @@ const measureFontSizeFromWH = (
|
||||
|
||||
const getSidesForTransformHandle = (
|
||||
transformHandleType: TransformHandleType,
|
||||
shouldResizeFromCenter: boolean,
|
||||
isResizeFromCenter: boolean,
|
||||
) => {
|
||||
return {
|
||||
n:
|
||||
/^(n|ne|nw)$/.test(transformHandleType) ||
|
||||
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
||||
(isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
||||
s:
|
||||
/^(s|se|sw)$/.test(transformHandleType) ||
|
||||
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
||||
(isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
||||
w:
|
||||
/^(w|nw|sw)$/.test(transformHandleType) ||
|
||||
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
||||
(isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
||||
e:
|
||||
/^(e|ne|se)$/.test(transformHandleType) ||
|
||||
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
||||
(isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
shouldResizeFromCenter: boolean,
|
||||
isResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
@@ -361,7 +361,7 @@ const resizeSingleTextElement = (
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
|
||||
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
|
||||
element.x,
|
||||
element.y,
|
||||
element.angle,
|
||||
@@ -383,10 +383,10 @@ const resizeSingleTextElement = (
|
||||
|
||||
export const resizeSingleElement = (
|
||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
transformHandleDirection: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
isResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
@@ -444,13 +444,13 @@ export const resizeSingleElement = (
|
||||
let eleNewHeight = element.height * scaleY;
|
||||
|
||||
// adjust dimensions for resizing from center
|
||||
if (shouldResizeFromCenter) {
|
||||
if (isResizeFromCenter) {
|
||||
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
||||
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
||||
}
|
||||
|
||||
// adjust dimensions to keep sides ratio
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (shouldKeepSidesRatio) {
|
||||
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
||||
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
||||
if (transformHandleDirection.length === 1) {
|
||||
@@ -464,12 +464,16 @@ export const resizeSingleElement = (
|
||||
}
|
||||
}
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
);
|
||||
const [
|
||||
newBoundsX1,
|
||||
newBoundsY1,
|
||||
newBoundsX2,
|
||||
newBoundsY2,
|
||||
] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
@@ -491,7 +495,7 @@ export const resizeSingleElement = (
|
||||
}
|
||||
|
||||
// Keeps opposite handle fixed during resize
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (shouldKeepSidesRatio) {
|
||||
if (["s", "n"].includes(transformHandleDirection)) {
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
}
|
||||
@@ -519,7 +523,7 @@ export const resizeSingleElement = (
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResizeFromCenter) {
|
||||
if (isResizeFromCenter) {
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
||||
}
|
||||
@@ -554,18 +558,6 @@ export const resizeSingleElement = (
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
if ("scale" in element && "scale" in stateAtResizeStart) {
|
||||
mutateElement(element, {
|
||||
scale: [
|
||||
// defaulting because scaleX/Y can be 0/-0
|
||||
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
|
||||
stateAtResizeStart.scale[0],
|
||||
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
|
||||
stateAtResizeStart.scale[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
@@ -700,13 +692,13 @@ const rotateMultipleElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
) => {
|
||||
let centerAngle =
|
||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ export const resizeTest = (
|
||||
return false;
|
||||
}
|
||||
|
||||
const { rotation: rotationTransformHandle, ...transformHandles } =
|
||||
getTransformHandles(element, zoom, pointerType);
|
||||
const {
|
||||
rotation: rotationTransformHandle,
|
||||
...transformHandles
|
||||
} = getTransformHandles(element, zoom, pointerType);
|
||||
|
||||
if (
|
||||
rotationTransformHandle &&
|
||||
@@ -47,8 +49,9 @@ export const resizeTest = (
|
||||
}
|
||||
|
||||
const filter = Object.keys(transformHandles).filter((key) => {
|
||||
const transformHandle =
|
||||
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
|
||||
const transformHandle = transformHandles[
|
||||
key as Exclude<TransformHandleType, "rotation">
|
||||
]!;
|
||||
if (!transformHandle) {
|
||||
return false;
|
||||
}
|
||||
@@ -102,8 +105,9 @@ export const getTransformHandleTypeFromCoords = (
|
||||
);
|
||||
|
||||
const found = Object.keys(transformHandles).find((key) => {
|
||||
const transformHandle =
|
||||
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
|
||||
const transformHandle = transformHandles[
|
||||
key as Exclude<TransformHandleType, "rotation">
|
||||
]!;
|
||||
return (
|
||||
transformHandle &&
|
||||
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
||||
|
||||
@@ -108,7 +108,6 @@ export const textWysiwyg = ({
|
||||
editable.dataset.type = "wysiwyg";
|
||||
// prevent line wrapping on Safari
|
||||
editable.wrap = "off";
|
||||
editable.classList.add("excalidraw-wysiwyg");
|
||||
|
||||
Object.assign(editable.style, {
|
||||
position: "absolute",
|
||||
|
||||
@@ -17,9 +17,9 @@ export type TransformHandleDirection =
|
||||
export type TransformHandleType = TransformHandleDirection | "rotation";
|
||||
|
||||
export type TransformHandle = [number, number, number, number];
|
||||
export type TransformHandles = Partial<{
|
||||
[T in TransformHandleType]: TransformHandle;
|
||||
}>;
|
||||
export type TransformHandles = Partial<
|
||||
{ [T in TransformHandleType]: TransformHandle }
|
||||
>;
|
||||
export type MaybeTransformHandleType = TransformHandleType | false;
|
||||
|
||||
const transformHandleSizes: { [k in PointerType]: number } = {
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
ExcalidrawImageElement,
|
||||
} from "./types";
|
||||
|
||||
export const isGenericElement = (
|
||||
@@ -21,18 +19,6 @@ export const isGenericElement = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is InitializedExcalidrawImageElement => {
|
||||
return !!element && element.type === "image" && !!element.fileId;
|
||||
};
|
||||
|
||||
export const isImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawImageElement => {
|
||||
return !!element && element.type === "image";
|
||||
};
|
||||
|
||||
export const isTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElement => {
|
||||
|
||||
@@ -63,21 +63,6 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
type: "ellipse";
|
||||
};
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "image";
|
||||
fileId: FileId | null;
|
||||
/** whether respective file is persisted */
|
||||
status: "pending" | "saved" | "error";
|
||||
/** X and Y scale factors <-1, 1>, used for image axis flipping */
|
||||
scale: [number, number];
|
||||
}>;
|
||||
|
||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
ExcalidrawImageElement,
|
||||
"fileId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
@@ -96,11 +81,10 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawFreeDrawElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
isDeleted: false;
|
||||
};
|
||||
|
||||
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
|
||||
@@ -120,8 +104,7 @@ export type ExcalidrawBindableElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawTextElement;
|
||||
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
@@ -129,7 +112,7 @@ export type PointBinding = {
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export type Arrowhead = "arrow" | "bar" | "dot" | "triangle";
|
||||
export type Arrowhead = "arrow" | "bar" | "dot";
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
@@ -150,5 +133,3 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: Point | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// time constants (ms)
|
||||
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
||||
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
|
||||
export const FILE_UPLOAD_TIMEOUT = 300;
|
||||
export const LOAD_IMAGES_TIMEOUT = 500;
|
||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
export const BROADCAST = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
@@ -18,10 +12,3 @@ export enum SCENE {
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
}
|
||||
|
||||
export const FIREBASE_STORAGE_PREFIXES = {
|
||||
shareLinkFiles: `/files/shareLinks`,
|
||||
collabFiles: `/files/rooms`,
|
||||
};
|
||||
|
||||
export const ROOM_ID_BYTES = 10;
|
||||
|
||||
@@ -4,26 +4,20 @@ import { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../element/types";
|
||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
||||
getElementMap,
|
||||
getSceneVersion,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
import {
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
} from "../../utils";
|
||||
import {
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
SCENE,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
decryptAESGEM,
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
SocketUpdateDataSource,
|
||||
@@ -31,9 +25,7 @@ import {
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
loadFromFirebase,
|
||||
saveFilesToFirebase,
|
||||
saveToFirebase,
|
||||
} from "../data/firebase";
|
||||
import {
|
||||
@@ -49,22 +41,7 @@ import { UserIdleState } from "../../types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { isInvisiblySmallElement } from "../../element";
|
||||
import {
|
||||
encodeFilesForUpload,
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { AbortError } from "../../errors";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "../../element/typeChecks";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../data/encryption";
|
||||
import { getRandomUsername } from "@excalidraw/random-username";
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
@@ -85,12 +62,14 @@ export interface CollabAPI {
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
broadcastElements: CollabInstance["broadcastElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
}
|
||||
|
||||
type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
};
|
||||
|
||||
interface Props {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
onRoomClose?: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -103,13 +82,12 @@ export { CollabContext, CollabContextConsumer };
|
||||
|
||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
isCollaborating: boolean = false;
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private socketInitializationTimer?: NodeJS.Timeout;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
|
||||
@@ -123,31 +101,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
activeRoomLink: "",
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
|
||||
},
|
||||
saveFiles: async ({ addedFiles }) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return saveFilesToFirebase({
|
||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||
files: await encodeFilesForUpload({
|
||||
files: addedFiles,
|
||||
encryptionKey: roomKey,
|
||||
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
this.activeIntervalId = null;
|
||||
this.idleTimeoutId = null;
|
||||
@@ -200,14 +153,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
if (
|
||||
this.isCollaborating &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
!isSavedToFirebase(this.portal, syncableElements)
|
||||
) {
|
||||
// this won't run in time if user decides to leave the site, but
|
||||
// the purpose is to run in immediately after user decides to stay
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
|
||||
preventUnload(event);
|
||||
event.preventDefault();
|
||||
// NOTE: modern browsers no longer allow showing a custom message here
|
||||
event.returnValue = "";
|
||||
}
|
||||
|
||||
if (this.isCollaborating || this.portal.roomId) {
|
||||
@@ -224,13 +178,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
|
||||
syncableElements: ExcalidrawElement[] = this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
) => {
|
||||
try {
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -241,30 +195,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
|
||||
this.saveCollabRoomToFirebase();
|
||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
this.destroySocketClient();
|
||||
trackEvent("share", "room closed");
|
||||
|
||||
this.props.onRoomClose?.();
|
||||
|
||||
const elements = this.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,47 +214,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
this.isCollaborating = false;
|
||||
}
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
};
|
||||
|
||||
private fetchImageFilesFromFirebase = async (scene: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
}) => {
|
||||
const unfetchedImages = scene.elements
|
||||
.filter((element) => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
!this.fileManager.isFileHandled(element.fileId) &&
|
||||
!element.isDeleted &&
|
||||
element.status === "saved"
|
||||
);
|
||||
})
|
||||
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
|
||||
|
||||
return await this.fileManager.getFiles(unfetchedImages);
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
) => {
|
||||
try {
|
||||
const decrypted = await decryptData(iv, encryptedData, decryptionKey);
|
||||
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
} catch (error) {
|
||||
window.alert(t("alerts.decryptFailed"));
|
||||
console.error(error);
|
||||
return {
|
||||
type: "INVALID_RESPONSE",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private initializeSocketClient = async (
|
||||
@@ -329,6 +224,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.state.username) {
|
||||
this.updateUsername(getRandomUsername());
|
||||
}
|
||||
|
||||
let roomId;
|
||||
let roomKey;
|
||||
|
||||
@@ -368,17 +267,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
scrollToContent: true,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
const elements = this.excalidrawAPI.getSceneElements();
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
@@ -388,16 +282,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.broadcastElements(elements);
|
||||
|
||||
const syncableElements = this.getSyncableElements(elements);
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_UPDATE message
|
||||
this.socketInitializationTimer = window.setTimeout(() => {
|
||||
this.socketInitializationTimer = setTimeout(() => {
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||
@@ -409,11 +298,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedData = await this.decryptPayload(
|
||||
iv,
|
||||
const decryptedData = await decryptAESGEM(
|
||||
encryptedData,
|
||||
this.portal.roomKey,
|
||||
iv,
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
@@ -441,8 +329,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
const { pointer, button, username, selectedElementIds } =
|
||||
decryptedData.payload;
|
||||
const {
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
} = decryptedData.payload;
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
@@ -498,43 +390,67 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private reconcileElements = (
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ReconciledElements => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const currentElements = this.getSceneElementsIncludingDeleted();
|
||||
// create a map of ids so we don't have to iterate
|
||||
// over the array more than once.
|
||||
const localElementMap = getElementMap(currentElements);
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
const reconciledElements = _reconcileElements(
|
||||
localElements,
|
||||
remoteElements,
|
||||
appState,
|
||||
);
|
||||
// Reconcile
|
||||
const newElements: readonly ExcalidrawElement[] = elements
|
||||
.reduce((elements, element) => {
|
||||
// if the remote element references one that's currently
|
||||
// edited on local, skip it (it'll be added in the next step)
|
||||
if (
|
||||
element.id === appState.editingElement?.id ||
|
||||
element.id === appState.resizingElement?.id ||
|
||||
element.id === appState.draggingElement?.id
|
||||
) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (
|
||||
localElementMap.hasOwnProperty(element.id) &&
|
||||
localElementMap[element.id].version > element.version
|
||||
) {
|
||||
elements.push(localElementMap[element.id]);
|
||||
delete localElementMap[element.id];
|
||||
} else if (
|
||||
localElementMap.hasOwnProperty(element.id) &&
|
||||
localElementMap[element.id].version === element.version &&
|
||||
localElementMap[element.id].versionNonce !== element.versionNonce
|
||||
) {
|
||||
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
||||
if (localElementMap[element.id].versionNonce < element.versionNonce) {
|
||||
elements.push(localElementMap[element.id]);
|
||||
} else {
|
||||
// it should be highly unlikely that the two versionNonces are the same. if we are
|
||||
// really worried about this, we can replace the versionNonce with the socket id.
|
||||
elements.push(element);
|
||||
}
|
||||
delete localElementMap[element.id];
|
||||
} else {
|
||||
elements.push(element);
|
||||
delete localElementMap[element.id];
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as Mutable<typeof elements>)
|
||||
// add local elements that weren't deleted or on remote
|
||||
.concat(...Object.values(localElementMap));
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
// synchronously calls render.
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(reconciledElements),
|
||||
);
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
||||
|
||||
return reconciledElements;
|
||||
return newElements as ReconciledElements;
|
||||
};
|
||||
|
||||
private loadImageFiles = throttle(async () => {
|
||||
const { loadedFiles, erroredFiles } =
|
||||
await this.fetchImageFilesFromFirebase({
|
||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
|
||||
this.excalidrawAPI.addFiles(loadedFiles);
|
||||
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI: this.excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
}, LOAD_IMAGES_TIMEOUT);
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{ init = false }: { init?: boolean } = {},
|
||||
@@ -549,8 +465,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
||||
// right now we think this is the right tradeoff.
|
||||
this.excalidrawAPI.history.clear();
|
||||
|
||||
this.loadImageFiles();
|
||||
};
|
||||
|
||||
private onPointerMove = () => {
|
||||
@@ -607,8 +521,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
this.setState((state) => {
|
||||
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
||||
new Map();
|
||||
const collaborators: InstanceType<
|
||||
typeof CollabWrapper
|
||||
>["collaborators"] = new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
@@ -653,7 +568,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
this.getSyncableElements(elements),
|
||||
false,
|
||||
);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
@@ -662,7 +581,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
|
||||
@@ -677,7 +598,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setState({ modalIsShown: false });
|
||||
};
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
updateUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
@@ -688,12 +609,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
};
|
||||
|
||||
isSyncableElement = (element: ExcalidrawElement) => {
|
||||
return element.isDeleted || !isInvisiblySmallElement(element);
|
||||
};
|
||||
|
||||
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter((element) => this.isSyncableElement(element));
|
||||
elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
|
||||
|
||||
/** PRIVATE. Use `this.getContextValue()` instead. */
|
||||
private contextValue: CollabAPI | null = null;
|
||||
@@ -710,8 +627,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||
this.contextValue.broadcastElements = this.broadcastElements;
|
||||
this.contextValue.fetchImageFilesFromFirebase =
|
||||
this.fetchImageFilesFromFirebase;
|
||||
return this.contextValue;
|
||||
};
|
||||
|
||||
@@ -725,7 +640,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
handleClose={this.handleClose}
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={this.onUsernameChange}
|
||||
onUsernameChange={this.updateUsername}
|
||||
onRoomCreate={this.openPortal}
|
||||
onRoomDestroy={this.closePortal}
|
||||
setErrorMessage={(errorMessage) => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
||||
import {
|
||||
encryptAESGEM,
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
} from "../data";
|
||||
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
|
||||
import { BROADCAST, SCENE } from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { throttle } from "lodash";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../data/encryption";
|
||||
|
||||
class Portal {
|
||||
collab: CollabWrapper;
|
||||
@@ -38,7 +38,9 @@ class Portal {
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
SCENE.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
this.collab.getSyncableElements(
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
@@ -51,7 +53,6 @@ class Portal {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.queueFileUpload.flush();
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.roomId = null;
|
||||
@@ -76,79 +77,36 @@ class Portal {
|
||||
if (this.isOpen()) {
|
||||
const json = JSON.stringify(data);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
const encrypted = await encryptAESGEM(encoded, this.roomKey!);
|
||||
this.socket!.emit(
|
||||
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
|
||||
this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
encrypted.data,
|
||||
encrypted.iv,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queueFileUpload = throttle(async () => {
|
||||
try {
|
||||
await this.collab.fileManager.saveFiles({
|
||||
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
files: this.collab.excalidrawAPI.getFiles(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: this.collab.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||
// this will signal collaborators to pull image data from server
|
||||
// (using mutation instead of newElementWith otherwise it'd break
|
||||
// in-progress dragging)
|
||||
return newElementWith(element, { status: "saved" });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
});
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncableElements: ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
// sync out only the elements we think we need to to save bandwidth.
|
||||
// periodically we'll resync the whole thing to make sure no one diverges
|
||||
// due to a dropped message (server goes down etc).
|
||||
const syncableElements = allElements.reduce(
|
||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
||||
if (
|
||||
(syncAll ||
|
||||
!this.broadcastedElementVersions.has(element.id) ||
|
||||
element.version >
|
||||
this.broadcastedElementVersions.get(element.id)!) &&
|
||||
this.collab.isSyncableElement(element)
|
||||
) {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
parent: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
if (!syncAll) {
|
||||
// sync out only the elements we think we need to to save bandwidth.
|
||||
// periodically we'll resync the whole thing to make sure no one diverges
|
||||
// due to a dropped message (server goes down etc).
|
||||
syncableElements = syncableElements.filter(
|
||||
(syncableElement) =>
|
||||
!this.broadcastedElementVersions.has(syncableElement.id) ||
|
||||
syncableElement.version >
|
||||
this.broadcastedElementVersions.get(syncableElement.id)!,
|
||||
);
|
||||
}
|
||||
|
||||
const data: SocketUpdateDataSource[typeof sceneType] = {
|
||||
type: sceneType,
|
||||
@@ -168,8 +126,6 @@ class Portal {
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
if (syncAll && this.collab.isCollaborating) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
@@ -208,8 +164,8 @@ class Portal {
|
||||
socketId: this.socket.id,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
this.collab.excalidrawAPI.getAppState().selectedElementIds,
|
||||
selectedElementIds: this.collab.excalidrawAPI.getAppState()
|
||||
.selectedElementIds,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
input.RoomDialog-link {
|
||||
.RoomDialog-link {
|
||||
color: var(--text-primary-color);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
@@ -14,6 +14,8 @@
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
padding: 0 0.5rem;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--space-factor);
|
||||
@@ -53,7 +55,10 @@
|
||||
margin-top: 0.5em;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
height: 2.5rem;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.RoomDialog-sessionStartButtonContainer {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user