Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle 0e3a5b2042 feat: cycle through selected elements on cmd/ctrl-click 2021-05-29 22:34:41 +02:00
230 changed files with 5418 additions and 18281 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/ 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_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' REACT_APP_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
View File
@@ -5,4 +5,3 @@ package-lock.json
firebase/ firebase/
dist/ dist/
public/workbox public/workbox
src/packages/excalidraw/types
-2
View File
@@ -5,11 +5,9 @@
.env.test.local .env.test.local
.envrc .envrc
.eslintcache .eslintcache
.history
.idea .idea
.vercel .vercel
.vscode .vscode
.yarn
*.log *.log
*.tgz *.tgz
build build
+1 -1
View File
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
COPY . . COPY . .
RUN yarn build:app:docker RUN yarn build:app:docker
FROM nginx:1.21-alpine FROM nginx:1.17-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html COPY --from=build /opt/node_app/build /usr/share/nginx/html
+1 -3
View File
@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server thats go
The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages. The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages.
> Note: Please ensure that the encryption key is 22 characters long.
## Shape libraries ## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com). Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
#### Requirements #### Requirements
- [Node.js](https://nodejs.org/en/) - [Node.js](https://nodejs.org/en/)
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+) - [Yarn](https://yarnpkg.com/getting-started/install)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
#### Clone the repo #### Clone the repo
-3
View File
@@ -2,8 +2,5 @@
"firestore": { "firestore": {
"rules": "firestore.rules", "rules": "firestore.rules",
"indexes": "firestore.indexes.json" "indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
} }
} }
-12
View File
@@ -1,12 +0,0 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{migrations} {
match /{scenes}/{scene} {
allow get, write: if true;
// redundant, but let's be explicit'
allow list: if false;
}
}
}
}
+2 -9
View File
@@ -23,24 +23,19 @@
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10", "@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.6", "@testing-library/react": "11.2.6",
"@tldraw/vec": "0.0.106",
"@types/jest": "26.0.22", "@types/jest": "26.0.22",
"@types/pica": "5.1.3",
"@types/react": "17.0.3", "@types/react": "17.0.3",
"@types/react-dom": "17.0.3", "@types/react-dom": "17.0.3",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.21.0", "browser-fs-access": "0.16.4",
"clsx": "1.1.1", "clsx": "1.1.1",
"fake-indexeddb": "3.1.3",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.0", "i18next-browser-languagedetector": "6.1.0",
"idb-keyval": "5.1.3",
"image-blob-reduce": "3.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.22", "nanoid": "3.1.22",
"open-color": "1.8.0", "open-color": "1.8.0",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.15", "perfect-freehand": "0.4.7",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
@@ -57,11 +52,9 @@
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.2.22",
"@types/lodash.throttle": "4.1.6", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1", "@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.5", "@types/resize-observer-browser": "0.1.5",
"chai": "4.3.4",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0", "firebase-tools": "9.9.0",
Binary file not shown.
Binary file not shown.
-30
View File
@@ -11,33 +11,3 @@
src: url("Cascadia.woff2"); src: url("Cascadia.woff2");
font-display: swap; font-display: swap;
} }
@font-face {
font-family: "REDACTED_REGULAR";
src: url("redacted-regular.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_SCRIPT_BOLD";
src: url("redacted-script-bold.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_SCRIPT_REGULAR";
src: url("redacted-script-regular.woff2");
font-display: swap;
}
@font-face {
font-family: "Scribble";
src: url("scribble-webfont.woff2");
font-display: swap;
}
@font-face {
font-family: "Blokk";
src: url("BLOKKNeue-Regular.woff2");
font-display: swap;
}
-12
View File
@@ -13,18 +13,6 @@
<meta name="theme-color" content="#000" /> <meta name="theme-color" content="#000" />
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
<meta
http-equiv="origin-trial"
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
/>
<!-- File Handling (https://web.dev/file-handling/) -->
<meta
http-equiv="origin-trial"
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
/>
<!-- General tags --> <!-- General tags -->
<meta <meta
name="description" name="description"
+1 -1
View File
@@ -26,7 +26,7 @@
} }
} }
], ],
"capture_links": "new-client", "capture_links": "new_client",
"share_target": { "share_target": {
"action": "/web-share-target", "action": "/web-share-target",
"method": "POST", "method": "POST",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -4
View File
@@ -31,11 +31,9 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/; const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => { const excalidrawPackageFiles = changedFiles.filter((file) => {
return ( return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
!filesToIgnoreRegex.test(file)
);
}); });
if (!excalidrawPackageFiles.length) { if (!excalidrawPackageFiles.length) {
process.exit(0); process.exit(0);
} }
@@ -48,5 +46,6 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
// update readme // update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8"); const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8"); fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
publish(); publish();
}); });
+1 -4
View File
@@ -38,8 +38,7 @@ const crowdinMap = {
"zh-CN": "en-zhcn", "zh-CN": "en-zhcn",
"zh-TW": "en-zhtw", "zh-TW": "en-zhtw",
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "cs-cz",
"kk-KZ": "en-kk",
}; };
const flags = { const flags = {
@@ -79,7 +78,6 @@ const flags = {
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"lv-LV": "🇱🇻", "lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿", "cs-CZ": "🇨🇿",
"kk-KZ": "🇰🇿",
}; };
const languages = { const languages = {
@@ -119,7 +117,6 @@ const languages = {
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"lv-LV": "Latviešu", "lv-LV": "Latviešu",
"cs-CZ": "Česky", "cs-CZ": "Česky",
"kk-KZ": "Қазақ тілі",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(
-39
View File
@@ -1,39 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateReadme = require("./updateReadme");
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const release = async (nextVersion) => {
try {
updateReadme();
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (e) {
console.error(e);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
release(nextVersion);
-97
View File
@@ -1,97 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const lastVersion = pkg.version;
const existingChangeLog = fs.readFileSync(
`${excalidrawDir}/CHANGELOG.md`,
"utf8",
);
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
const headerForType = {
feat: "Features",
fix: "Fixes",
style: "Styles",
refactor: " Refactor",
perf: "Performance",
build: "Build",
};
const getCommitHashForLastVersion = async () => {
try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
const { stdout } = await exec(
`git log --format=format:"%H" --grep=${commitMessage}`,
);
return stdout;
} catch (e) {
console.error(e);
}
};
const getLibraryCommitsSinceLastRelease = async () => {
const commitHash = await getCommitHashForLastVersion();
const { stdout } = await exec(
`git log --pretty=format:%s ${commitHash}...master`,
);
const commitsSinceLastRelease = stdout.split("\n");
const commitList = {};
supportedTypes.forEach((type) => {
commitList[type] = [];
});
commitsSinceLastRelease.forEach((commit) => {
const indexOfColon = commit.indexOf(":");
const type = commit.slice(0, indexOfColon);
if (!supportedTypes.includes(type)) {
return;
}
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
const messageWithCapitalizeFirst =
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
// return if the changelog already contains the pr number which would happen for package updates
if (existingChangeLog.includes(prNumber)) {
return;
}
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
});
return commitList;
};
const updateChangelog = async (nextVersion) => {
const commitList = await getLibraryCommitsSinceLastRelease();
let changelogForLibrary =
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
supportedTypes.forEach((type) => {
if (commitList[type].length) {
changelogForLibrary += `### ${headerForType[type]}\n\n`;
const commits = commitList[type];
commits.forEach((commit) => {
changelogForLibrary += `- ${commit}\n\n`;
});
}
});
changelogForLibrary += "---\n";
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
let updatedContent =
existingChangeLog.slice(0, lastVersionIndex) +
changelogForLibrary +
existingChangeLog.slice(lastVersionIndex);
const currentDate = new Date().toISOString().slice(0, 10);
const newVersion = `## ${nextVersion} (${currentDate})`;
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
};
module.exports = updateChangelog;
-27
View File
@@ -1,27 +0,0 @@
const fs = require("fs");
const updateReadme = () => {
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
// remove note for unstable release
data = data.replace(
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
"",
);
// replace "excalidraw-next" with "excalidraw"
data = data.replace(/excalidraw-next/g, "excalidraw");
data = data.trim();
const demoIndex = data.indexOf("### Demo");
const excalidrawNextNote =
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
// Add excalidraw next note to try out for unreleased changes
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
module.exports = updateReadme;
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { alignElements, Alignment } from "../align"; import { alignElements, Alignment } from "../align";
import { import {
AlignBottomIcon, AlignBottomIcon,
+35 -32
View File
@@ -1,11 +1,15 @@
import React from "react";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { zoomIn, zoomOut } from "../components/icons"; import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants"; import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene"; import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
@@ -13,10 +17,6 @@ import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; 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({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -34,9 +34,9 @@ export const actionChangeViewBackgroundColor = register({
type="canvasBackground" type="canvasBackground"
color={appState.viewBackgroundColor} color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })} onChange={(color) => updateData({ viewBackgroundColor: color })}
isActive={appState.openPopup === "canvasColorPicker"} isActive={appState.openMenu === "canvasColorPicker"}
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "canvasColorPicker" : null }) updateData({ openMenu: active ? "canvasColorPicker" : null })
} }
data-testid="canvas-background-picker" data-testid="canvas-background-picker"
/> />
@@ -47,15 +47,13 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
perform: (elements, appState, _, app) => { perform: (elements, appState: AppState) => {
app.imageCache.clear();
return { return {
elements: elements.map((element) => elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }), newElementWith(element, { isDeleted: true }),
), ),
appState: { appState: {
...getDefaultAppState(), ...getDefaultAppState(),
files: {},
theme: appState.theme, theme: appState.theme,
elementLocked: appState.elementLocked, elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
@@ -67,8 +65,21 @@ export const actionClearCanvas = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={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({ export const actionZoomIn = register({
@@ -97,7 +108,6 @@ export const actionZoomIn = register({
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@@ -132,7 +142,6 @@ export const actionZoomOut = register({
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@@ -159,21 +168,16 @@ export const actionResetZoom = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData }) => (
<Tooltip label={t("buttons.resetZoom")}> <ToolButton
<ToolButton type="button"
type="button" icon={resetZoom}
className="reset-zoom-button" title={t("buttons.resetZoom")}
title={t("buttons.resetZoom")} aria-label={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")} onClick={() => {
onClick={() => { updateData(null);
updateData(null); }}
}} />
size="small"
>
{(appState.zoom.value * 100).toFixed(0)}%
</ToolButton>
</Tooltip>
), ),
keyTest: (event) => keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) && (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
@@ -267,8 +271,7 @@ export const actionToggleTheme = register({
return { return {
appState: { appState: {
...appState, ...appState,
theme: theme: value || (appState.theme === "light" ? "dark" : "light"),
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
commitToHistory: false, commitToHistory: false,
}; };
+2 -4
View File
@@ -9,8 +9,8 @@ import { t } from "../i18n";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
perform: (elements, appState, _, app) => { perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files); copyToClipboard(getNonDeletedElements(elements), appState);
return { return {
commitToHistory: false, commitToHistory: false,
@@ -50,7 +50,6 @@ export const actionCopyAsSvg = register({
? selectedElements ? selectedElements
: getNonDeletedElements(elements), : getNonDeletedElements(elements),
appState, appState,
app.files,
appState, appState,
); );
return { return {
@@ -89,7 +88,6 @@ export const actionCopyAsPng = register({
? selectedElements ? selectedElements
: getNonDeletedElements(elements), : getNonDeletedElements(elements),
appState, appState,
app.files,
appState, appState,
); );
return { return {
+1
View File
@@ -1,6 +1,7 @@
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import React from "react";
import { trash } from "../components/icons"; import { trash } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { import {
DistributeHorizontallyIcon, DistributeHorizontallyIcon,
DistributeVerticallyIcon, DistributeVerticallyIcon,
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
+27 -89
View File
@@ -1,25 +1,18 @@
import React from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons"; import { load, questionCircle, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { supported as fsSupported } from "browser-fs-access";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
@@ -39,54 +32,6 @@ export const actionChangeProjectName = register({
), ),
}); });
export const actionChangeExportScale = register({
name: "changeExportScale",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
const elements = getNonDeletedElements(allElements);
const exportSelected = isSomeElementSelected(elements, appState);
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
: elements;
return (
<>
{EXPORT_SCALES.map((s) => {
const [width, height] = getExportSize(
exportedElements,
DEFAULT_EXPORT_PADDING,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="small"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === appState.exportScale}
onChange={() => updateData(s)}
/>
);
})}
</>
);
},
});
export const actionChangeExportBackground = register({ export const actionChangeExportBackground = register({
name: "changeExportBackground", name: "changeExportBackground",
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
@@ -120,7 +65,7 @@ export const actionChangeExportEmbedScene = register({
> >
{t("labels.exportEmbedScene")} {t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}> <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div> <div className="Tooltip-icon">{questionCircle}</div>
</Tooltip> </Tooltip>
</CheckboxItem> </CheckboxItem>
), ),
@@ -128,21 +73,17 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle; const fileHandleExists = !!appState.fileHandle;
try { try {
const { fileHandle } = isImageFileHandle(appState.fileHandle) const { fileHandle } = await saveAsJSON(elements, appState);
? await resaveAsImageWithScene(elements, appState, app.files)
: await saveAsJSON(elements, appState, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
appState: { appState: {
...appState, ...appState,
fileHandle, fileHandle,
toastMessage: fileHandleExists toastMessage: fileHandleExists
? fileHandle?.name ? fileHandle.name
? t("toast.fileSavedToFilename").replace( ? t("toast.fileSavedToFilename").replace(
"{filename}", "{filename}",
`"${fileHandle.name}"`, `"${fileHandle.name}"`,
@@ -160,26 +101,26 @@ export const actionSaveToActiveFile = register({
}, },
keyTest: (event) => keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey, event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData }) => (
<ActiveFile <ToolButton
onSave={() => updateData(null)} type="icon"
fileName={appState.fileHandle?.name} icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={() => updateData(null)}
data-testid="save-button"
/> />
), ),
}); });
export const actionSaveFileToDisk = register({ export const actionSaveFileToDisk = register({
name: "saveFileToDisk", name: "saveFileToDisk",
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value) => {
try { try {
const { fileHandle } = await saveAsJSON( const { fileHandle } = await saveAsJSON(elements, {
elements, ...appState,
{ fileHandle: null,
...appState, });
fileHandle: null,
},
app.files,
);
return { commitToHistory: false, appState: { ...appState, fileHandle } }; return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
@@ -197,7 +138,7 @@ export const actionSaveFileToDisk = register({
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported} hidden={!fsSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
/> />
@@ -206,17 +147,15 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({ export const actionLoadScene = register({
name: "loadScene", name: "loadScene",
perform: async (elements, appState, _, app) => { perform: async (elements, appState) => {
try { try {
const { const {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, } = await loadFromJSON(appState);
} = await loadFromJSON(appState, elements);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files,
commitToHistory: true, commitToHistory: true,
}; };
} catch (error) { } catch (error) {
@@ -226,7 +165,6 @@ export const actionLoadScene = register({
return { return {
elements, elements,
appState: { ...appState, errorMessage: error.message }, appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false, commitToHistory: false,
}; };
} }
@@ -263,9 +201,9 @@ export const actionExportWithDarkMode = register({
}} }}
> >
<DarkModeToggle <DarkModeToggle
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT} value={appState.exportWithDarkMode ? "dark" : "light"}
onChange={(theme: Theme) => { onChange={(theme: Appearence) => {
updateData(theme === THEME.DARK); updateData(theme === "dark");
}} }}
title={t("labels.toggleExportColorScheme")} title={t("labels.toggleExportColorScheme")}
/> />
+1 -6
View File
@@ -1,6 +1,7 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element"; import { isInvisiblySmallElement } from "../element";
import { resetCursor } from "../utils"; import { resetCursor } from "../utils";
import React from "react";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons"; import { done } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -49,11 +50,6 @@ export const actionFinalize = register({
} }
let newElements = elements; let newElements = elements;
if (appState.pendingImageElement) {
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
@@ -157,7 +153,6 @@ export const actionFinalize = register({
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
pendingImageElement: null,
}, },
commitToHistory: appState.elementType === "freedraw", commitToHistory: appState.elementType === "freedraw",
}; };
+4 -4
View File
@@ -93,13 +93,13 @@ const flipElements = (
appState: AppState, appState: AppState,
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
elements.forEach((element) => { for (let i = 0; i < elements.length; i++) {
flipElement(element, appState); flipElement(elements[i], appState);
// If vertical flip, rotate an extra 180 // If vertical flip, rotate an extra 180
if (flipDirection === "vertical") { if (flipDirection === "vertical") {
rotateElement(element, Math.PI); rotateElement(elements[i], Math.PI);
} }
}); }
return elements; return elements;
}; };
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
+3 -4
View File
@@ -1,4 +1,5 @@
import { Action, ActionResult } from "./types"; import { Action, ActionResult } from "./types";
import React from "react";
import { undo, redo } from "../components/icons"; import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z && event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey, !event.shiftKey,
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={undo} icon={undo}
aria-label={t("buttons.undo")} aria-label={t("buttons.undo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"}
/> />
), ),
commitToHistory: () => false, commitToHistory: () => false,
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
event.shiftKey && event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) || event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y), (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={redo} icon={redo}
aria-label={t("buttons.redo")} aria-label={t("buttons.redo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"}
/> />
), ),
commitToHistory: () => false, commitToHistory: () => false,
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { menu, palette } from "../components/icons"; import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
+3 -2
View File
@@ -1,3 +1,4 @@
import React from "react";
import { getClientColors, getClientInitials } from "../clients"; import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ appState, updateData, id }) => {
const clientId: string | undefined = data?.id; const clientId = id;
if (!clientId) { if (!clientId) {
return null; return null;
} }
+47 -77
View File
@@ -1,3 +1,4 @@
import React from "react";
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
@@ -6,20 +7,12 @@ import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadDotIcon,
ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
EdgeRoundIcon, EdgeRoundIcon,
EdgeSharpIcon, EdgeSharpIcon,
FillCrossHatchIcon, FillCrossHatchIcon,
FillHachureIcon, FillHachureIcon,
FillSolidIcon, FillSolidIcon,
// FontFamilyCodeIcon,
// FontFamilyHandDrawnIcon,
// FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon, SloppinessArchitectIcon,
SloppinessArtistIcon, SloppinessArtistIcon,
SloppinessCartoonistIcon, SloppinessCartoonistIcon,
@@ -27,15 +20,18 @@ import {
StrokeStyleDottedIcon, StrokeStyleDottedIcon,
StrokeStyleSolidIcon, StrokeStyleSolidIcon,
StrokeWidthIcon, StrokeWidthIcon,
TextAlignCenterIcon, FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
} from "../components/icons"; } from "../components/icons";
import { import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
//FONT_FAMILY,
} from "../constants";
import { import {
getNonDeletedElements, getNonDeletedElements,
isTextElement, isTextElement,
@@ -48,7 +44,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
//FontFamilyValues, FontFamily,
TextAlign, TextAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
@@ -60,9 +56,7 @@ import {
getTargetElements, getTargetElements,
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { register } from "./register"; import { register } from "./register";
import FontsList from "../components/FontList";
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -106,13 +100,11 @@ export const actionChangeStrokeColor = register({
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeColor(el.type) newElementWith(el, {
? newElementWith(el, { strokeColor: value.currentItemStrokeColor,
strokeColor: value.currentItemStrokeColor, }),
}) ),
: el;
}),
}), }),
appState: { appState: {
...appState, ...appState,
@@ -134,9 +126,9 @@ export const actionChangeStrokeColor = register({
appState.currentItemStrokeColor, appState.currentItemStrokeColor,
)} )}
onChange={(color) => updateData({ currentItemStrokeColor: color })} onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"} isActive={appState.openMenu === "strokeColorPicker"}
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null }) updateData({ openMenu: active ? "strokeColorPicker" : null })
} }
/> />
</> </>
@@ -174,9 +166,9 @@ export const actionChangeBackgroundColor = register({
appState.currentItemBackgroundColor, appState.currentItemBackgroundColor,
)} )}
onChange={(color) => updateData({ currentItemBackgroundColor: color })} onChange={(color) => updateData({ currentItemBackgroundColor: color })}
isActive={appState.openPopup === "backgroundColorPicker"} isActive={appState.openMenu === "backgroundColorPicker"}
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null }) updateData({ openMenu: active ? "backgroundColorPicker" : null })
} }
/> />
</> </>
@@ -507,43 +499,37 @@ export const actionChangeFontFamily = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
// const options: { const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
// value: FontFamilyValues; {
// text: string; value: 1,
// icon: JSX.Element; text: t("labels.handDrawn"),
// }[] = [ icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
// { },
// value: FONT_FAMILY.Virgil, {
// text: t("labels.handDrawn"), value: 2,
// icon: <FontFamilyHandDrawnIcon theme={appState.theme} />, text: t("labels.normal"),
// }, icon: <FontFamilyNormalIcon theme={appState.theme} />,
// { },
// value: FONT_FAMILY.Helvetica, {
// text: t("labels.normal"), value: 3,
// icon: <FontFamilyNormalIcon theme={appState.theme} />, text: t("labels.code"),
// }, icon: <FontFamilyCodeIcon theme={appState.theme} />,
// { },
// value: FONT_FAMILY.Cascadia, ];
// text: t("labels.code"),
// icon: <FontFamilyCodeIcon theme={appState.theme} />,
// },
// ];
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<FontsList <ButtonIconSelect<FontFamily | false>
onChange={(val) => { group="font-family"
updateData(val); options={options}
}} value={getFormValue(
currentFontFamily={ elements,
getFormValue( appState,
elements, (element) => isTextElement(element) && element.fontFamily,
appState, appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
(element) => isTextElement(element) && element.fontFamily, )}
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, onChange={(value) => updateData(value)}
) || DEFAULT_FONT_FAMILY
}
/> />
</fieldset> </fieldset>
); );
@@ -742,14 +728,6 @@ export const actionChangeArrowhead = register({
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r", keyBinding: "r",
}, },
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "t",
},
]} ]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
@@ -792,14 +770,6 @@ export const actionChangeArrowhead = register({
keyBinding: "r", keyBinding: "r",
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
}, },
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
),
keyBinding: "t",
},
]} ]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
+1
View File
@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
appState: { appState: {
...appState, ...appState,
viewModeEnabled: !this.checked!(appState), viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
}, },
commitToHistory: false, commitToHistory: false,
}; };
+19 -9
View File
@@ -5,11 +5,20 @@ import {
UpdaterFn, UpdaterFn,
ActionName, ActionName,
ActionResult, ActionResult,
PanelComponentProps,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppProps, AppState } from "../types";
import { MODES } from "../constants"; 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 { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +27,13 @@ export class ActionManager implements ActionsManagerInterface {
getAppState: () => Readonly<AppState>; getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: AppClassProperties; app: App;
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
getAppState: () => AppState, getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[], getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: AppClassProperties, app: App,
) { ) {
this.updater = (actionResult) => { this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) { if (actionResult && "then" in actionResult) {
@@ -98,10 +107,11 @@ export class ActionManager implements ActionsManagerInterface {
); );
} }
/** // Id is an attribute that we can use to pass in data like keys.
* @param data additional data sent to the PanelComponent // This is needed for dynamically generated action components
*/ // like the user list. We can use this key to extract more
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => { // data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
if ( if (
@@ -129,8 +139,8 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()} elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
id={id}
appProps={this.app.props} appProps={this.app.props}
data={data}
/> />
); );
} }
+16 -20
View File
@@ -1,12 +1,7 @@
import React from "react"; import React from "react";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { import { AppState, ExcalidrawProps } from "../types";
AppClassProperties, import Library from "../data/library";
AppState,
ExcalidrawProps,
BinaryFiles,
} from "../types";
import { ToolButtonSize } from "../components/ToolButton";
/** if false, the action should be prevented */ /** if false, the action should be prevented */
export type ActionResult = export type ActionResult =
@@ -16,18 +11,22 @@ export type ActionResult =
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> | null; > | null;
files?: BinaryFiles | null;
commitToHistory: boolean; commitToHistory: boolean;
syncHistory?: boolean; syncHistory?: boolean;
replaceFiles?: boolean;
} }
| false; | false;
type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = ( type ActionFn = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
formData: any, formData: any,
app: AppClassProperties, app: AppAPI,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
@@ -67,7 +66,6 @@ export type ActionName =
| "changeProjectName" | "changeProjectName"
| "changeExportBackground" | "changeExportBackground"
| "changeExportEmbedScene" | "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile" | "saveToActiveFile"
| "saveFileToDisk" | "saveFileToDisk"
| "loadScene" | "loadScene"
@@ -103,17 +101,15 @@ export type ActionName =
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme"; | "toggleTheme";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>;
};
export interface Action { export interface Action {
name: ActionName; name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<{
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
id?: string;
}>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (
+66 -92
View File
@@ -3,23 +3,17 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
THEME,
} from "./constants"; } from "./constants";
import { t } from "./i18n"; import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types"; import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils"; import { getDateTime } from "./utils";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
: 1;
export const getDefaultAppState = (): Omit< export const getDefaultAppState = (): Omit<
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> => { > => {
return { return {
theme: THEME.LIGHT, theme: "light",
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
currentItemBackgroundColor: "transparent", currentItemBackgroundColor: "transparent",
@@ -45,7 +39,6 @@ export const getDefaultAppState = (): Omit<
elementType: "selection", elementType: "selection",
errorMessage: null, errorMessage: null,
exportBackground: true, exportBackground: true,
exportScale: defaultExportScale,
exportEmbedScene: false, exportEmbedScene: false,
exportWithDarkMode: false, exportWithDarkMode: false,
fileHandle: null, fileHandle: null,
@@ -59,7 +52,6 @@ export const getDefaultAppState = (): Omit<
multiElement: null, multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null, openMenu: null,
openPopup: null,
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {}, previousSelectedElementIds: {},
resizingElement: null, resizingElement: null,
@@ -79,7 +71,6 @@ export const getDefaultAppState = (): Omit<
zenModeEnabled: false, zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } }, zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElement: null,
}; };
}; };
@@ -93,87 +84,76 @@ const APP_STATE_STORAGE_CONF = (<
browser: boolean; browser: boolean;
/** whether to keep when exporting to file/database */ /** whether to keep when exporting to file/database */
export: boolean; export: boolean;
/** server (shareLink/collab/...) */
server: boolean;
}, },
T extends Record<keyof AppState, Values> T extends Record<keyof AppState, Values>
>( >(
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }, config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({ ) => config)({
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false, server: false }, currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false, server: false }, currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false, server: false }, currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false, server: false }, currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false, server: false }, currentItemFontSize: { browser: true, export: false },
currentItemLinearStrokeSharpness: { currentItemLinearStrokeSharpness: { browser: true, export: false },
browser: true, currentItemOpacity: { browser: true, export: false },
export: false, currentItemRoughness: { browser: true, export: false },
server: false, currentItemStartArrowhead: { browser: true, export: false },
}, currentItemStrokeColor: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false, server: false }, currentItemStrokeSharpness: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false, server: false }, draggingElement: { browser: false, export: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, editingElement: { browser: false, export: false },
currentItemTextAlign: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false },
cursorButton: { browser: true, export: false, server: false }, editingLinearElement: { browser: false, export: false },
draggingElement: { browser: false, export: false, server: false }, elementLocked: { browser: true, export: false },
editingElement: { browser: false, export: false, server: false }, elementType: { browser: true, export: false },
editingGroupId: { browser: true, export: false, server: false }, errorMessage: { browser: false, export: false },
editingLinearElement: { browser: false, export: false, server: false }, exportBackground: { browser: true, export: false },
elementLocked: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false },
elementType: { browser: true, export: false, server: false }, exportWithDarkMode: { browser: true, export: false },
errorMessage: { browser: false, export: false, server: false }, fileHandle: { browser: false, export: false },
exportBackground: { browser: true, export: false, server: false }, gridSize: { browser: true, export: true },
exportEmbedScene: { browser: true, export: false, server: false }, height: { browser: false, export: false },
exportScale: { browser: true, export: false, server: false }, isBindingEnabled: { browser: false, export: false },
exportWithDarkMode: { browser: true, export: false, server: false }, isLibraryOpen: { browser: false, export: false },
fileHandle: { browser: false, export: false, server: false }, isLoading: { browser: false, export: false },
gridSize: { browser: true, export: true, server: true }, isResizing: { browser: false, export: false },
height: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false, server: false }, lastPointerDownWith: { browser: true, export: false },
isLibraryOpen: { browser: false, export: false, server: false }, multiElement: { browser: false, export: false },
isLoading: { browser: false, export: false, server: false }, name: { browser: true, export: false },
isResizing: { browser: false, export: false, server: false }, offsetLeft: { browser: false, export: false },
isRotating: { browser: false, export: false, server: false }, offsetTop: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false, server: false }, openMenu: { browser: true, export: false },
multiElement: { browser: false, export: false, server: false }, pasteDialog: { browser: false, export: false },
name: { browser: true, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false },
offsetLeft: { browser: false, export: false, server: false }, resizingElement: { browser: false, export: false },
offsetTop: { browser: false, export: false, server: false }, scrolledOutside: { browser: true, export: false },
openMenu: { browser: true, export: false, server: false }, scrollX: { browser: true, export: false },
openPopup: { browser: false, export: false, server: false }, scrollY: { browser: true, export: false },
pasteDialog: { browser: false, export: false, server: false }, selectedElementIds: { browser: true, export: false },
previousSelectedElementIds: { browser: true, export: false, server: false }, selectedGroupIds: { browser: true, export: false },
resizingElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false },
scrolledOutside: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false },
scrollX: { browser: true, export: false, server: false }, showHelpDialog: { browser: false, export: false },
scrollY: { browser: true, export: false, server: false }, showStats: { browser: true, export: false },
selectedElementIds: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false },
selectedGroupIds: { browser: true, export: false, server: false }, suggestedBindings: { browser: false, export: false },
selectionElement: { browser: false, export: false, server: false }, toastMessage: { browser: false, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, viewBackgroundColor: { browser: true, export: true },
showHelpDialog: { browser: false, export: false, server: false }, width: { browser: false, export: false },
showStats: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false },
startBoundElement: { browser: false, export: false, server: false }, zoom: { browser: true, export: false },
suggestedBindings: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: 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 },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
ExportType extends "export" | "browser" | "server"
>(
appState: Partial<AppState>, appState: Partial<AppState>,
exportType: ExportType, exportType: ExportType,
) => { ) => {
@@ -186,10 +166,8 @@ const _clearAppStateForStorage = <
for (const key of Object.keys(appState) as (keyof typeof appState)[]) { for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key]; const propConfig = APP_STATE_STORAGE_CONF[key];
if (propConfig?.[exportType]) { if (propConfig?.[exportType]) {
const nextValue = appState[key]; // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
// https://github.com/microsoft/TypeScript/issues/31445
(stateForExport as any)[key] = nextValue;
} }
} }
return stateForExport; return stateForExport;
@@ -202,7 +180,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
export const cleanAppStateForExport = (appState: Partial<AppState>) => { export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export"); return _clearAppStateForStorage(appState, "export");
}; };
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
+6 -20
View File
@@ -3,22 +3,19 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types"; import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: ExcalidrawElement[]; elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
}; };
export interface ClipboardData { export interface ClipboardData {
spreadsheet?: Spreadsheet; spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[]; elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string; text?: string;
errorMessage?: string; errorMessage?: string;
} }
@@ -40,7 +37,7 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = ( const clipboardContainsElements = (
contents: any, contents: any,
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => { ): contents is { elements: ExcalidrawElement[] } => {
if ( if (
[ [
EXPORT_DATA_TYPES.excalidraw, EXPORT_DATA_TYPES.excalidraw,
@@ -56,18 +53,10 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles,
) => { ) => {
const selectedElements = getSelectedElements(elements, appState);
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements, elements: getSelectedElements(elements, appState),
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
CLIPBOARD = json; CLIPBOARD = json;
@@ -149,10 +138,7 @@ export const parseClipboard = async (
try { try {
const systemClipboardData = JSON.parse(systemClipboard); const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) { if (clipboardContainsElements(systemClipboardData)) {
return { return { elements: systemClipboardData.elements };
elements: systemClipboardData.elements,
files: systemClipboardData.files,
};
} }
return appClipboardData; return appClipboardData;
} catch { } catch {
@@ -167,7 +153,7 @@ export const parseClipboard = async (
export const copyBlobToClipboardAsPng = async (blob: Blob) => { export const copyBlobToClipboardAsPng = async (blob: Blob) => {
await navigator.clipboard.write([ await navigator.clipboard.write([
new window.ClipboardItem({ [MIME_TYPES.png]: blob }), new window.ClipboardItem({ "image/png": blob }),
]); ]);
}; };
+34 -27
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { import {
@@ -18,7 +18,6 @@ import { AppState, Zoom } from "../types";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils"; import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@@ -49,22 +48,9 @@ export const SelectedShapeActions = ({
hasBackground(elementType) || hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type)); 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 ( return (
<div className="panelColumn"> <div className="panelColumn">
{((hasStrokeColor(elementType) && {renderAction("changeStrokeColor")}
elementType !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
@@ -165,24 +151,31 @@ export const SelectedShapeActions = ({
); );
}; };
const LIBRARY_ICON = (
// fa-th-large
<svg viewBox="0 0 512 512">
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
</svg>
);
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
canvas, canvas,
elementType, elementType,
setAppState, setAppState,
onImageAction, isLibraryOpen,
}: { }: {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"]; elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; isLibraryOpen: boolean;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key }, index) => { {SHAPES.map(({ value, icon, key }, index) => {
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]); const letter = typeof key === "string" ? key : key[0];
const shortcut = letter const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}` index + 1
: `${index + 1}`; }`;
return ( return (
<ToolButton <ToolButton
className="Shape" className="Shape"
@@ -196,20 +189,31 @@ export const ShapesSwitcher = ({
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={value} data-testid={value}
onChange={({ pointerType }) => { onChange={() => {
setAppState({ setAppState({
elementType: value, elementType: value,
multiElement: null, multiElement: null,
selectedElementIds: {}, selectedElementIds: {},
}); });
setCursorForShape(canvas, value); setCursorForShape(canvas, value);
if (value === "image") { setAppState({});
onImageAction({ pointerType });
}
}} }}
/> />
); );
})} })}
<ToolButton
className="Shape ToolIcon_type_button__library"
type="button"
icon={LIBRARY_ICON}
name="editor-library"
keyBindingLabel="9"
aria-keyshortcuts="9"
title={`${capitalizeString(t("toolBar.library"))} — 9`}
aria-label={capitalizeString(t("toolBar.library"))}
onClick={() => {
setAppState({ isLibraryOpen: !isLibraryOpen });
}}
/>
</> </>
); );
@@ -222,9 +226,12 @@ export const ZoomActions = ({
}) => ( }) => (
<Stack.Col gap={1}> <Stack.Col gap={1}>
<Stack.Row gap={1} align="center"> <Stack.Row gap={1} align="center">
{renderAction("zoomOut")}
{renderAction("zoomIn")} {renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")} {renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
); );
-21
View File
@@ -1,21 +0,0 @@
.excalidraw {
.ActiveFile {
.ActiveFile__fileName {
display: flex;
align-items: center;
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 9.3em;
}
svg {
width: 1.15em;
margin-inline-end: 0.3em;
transform: scaleY(0.9);
}
}
}
}
-28
View File
@@ -1,28 +0,0 @@
import Stack from "../components/Stack";
import { ToolButton } from "../components/ToolButton";
import { save, file } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
type ActiveFileProps = {
fileName?: string;
onSave: () => void;
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<Stack.Row className="ActiveFile" gap={1} align="center">
<span className="ActiveFile__fileName">
{file}
<span>{fileName}</span>
</span>
<ToolButton
type="icon"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={onSave}
data-testid="save-button"
/>
</Stack.Row>
);
+287 -878
View File
File diff suppressed because it is too large Load Diff
@@ -16,5 +16,10 @@ export const BackgroundPickerAndDarkModeToggle = ({
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")} {actionManager.renderAction("changeViewBackgroundColor")}
{showThemeBtn && actionManager.renderAction("toggleTheme")} {showThemeBtn && actionManager.renderAction("toggleTheme")}
{appState.fileHandle && (
<div style={{ marginInlineStart: "0.25rem" }}>
{actionManager.renderAction("saveToActiveFile")}
</div>
)}
</div> </div>
); );
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
export const ButtonSelect = <T extends Object>({ export const ButtonSelect = <T extends Object>({
-4
View File
@@ -48,10 +48,6 @@
.ToolIcon__label { .ToolIcon__label {
color: $oc-white; color: $oc-white;
} }
.Spinner {
--spinner-color: #fff;
}
} }
} }
} }
+1 -1
View File
@@ -81,7 +81,7 @@
align-items: center; align-items: center;
} }
.excalidraw-tooltip-icon { .Tooltip-icon {
width: 1em; width: 1em;
height: 1em; height: 1em;
} }
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { checkIcon } from "./icons"; import { checkIcon } from "./icons";
-42
View File
@@ -1,42 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.clear-canvas {
&-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: 1.5rem;
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;
}
}
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-2;
}
}
}
-67
View File
@@ -1,67 +0,0 @@
import { useState } from "react";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { Dialog } from "./Dialog";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
import "./ClearCanvas.scss";
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 && (
<Dialog
onCloseRequest={toggleDialog}
title={t("clearCanvasDialog.title")}
className="clear-canvas"
small={true}
>
<>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
<div className="clear-canvas-buttons">
<ToolButton
type="button"
title={t("buttons.clear")}
aria-label={t("buttons.clear")}
label={t("buttons.clear")}
onClick={() => {
onConfirm();
toggleDialog();
}}
data-testid="confirm-clear-canvas-button"
className="clear-canvas--confirm"
/>
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={toggleDialog}
data-testid="cancel-clear-canvas-button"
className="clear-canvas--cancel"
/>
</div>
</>
</Dialog>
)}
</>
);
};
export default ClearCanvas;
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
+31 -37
View File
@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import { isTransparent } from "../utils";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
}; };
const getColor = (color: string): string | null => { const getColor = (color: string): string | null => {
if (isTransparent(color)) { if (color === "transparent") {
return color; return color;
} }
@@ -138,41 +137,36 @@ const Picker = ({
}} }}
tabIndex={0} tabIndex={0}
> >
{colors.map((_color, i) => { {colors.map((_color, i) => (
const _colorWithoutHash = _color.replace("#", ""); <button
return ( className="color-picker-swatch"
<button onClick={(event) => {
className="color-picker-swatch" (event.currentTarget as HTMLButtonElement).focus();
onClick={(event) => { onChange(_color);
(event.currentTarget as HTMLButtonElement).focus(); }}
onChange(_color); title={`${_color}${keyBindings[i].toUpperCase()}`}
}} aria-label={_color}
title={`${t(`colors.${_colorWithoutHash}`)}${ aria-keyshortcuts={keyBindings[i]}
!isTransparent(_color) ? ` (${_color})` : "" style={{ color: _color }}
} ${keyBindings[i].toUpperCase()}`} key={_color}
aria-label={t(`colors.${_colorWithoutHash}`)} ref={(el) => {
aria-keyshortcuts={keyBindings[i]} if (el && i === 0) {
style={{ color: _color }} firstItem.current = el;
key={_color} }
ref={(el) => { if (el && _color === color) {
if (el && i === 0) { activeItem.current = el;
firstItem.current = el; }
} }}
if (el && _color === color) { onFocus={() => {
activeItem.current = el; onChange(_color);
} }}
}} >
onFocus={() => { {_color === "transparent" ? (
onChange(_color); <div className="color-picker-transparent"></div>
}} ) : undefined}
> <span className="color-picker-keybinding">{keyBindings[i]}</span>
{isTransparent(_color) ? ( </button>
<div className="color-picker-transparent"></div> ))}
) : undefined}
<span className="color-picker-keybinding">{keyBindings[i]}</span>
</button>
);
})}
{showInput && ( {showInput && (
<ColorInput <ColorInput
color={color} color={color}
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { render, unmountComponentAtNode } from "react-dom"; import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
+7 -8
View File
@@ -1,15 +1,16 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { THEME } from "../constants";
import { Theme } from "../element/types"; export type Appearence = "light" | "dark";
// We chose to use only explicit toggle and not a third option for system value, // We chose to use only explicit toggle and not a third option for system value,
// but this could be added in the future. // but this could be added in the future.
export const DarkModeToggle = (props: { export const DarkModeToggle = (props: {
value: Theme; value: Appearence;
onChange: (value: Theme) => void; onChange: (value: Appearence) => void;
title?: string; title?: string;
}) => { }) => {
const title = const title =
@@ -19,12 +20,10 @@ export const DarkModeToggle = (props: {
return ( return (
<ToolButton <ToolButton
type="icon" type="icon"
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN} icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
title={title} title={title}
aria-label={title} aria-label={title}
onClick={() => onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
data-testid="toggle-dark-mode" data-testid="toggle-dark-mode"
/> />
); );
+2 -3
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer, useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, close } from "./icons"; import { back, close } from "./icons";
@@ -21,7 +21,6 @@ export const Dialog = (props: {
}) => { }) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement); const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
useEffect(() => { useEffect(() => {
if (!islandNode) { if (!islandNode) {
@@ -83,7 +82,7 @@ export const Dialog = (props: {
theme={props.theme} theme={props.theme}
> >
<Island ref={setIslandNode}> <Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title"> <h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span> <span className="Dialog__titleContent">{props.title}</span>
<button <button
className="Modal__close" className="Modal__close"
+1 -1
View File
@@ -12,7 +12,7 @@ export const ErrorDialog = ({
onClose?: () => void; onClose?: () => void;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(!!message); const [modalIsShown, setModalIsShown] = useState(!!message);
const { container: excalidrawContainer } = useExcalidrawContainer(); const excalidrawContainer = useExcalidrawContainer();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setModalIsShown(false); setModalIsShown(false);
+1 -2
View File
@@ -97,8 +97,7 @@
border-radius: 1rem; border-radius: 1rem;
background-color: var(--button-color); background-color: var(--button-color);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28), box-shadow: 0 3px 5px -1px rgb(0 0 0 / 28%), 0 6px 10px 0 rgb(0 0 0 / 14%);
0 6px 10px 0 rgba(0, 0, 0, 0.14);
font-family: Cascadia; font-family: Cascadia;
font-size: 1.8em; font-size: 1.8em;
-53
View File
@@ -1,53 +0,0 @@
import { FONT_FAMILY } from "../constants";
import { FontFamilyValues } from "../element/types";
const FontsList = ({
onChange,
currentFontFamily,
}: {
onChange: (val: FontFamilyValues) => void;
currentFontFamily: FontFamilyValues;
}) => {
return (
<select
className="dropdown-select"
onChange={(event) => {
onChange(Number(event.target.value));
}}
value={currentFontFamily}
>
<option key="virgil" value={FONT_FAMILY.Virgil}>
Hand-Drawn
</option>
<option key="helvetica" value={FONT_FAMILY.Helvetica}>
Normal
</option>
<option key="cascadia" value={FONT_FAMILY.Cascadia}>
code
</option>
<option key="redacted-regular" value={FONT_FAMILY.REDACTED_REGULAR}>
Redacted Regular
</option>
<option
key="redacted-script-regular"
value={FONT_FAMILY.REDACTED_SCRIPT_REGULAR}
>
Redacted Script
</option>
<option
key="redacted-script-bold"
value={FONT_FAMILY.REDACTED_SCRIPT_BOLD}
>
Redacted Script BOLD
</option>
<option key="Scribble" value={FONT_FAMILY.SCRIBBLE}>
Scribble
</option>
<option key="Blokk" value={FONT_FAMILY.BLOKK}>
Blokk
</option>
</select>
);
};
export default FontsList;
-9
View File
@@ -157,15 +157,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["Shift+P", "7"]} shortcuts={["Shift+P", "7"]}
/> />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("Enter"),
t("helpDialog.doubleClick"),
]}
/>
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}
shortcuts={[ shortcuts={[
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { questionCircle } from "../components/icons"; import { questionCircle } from "../components/icons";
type HelpIconProps = { type HelpIconProps = {
+3 -20
View File
@@ -1,14 +1,11 @@
import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { AppState } from "../types"; import { AppState } from "../types";
import { import { isLinearElement } from "../element/typeChecks";
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
interface Hint { interface Hint {
@@ -34,10 +31,6 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.text"); return t("hints.text");
} }
if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage");
}
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if ( if (
isResizing && isResizing &&
@@ -48,9 +41,7 @@ const getHints = ({ appState, elements }: Hint) => {
if (isLinearElement(targetElement) && targetElement.points.length === 2) { if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle"); return t("hints.lockAngle");
} }
return isImageElement(targetElement) return t("hints.resize");
? t("hints.resizeImage")
: t("hints.resize");
} }
if (isRotating && lastPointerDownWith === "mouse") { if (isRotating && lastPointerDownWith === "mouse") {
@@ -66,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.lineEditor_info"); return t("hints.lineEditor_info");
} }
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing");
}
return null; return null;
}; };
+1 -1
View File
@@ -90,7 +90,7 @@
.picker-content { .picker-content {
padding: 0.5rem; padding: 0.5rem;
display: grid; display: grid;
grid-template-columns: repeat(3, auto); grid-auto-flow: column;
grid-gap: 0.5rem; grid-gap: 0.5rem;
border-radius: 4px; border-radius: 4px;
:root[dir="rtl"] & { :root[dir="rtl"] & {
+63 -34
View File
@@ -8,17 +8,20 @@ import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons"; import { clipboard, exportImage } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import { supported as fsSupported } from "browser-fs-access";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -79,8 +82,7 @@ const ExportButton: React.FC<{
const ImageExportModal = ({ const ImageExportModal = ({
elements, elements,
appState, appState,
files, exportPadding = 10,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
@@ -88,7 +90,6 @@ const ImageExportModal = ({
}: { }: {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
actionManager: ActionsManagerInterface; actionManager: ActionsManagerInterface;
onExportToPng: ExportCB; onExportToPng: ExportCB;
@@ -97,6 +98,7 @@ const ImageExportModal = ({
onCloseRequest: () => void; onCloseRequest: () => void;
}) => { }) => {
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState; const { exportBackground, viewBackgroundColor } = appState;
@@ -114,29 +116,35 @@ const ImageExportModal = ({
if (!previewNode) { if (!previewNode) {
return; return;
} }
exportToCanvas(exportedElements, appState, files, { try {
exportBackground, const canvas = exportToCanvas(exportedElements, appState, {
viewBackgroundColor, exportBackground,
exportPadding, viewBackgroundColor,
}) exportPadding,
.then((canvas) => { scale,
// 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);
}); });
// 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, appState,
files,
exportedElements, exportedElements,
exportBackground, exportBackground,
exportPadding, exportPadding,
viewBackgroundColor, viewBackgroundColor,
scale,
]); ]);
return ( return (
@@ -167,8 +175,33 @@ const ImageExportModal = ({
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}> <div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
<Stack.Row gap={2}> <Stack.Row gap={2} justifyContent={"center"}>
{actionManager.renderAction("changeExportScale")} {scales.map((_scale) => {
const [width, height] = getExportSize(
exportedElements,
exportPadding,
_scale,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${_scale}x (${width}x${height})`;
return (
<ToolButton
key={_scale}
size="s"
type="radio"
icon={`${_scale}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={_scale === scale}
onChange={() => setScale(_scale)}
/>
);
})}
</Stack.Row> </Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p> <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
</div> </div>
@@ -180,15 +213,14 @@ const ImageExportModal = ({
margin: ".6em 0", margin: ".6em 0",
}} }}
> >
{!nativeFileSystemSupported && {!fsSupported && actionManager.renderAction("changeProjectName")}
actionManager.renderAction("changeProjectName")}
</div> </div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}> <Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton <ExportButton
color="indigo" color="indigo"
title={t("buttons.exportToPng")} title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")} aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements)} onClick={() => onExportToPng(exportedElements, scale)}
> >
PNG PNG
</ExportButton> </ExportButton>
@@ -196,14 +228,14 @@ const ImageExportModal = ({
color="red" color="red"
title={t("buttons.exportToSvg")} title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")} aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements)} onClick={() => onExportToSvg(exportedElements, scale)}
> >
SVG SVG
</ExportButton> </ExportButton>
{probablySupportsClipboardBlob && ( {probablySupportsClipboardBlob && (
<ExportButton <ExportButton
title={t("buttons.copyPngToClipboard")} title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)} onClick={() => onExportToClipboard(exportedElements, scale)}
color="gray" color="gray"
shade={7} shade={7}
> >
@@ -218,8 +250,7 @@ const ImageExportModal = ({
export const ImageExportDialog = ({ export const ImageExportDialog = ({
elements, elements,
appState, appState,
files, exportPadding = 10,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
@@ -227,7 +258,6 @@ export const ImageExportDialog = ({
}: { }: {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
actionManager: ActionsManagerInterface; actionManager: ActionsManagerInterface;
onExportToPng: ExportCB; onExportToPng: ExportCB;
@@ -258,7 +288,6 @@ export const ImageExportDialog = ({
<ImageExportModal <ImageExportModal
elements={elements} elements={elements}
appState={appState} appState={appState}
files={files}
exportPadding={exportPadding} exportPadding={exportPadding}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={onExportToPng} onExportToPng={onExportToPng}
+20 -15
View File
@@ -1,25 +1,30 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { defaultLang, Language, languages, setLanguage } from "../i18n"; import { defaultLang, Language, languages, setLanguage } from "../i18n";
interface Props { interface Props {
langCode: Language["code"]; langCode: Language["code"];
children: React.ReactElement;
} }
interface State {
isLoading: boolean;
}
export class InitializeApp extends React.Component<Props, State> {
public state: { isLoading: boolean } = {
isLoading: true,
};
export const InitializeApp = (props: Props) => { async componentDidMount() {
const [loading, setLoading] = useState(true);
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
};
const currentLang = const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang; languages.find((lang) => lang.code === this.props.langCode) ||
updateLang(); defaultLang;
setLoading(false); await setLanguage(currentLang);
}, [props.langCode]); this.setState({
isLoading: false,
});
}
return loading ? <LoadingMessage /> : props.children; public render() {
}; return this.state.isLoading ? <LoadingMessage /> : this.props.children;
}
}
+6 -14
View File
@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useIsMobile } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons"; import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -11,7 +11,7 @@ import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card"; import { Card } from "./Card";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem"; import { supported as fsSupported } from "browser-fs-access";
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -21,13 +21,11 @@ export type ExportCB = (
const JSONExportModal = ({ const JSONExportModal = ({
elements, elements,
appState, appState,
files,
actionManager, actionManager,
exportOpts, exportOpts,
canvas, canvas,
}: { }: {
appState: AppState; appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface; actionManager: ActionsManagerInterface;
onCloseRequest: () => void; onCloseRequest: () => void;
@@ -44,8 +42,7 @@ const JSONExportModal = ({
<h2>{t("exportDialog.disk_title")}</h2> <h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details"> <div className="Card-details">
{t("exportDialog.disk_details")} {t("exportDialog.disk_details")}
{!nativeFileSystemSupported && {!fsSupported && actionManager.renderAction("changeProjectName")}
actionManager.renderAction("changeProjectName")}
</div> </div>
<ToolButton <ToolButton
className="Card-button" className="Card-button"
@@ -70,14 +67,12 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")} title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")} aria-label={t("exportDialog.link_button")}
showAriaLabel={true} showAriaLabel={true}
onClick={() => onClick={() => onExportToBackend(elements, appState, canvas)}
onExportToBackend(elements, appState, files, canvas)
}
/> />
</Card> </Card>
)} )}
{exportOpts.renderCustomUI && {exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, files, canvas)} exportOpts.renderCustomUI(elements, appState, canvas)}
</div> </div>
</div> </div>
); );
@@ -86,14 +81,12 @@ const JSONExportModal = ({
export const JSONExportDialog = ({ export const JSONExportDialog = ({
elements, elements,
appState, appState,
files,
actionManager, actionManager,
exportOpts, exportOpts,
canvas, canvas,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
files: BinaryFiles; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface; actionManager: ActionsManagerInterface;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
@@ -122,7 +115,6 @@ export const JSONExportDialog = ({
<JSONExportModal <JSONExportModal
elements={elements} elements={elements}
appState={appState} appState={appState}
files={files}
actionManager={actionManager} actionManager={actionManager}
onCloseRequest={handleClose} onCloseRequest={handleClose}
exportOpts={exportOpts} exportOpts={exportOpts}
+3 -14
View File
@@ -73,10 +73,10 @@
} }
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left { :root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(-76px, 0); transform: translate(-92px, 0);
} }
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left { :root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(76px, 0); transform: translate(92px, 0);
} }
&.layer-ui__wrapper__footer-left--transition-bottom { &.layer-ui__wrapper__footer-left--transition-bottom {
@@ -116,19 +116,8 @@
} }
} }
.layer-ui__wrapper__footer-left, .layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
.disable-zen-mode--visible {
pointer-events: all;
}
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right { .layer-ui__wrapper__footer-right {
margin-top: auto; pointer-events: all;
margin-bottom: auto;
margin-inline-end: 1em;
} }
} }
} }
+20 -91
View File
@@ -20,7 +20,6 @@ import {
AppProps, AppProps,
AppState, AppState,
ExcalidrawProps, ExcalidrawProps,
BinaryFiles,
LibraryItem, LibraryItem,
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
@@ -37,7 +36,7 @@ import { Island } from "./Island";
import "./LayerUI.scss"; import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockIcon } from "./LockIcon";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog"; import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section"; import { Section } from "./Section";
@@ -48,13 +47,10 @@ import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library"; import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: AppState; appState: AppState;
files: BinaryFiles;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
@@ -67,10 +63,7 @@ interface LayerUIProps {
toggleZenMode: () => void; toggleZenMode: () => void;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
renderTopRightUI?: ( renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean; viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -78,7 +71,6 @@ interface LayerUIProps {
focusContainer: () => void; focusContainer: () => void;
library: Library; library: Library;
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
} }
const useOnClickOutside = ( const useOnClickOutside = (
@@ -115,13 +107,11 @@ const LibraryMenuItems = ({
onAddToLibrary, onAddToLibrary,
onInsertShape, onInsertShape,
pendingElements, pendingElements,
theme,
setAppState, setAppState,
setLibraryItems, setLibraryItems,
libraryReturnUrl, libraryReturnUrl,
focusContainer, focusContainer,
library, library,
files,
id, id,
}: { }: {
libraryItems: LibraryItems; libraryItems: LibraryItems;
@@ -129,8 +119,6 @@ const LibraryMenuItems = ({
onRemoveFromLibrary: (index: number) => void; onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void; onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void; onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void; setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -204,7 +192,7 @@ const LibraryMenuItems = ({
<a <a
href={`https://libraries.excalidraw.com?target=${ href={`https://libraries.excalidraw.com?target=${
window.name || "_blank" window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`} }&referrer=${referrer}&useHash=true&token=${id}`}
target="_excalidraw_libraries" target="_excalidraw_libraries"
> >
{t("labels.libraries")} {t("labels.libraries")}
@@ -226,7 +214,6 @@ const LibraryMenuItems = ({
<Stack.Col key={x}> <Stack.Col key={x}>
<LibraryUnit <LibraryUnit
elements={libraryItems[y + x]} elements={libraryItems[y + x]}
files={files}
pendingElements={ pendingElements={
shouldAddPendingElements ? pendingElements : undefined shouldAddPendingElements ? pendingElements : undefined
} }
@@ -259,9 +246,7 @@ const LibraryMenu = ({
onInsertShape, onInsertShape,
pendingElements, pendingElements,
onAddToLibrary, onAddToLibrary,
theme,
setAppState, setAppState,
files,
libraryReturnUrl, libraryReturnUrl,
focusContainer, focusContainer,
library, library,
@@ -271,8 +256,6 @@ const LibraryMenu = ({
onClickOutside: (event: MouseEvent) => void; onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void; onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void; focusContainer: () => void;
@@ -294,12 +277,12 @@ const LibraryMenu = ({
"preloading" | "loading" | "ready" "preloading" | "loading" | "ready"
>("preloading"); >("preloading");
const loadingTimerRef = useRef<number | null>(null); const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
Promise.race([ Promise.race([
new Promise((resolve) => { new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => { loadingTimerRef.current = setTimeout(() => {
resolve("loading"); resolve("loading");
}, 100); }, 100);
}), }),
@@ -332,12 +315,6 @@ const LibraryMenu = ({
const addToLibrary = useCallback( const addToLibrary = useCallback(
async (elements: LibraryItem) => { async (elements: LibraryItem) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary(); const items = await library.loadLibrary();
const nextItems = [...items, elements]; const nextItems = [...items, elements];
onAddToLibrary(); onAddToLibrary();
@@ -368,8 +345,6 @@ const LibraryMenu = ({
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer} focusContainer={focusContainer}
library={library} library={library}
theme={theme}
files={files}
id={id} id={id}
/> />
)} )}
@@ -380,7 +355,6 @@ const LibraryMenu = ({
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
files,
setAppState, setAppState,
canvas, canvas,
elements, elements,
@@ -400,7 +374,6 @@ const LayerUI = ({
focusContainer, focusContainer,
library, library,
id, id,
onImageAction,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -413,7 +386,6 @@ const LayerUI = ({
<JSONExportDialog <JSONExportDialog
elements={elements} elements={elements}
appState={appState} appState={appState}
files={files}
actionManager={actionManager} actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export} exportOpts={UIOptions.canvasActions.export}
canvas={canvas} canvas={canvas}
@@ -428,38 +400,25 @@ const LayerUI = ({
const createExporter = (type: ExportType): ExportCB => async ( const createExporter = (type: ExportType): ExportCB => async (
exportedElements, exportedElements,
scale,
) => { ) => {
const fileHandle = await exportCanvas( await exportCanvas(type, exportedElements, appState, {
type, exportBackground: appState.exportBackground,
exportedElements, name: appState.name,
appState, viewBackgroundColor: appState.viewBackgroundColor,
files, scale,
{ })
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
},
)
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
setAppState({ errorMessage: error.message }); setAppState({ errorMessage: error.message });
}); });
if (
appState.exportEmbedScene &&
fileHandle &&
isImageFileHandle(fileHandle)
) {
setAppState({ fileHandle });
}
}; };
return ( return (
<ImageExportDialog <ImageExportDialog
elements={elements} elements={elements}
appState={appState} appState={appState}
files={files}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={createExporter("png")} onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")} onExportToSvg={createExporter("svg")}
@@ -493,7 +452,6 @@ const LayerUI = ({
</Section> </Section>
); );
}; };
const renderCanvasActions = () => ( const renderCanvasActions = () => (
<Section <Section
heading="canvasActions" heading="canvasActions"
@@ -526,9 +484,6 @@ const LayerUI = ({
setAppState={setAppState} setAppState={setAppState}
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
/> />
{appState.fileHandle && (
<>{actionManager.renderAction("saveToActiveFile")}</>
)}
</Stack.Col> </Stack.Col>
</Island> </Island>
</Section> </Section>
@@ -547,8 +502,7 @@ const LayerUI = ({
style={{ style={{
// we want to make sure this doesn't overflow so substracting 200 // we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer // which is approximately height of zoom footer and top left menu items with some buffer
// if active file name is displayed, subtracting 248 to account for its height maxHeight: `${appState.height - 200}px`,
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
}} }}
> >
<SelectedShapeActions <SelectedShapeActions
@@ -585,8 +539,6 @@ const LayerUI = ({
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer} focusContainer={focusContainer}
library={library} library={library}
theme={appState.theme}
files={files}
id={id} id={id}
/> />
) : null; ) : null;
@@ -614,12 +566,6 @@ const LayerUI = ({
{(heading) => ( {(heading) => (
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row gap={1}> <Stack.Row gap={1}>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<Island <Island
padding={1} padding={1}
className={clsx({ "zen-mode": zenModeEnabled })} className={clsx({ "zen-mode": zenModeEnabled })}
@@ -631,17 +577,15 @@ const LayerUI = ({
canvas={canvas} canvas={canvas}
elementType={appState.elementType} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { isLibraryOpen={appState.isLibraryOpen}
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<LibraryButton <LockIcon
appState={appState} zenModeEnabled={zenModeEnabled}
setAppState={setAppState} checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/> />
</Stack.Row> </Stack.Row>
{libraryMenu} {libraryMenu}
@@ -667,9 +611,7 @@ const LayerUI = ({
label={client.username || "Unknown user"} label={client.username || "Unknown user"}
key={clientId} key={clientId}
> >
{actionManager.renderAction("goToCollaborator", { {actionManager.renderAction("goToCollaborator", clientId)}
id: clientId,
})}
</Tooltip> </Tooltip>
))} ))}
</UserList> </UserList>
@@ -702,16 +644,6 @@ const LayerUI = ({
zoom={appState.zoom} zoom={appState.zoom}
/> />
</Island> </Island>
{!viewModeEnabled && (
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
)}
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
@@ -725,7 +657,6 @@ const LayerUI = ({
> >
{renderCustomFooter?.(false, appState)} {renderCustomFooter?.(false, appState)}
</div> </div>
<div <div
className={clsx( className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition", "layer-ui__wrapper__footer-right zen-mode-transition",
@@ -797,8 +728,6 @@ const LayerUI = ({
renderCustomFooter={renderCustomFooter} renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled} viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
/> />
</> </>
) : ( ) : (
-46
View File
@@ -1,46 +0,0 @@
import React from "react";
import clsx from "clsx";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
const LIBRARY_ICON = (
<svg viewBox="0 0 576 512">
<path
fill="currentColor"
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
></path>
</svg>
);
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}> = ({ appState, setAppState }) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_medium`,
{
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},
)}
title={`${capitalizeString(t("toolBar.library"))} — 0`}
style={{ marginInlineStart: "var(--space-factor)" }}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name="editor-library"
onChange={(event) => {
setAppState({ isLibraryOpen: event.target.checked });
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
/>
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
</label>
);
};
+17 -23
View File
@@ -1,12 +1,12 @@
import clsx from "clsx"; import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons"; import { close } from "../components/icons";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
// fa-plus // fa-plus
@@ -21,44 +21,38 @@ const PLUS_ICON = (
export const LibraryUnit = ({ export const LibraryUnit = ({
elements, elements,
files,
pendingElements, pendingElements,
onRemoveFromLibrary, onRemoveFromLibrary,
onClick, onClick,
}: { }: {
elements?: LibraryItem; elements?: LibraryItem;
files: BinaryFiles;
pendingElements?: LibraryItem; pendingElements?: LibraryItem;
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
onClick: () => void; onClick: () => void;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
const node = ref.current; const elementsToRender = elements || pendingElements;
if (!node) { if (!elementsToRender) {
return; return;
} }
const svg = exportToSvg(elementsToRender, {
(async () => { exportBackground: false,
const elementsToRender = elements || pendingElements; viewBackgroundColor: oc.white,
if (!elementsToRender) { });
return; for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
} }
const svg = await exportToSvg( ref.current!.removeChild(child);
elementsToRender, }
{ ref.current!.appendChild(svg);
exportBackground: false,
viewBackgroundColor: oc.white,
},
files,
);
node.innerHTML = svg.outerHTML;
})();
const current = ref.current!;
return () => { return () => {
node.innerHTML = ""; current.removeChild(svg);
}; };
}, [elements, pendingElements, files]); }, [elements, pendingElements]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
export const LoadingMessage = () => { export const LoadingMessage = () => {
@@ -2,17 +2,20 @@ import "./ToolIcon.scss";
import React from "react"; import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
type LockIconSize = "s" | "m";
type LockIconProps = { type LockIconProps = {
title?: string; title?: string;
name?: string; name?: string;
id?: string;
checked: boolean; checked: boolean;
onChange?(): void; onChange?(): void;
size?: LockIconSize;
zenModeEnabled?: boolean; zenModeEnabled?: boolean;
}; };
const DEFAULT_SIZE: ToolButtonSize = "medium"; const DEFAULT_SIZE: LockIconSize = "m";
const ICONS = { const ICONS = {
CHECKED: ( CHECKED: (
@@ -38,12 +41,12 @@ const ICONS = {
), ),
}; };
export const LockButton = (props: LockIconProps) => { export const LockIcon = (props: LockIconProps) => {
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility", "ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`, `ToolIcon_size_${props.size || DEFAULT_SIZE}`,
{ {
"zen-mode-visibility--hidden": props.zenModeEnabled, "zen-mode-visibility--hidden": props.zenModeEnabled,
}, },
@@ -54,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
className="ToolIcon_type_checkbox" className="ToolIcon_type_checkbox"
type="checkbox" type="checkbox"
name={props.name} name={props.name}
id={props.id}
onChange={props.onChange} onChange={props.onChange}
checked={props.checked} checked={props.checked}
aria-label={props.title} aria-label={props.title}
+7 -20
View File
@@ -13,10 +13,9 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockIcon } from "./LockIcon";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@@ -33,11 +32,6 @@ type MobileMenuProps = {
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean; viewModeEnabled: boolean;
showThemeBtn: boolean; showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@@ -55,8 +49,6 @@ export const MobileMenu = ({
renderCustomFooter, renderCustomFooter,
viewModeEnabled, viewModeEnabled,
showThemeBtn, showThemeBtn,
onImageAction,
renderTopRightUI,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
@@ -72,21 +64,15 @@ export const MobileMenu = ({
canvas={canvas} canvas={canvas}
elementType={appState.elementType} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { isLibraryOpen={appState.isLibraryOpen}
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
{renderTopRightUI && renderTopRightUI(true, appState)} <LockIcon
<LockButton
checked={appState.elementLocked} checked={appState.elementLocked}
onChange={onLockToggle} onChange={onLockToggle}
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<LibraryButton appState={appState} setAppState={setAppState} />
</Stack.Row> </Stack.Row>
{libraryMenu} {libraryMenu}
</Stack.Col> </Stack.Col>
@@ -181,9 +167,10 @@ export const MobileMenu = ({
) )
.map(([clientId, client]) => ( .map(([clientId, client]) => (
<React.Fragment key={clientId}> <React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", { {actionManager.renderAction(
id: clientId, "goToCollaborator",
})} clientId,
)}
</React.Fragment> </React.Fragment>
))} ))}
</UserList> </UserList>
+2 -3
View File
@@ -6,7 +6,6 @@ import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App"; import { useExcalidrawContainer, useIsMobile } from "./App";
import { AppState } from "../types"; import { AppState } from "../types";
import { THEME } from "../constants";
export const Modal = (props: { export const Modal = (props: {
className?: string; className?: string;
@@ -16,7 +15,7 @@ export const Modal = (props: {
labelledBy: string; labelledBy: string;
theme?: AppState["theme"]; theme?: AppState["theme"];
}) => { }) => {
const { theme = THEME.LIGHT } = props; const { theme = "light" } = props;
const modalRoot = useBodyRoot(theme); const modalRoot = useBodyRoot(theme);
if (!modalRoot) { if (!modalRoot) {
@@ -59,7 +58,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
const isMobileRef = useRef(isMobile); const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile; isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer(); const excalidrawContainer = useExcalidrawContainer();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
+10 -16
View File
@@ -34,25 +34,19 @@ const ChartPreviewBtn = (props: {
0, 0,
); );
setChartElements(elements); setChartElements(elements);
let svg: SVGSVGElement;
const svg = exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { previewNode.appendChild(svg);
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
previewNode.appendChild(svg); if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
if (props.selected) { }
(previewNode.parentNode as HTMLDivElement).focus();
}
})();
return () => { return () => {
previewNode.removeChild(svg); previewNode.removeChild(svg);
+38 -32
View File
@@ -1,10 +1,9 @@
import "./TextInput.scss"; import "./TextInput.scss";
import React, { useState } from "react"; import React, { Component } from "react";
import { focusNearestParent } from "../utils"; import { focusNearestParent } from "../utils";
import "./ProjectName.scss"; import "./ProjectName.scss";
import { useExcalidrawContainer } from "./App";
type Props = { type Props = {
value: string; value: string;
@@ -13,19 +12,22 @@ type Props = {
isNameEditable: boolean; isNameEditable: boolean;
}; };
export const ProjectName = (props: Props) => { type State = {
const { id } = useExcalidrawContainer(); fileName: string;
const [fileName, setFileName] = useState<string>(props.value); };
export class ProjectName extends Component<Props, State> {
const handleBlur = (event: any) => { state = {
fileName: this.props.value,
};
private handleBlur = (event: any) => {
focusNearestParent(event.target); focusNearestParent(event.target);
const value = event.target.value; const value = event.target.value;
if (value !== props.value) { if (value !== this.props.value) {
props.onChange(value); this.props.onChange(value);
} }
}; };
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
if (event.nativeEvent.isComposing || event.keyCode === 229) { if (event.nativeEvent.isComposing || event.keyCode === 229) {
@@ -35,25 +37,29 @@ export const ProjectName = (props: Props) => {
} }
}; };
return ( public render() {
<div className="ProjectName"> return (
<label className="ProjectName-label" htmlFor="filename"> <div className="ProjectName">
{`${props.label}${props.isNameEditable ? "" : ":"}`} <label className="ProjectName-label" htmlFor="filename">
</label> {`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
{props.isNameEditable ? ( </label>
<input {this.props.isNameEditable ? (
className="TextInput" <input
onBlur={handleBlur} className="TextInput"
onKeyDown={handleKeyDown} onBlur={this.handleBlur}
id={`${id}-filename`} onKeyDown={this.handleKeyDown}
value={fileName} id="filename"
onChange={(event) => setFileName(event.target.value)} value={this.state.fileName}
/> onChange={(event) =>
) : ( this.setState({ fileName: event.target.value })
<span className="TextInput TextInput--readonly" id={`${id}-filename`}> }
{props.value} />
</span> ) : (
)} <span className="TextInput TextInput--readonly" id="filename">
</div> {this.props.value}
); </span>
}; )}
</div>
);
}
}
+2 -4
View File
@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
interface SectionProps extends React.HTMLProps<HTMLElement> { interface SectionProps extends React.HTMLProps<HTMLElement> {
heading: string; heading: string;
@@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
} }
export const Section = ({ heading, children, ...props }: SectionProps) => { export const Section = ({ heading, children, ...props }: SectionProps) => {
const { id } = useExcalidrawContainer();
const header = ( const header = (
<h2 className="visually-hidden" id={`${id}-${heading}-title`}> <h2 className="visually-hidden" id={`${heading}-title`}>
{t(`headings.${heading}`)} {t(`headings.${heading}`)}
</h2> </h2>
); );
return ( return (
<section {...props} aria-labelledby={`${id}-${heading}-title`}> <section {...props} aria-labelledby={`${heading}-title`}>
{typeof children === "function" ? ( {typeof children === "function" ? (
children(header) children(header)
) : ( ) : (
-48
View File
@@ -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;
}
}
}
-28
View File
@@ -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;
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import { TOAST_TIMEOUT } from "../constants"; import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss"; import "./Toast.scss";
+13 -65
View File
@@ -1,13 +1,9 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React, { useEffect, useRef, useState } from "react"; import React from "react";
import clsx from "clsx"; 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"; type ToolIconSize = "s" | "m";
type ToolButtonBaseProps = { type ToolButtonBaseProps = {
icon?: React.ReactNode; icon?: React.ReactNode;
@@ -18,7 +14,7 @@ type ToolButtonBaseProps = {
title?: string; title?: string;
name?: string; name?: string;
id?: string; id?: string;
size?: ToolButtonSize; size?: ToolIconSize;
keyBindingLabel?: string; keyBindingLabel?: string;
showAriaLabel?: boolean; showAriaLabel?: boolean;
hidden?: boolean; hidden?: boolean;
@@ -31,7 +27,7 @@ type ToolButtonProps =
| (ToolButtonBaseProps & { | (ToolButtonBaseProps & {
type: "button"; type: "button";
children?: React.ReactNode; children?: React.ReactNode;
onClick?(event: React.MouseEvent): void; onClick?(): void;
}) })
| (ToolButtonBaseProps & { | (ToolButtonBaseProps & {
type: "icon"; type: "icon";
@@ -41,46 +37,15 @@ type ToolButtonProps =
| (ToolButtonBaseProps & { | (ToolButtonBaseProps & {
type: "radio"; type: "radio";
checked: boolean; checked: boolean;
onChange?(data: { pointerType: PointerType | null }): void; onChange?(): void;
}); });
const DEFAULT_SIZE: ToolIconSize = "m";
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null); const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current); React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size}`; const sizeCn = `ToolIcon_size_${props.size || DEFAULT_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) {
if (!(error instanceof AbortError)) {
throw error;
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}
};
useEffect(
() => () => {
isMountedRef.current = false;
},
[],
);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (props.type === "button" || props.type === "icon") { if (props.type === "button" || props.type === "icon") {
return ( return (
@@ -103,9 +68,8 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title} title={props.title}
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
type="button" type="button"
onClick={onClick} onClick={props.onClick}
ref={innerRef} ref={innerRef}
disabled={isLoading}
> >
{(props.icon || props.label) && ( {(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true"> <div className="ToolIcon__icon" aria-hidden="true">
@@ -118,9 +82,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
</div> </div>
)} )}
{props.showAriaLabel && ( {props.showAriaLabel && (
<div className="ToolIcon__label"> <div className="ToolIcon__label">{props["aria-label"]}</div>
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)} )}
{props.children} {props.children}
</button> </button>
@@ -128,18 +90,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
} }
return ( return (
<label <label className={clsx("ToolIcon", props.className)} title={props.title}>
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input <input
className={`ToolIcon_type_radio ${sizeCn}`} className={`ToolIcon_type_radio ${sizeCn}`}
type="radio" type="radio"
@@ -147,10 +98,8 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]} aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]} data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`} id={props.id}
onChange={() => { onChange={props.onChange}
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}}
checked={props.checked} checked={props.checked}
ref={innerRef} ref={innerRef}
/> />
@@ -167,5 +116,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
ToolButton.defaultProps = { ToolButton.defaultProps = {
visible: true, visible: true,
className: "", className: "",
size: "medium",
}; };
+14 -47
View File
@@ -8,18 +8,10 @@
position: relative; position: relative;
font-family: Cascadia; font-family: Cascadia;
cursor: pointer; cursor: pointer;
background-color: var(--button-gray-1);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor); border-radius: var(--space-factor);
user-select: none; user-select: none;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
} }
.ToolIcon--plain { .ToolIcon--plain {
@@ -54,21 +46,15 @@
} }
.ToolIcon__label { .ToolIcon__label {
display: flex;
align-items: center;
color: var(--icon-fill-color); color: var(--icon-fill-color);
font-family: var(--ui-font); font-family: var(--ui-font);
margin: 0 0.8em; margin: 0 0.8em;
text-overflow: ellipsis; text-overflow: ellipsis;
.Spinner {
margin-left: 0.6em;
}
} }
.ToolIcon_size_small .ToolIcon__icon { .ToolIcon_size_s .ToolIcon__icon {
width: 2rem; width: 1.4rem;
height: 2rem; height: 1.4rem;
font-size: 0.8em; font-size: 0.8em;
} }
@@ -80,6 +66,14 @@
margin: 0; margin: 0;
font-size: inherit; font-size: inherit;
&:hover {
background-color: var(--button-gray-1);
}
&:active {
background-color: var(--button-gray-2);
}
&:focus { &:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 2px var(--focus-highlight-color);
} }
@@ -92,14 +86,6 @@
} }
} }
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&--show { &--show {
visibility: visible; visibility: visible;
} }
@@ -117,9 +103,6 @@
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon { &:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
background-color: var(--button-gray-2); background-color: var(--button-gray-2);
&:active {
background-color: var(--button-gray-3);
}
} }
&:focus + .ToolIcon__icon { &:focus + .ToolIcon__icon {
@@ -147,21 +130,12 @@
} }
.ToolIcon__icon { .ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
width: 2rem; width: 2rem;
height: 2em; height: 2em;
} }
} }
.ToolIcon.ToolIcon__lock { .ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating { &.ToolIcon_type_floating {
margin-left: 0.1rem; margin-left: 0.1rem;
} }
@@ -192,9 +166,10 @@
// move the lock button out of the way on small viewports // move the lock button out of the way on small viewports
// it begins to collide with the GitHub icon before we switch to mobile mode // it begins to collide with the GitHub icon before we switch to mobile mode
@media (max-width: 760px) { @media (max-width: 760px) {
.ToolIcon.ToolIcon_type_floating { .ToolIcon.ToolIcon__lock {
display: inline-block; display: inline-block;
position: absolute; position: absolute;
top: 60px;
right: -8px; right: -8px;
margin-left: 0; margin-left: 0;
@@ -219,14 +194,6 @@
position: static; position: static;
} }
} }
.ToolIcon.ToolIcon__library {
top: 100px;
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: 0;
top: 60px;
}
} }
.unlocked-icon { .unlocked-icon {
+10 -16
View File
@@ -1,6 +1,4 @@
@import "../css/variables.module"; @import "../css/variables.module";
// container in body where the actual tooltip is appended to
.excalidraw-tooltip { .excalidraw-tooltip {
position: absolute; position: absolute;
z-index: 1000; z-index: 1000;
@@ -26,20 +24,16 @@
} }
} }
// wraps the element we want to apply the tooltip to .excalidraw {
.excalidraw-tooltip-wrapper { .Tooltip-icon {
display: flex; width: 0.9em;
height: 100%; height: 0.9em;
} margin-left: 5px;
margin-top: 1px;
display: flex;
.excalidraw-tooltip-icon { @include isMobile {
width: 0.9em; display: none;
height: 0.9em; }
margin-left: 5px;
margin-top: 1px;
display: flex;
@include isMobile {
display: none;
} }
} }
-1
View File
@@ -74,7 +74,6 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
return ( return (
<div <div
className="excalidraw-tooltip-wrapper"
onPointerEnter={(event) => onPointerEnter={(event) =>
updateTooltip( updateTooltip(
event.currentTarget as HTMLDivElement, event.currentTarget as HTMLDivElement,
+420 -429
View File
File diff suppressed because it is too large Load Diff
+7 -42
View File
@@ -1,6 +1,6 @@
import { FontFamily } from "./element/types";
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types"; import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
@@ -14,7 +14,6 @@ export const CURSOR_TYPE = {
TEXT: "text", TEXT: "text",
CROSSHAIR: "crosshair", CROSSHAIR: "crosshair",
GRABBING: "grabbing", GRABBING: "grabbing",
GRAB: "grab",
POINTER: "pointer", POINTER: "pointer",
MOVE: "move", MOVE: "move",
AUTO: "", AUTO: "",
@@ -35,7 +34,6 @@ export enum EVENT {
MOUSE_MOVE = "mousemove", MOUSE_MOVE = "mousemove",
RESIZE = "resize", RESIZE = "resize",
UNLOAD = "unload", UNLOAD = "unload",
FOCUS = "focus",
BLUR = "blur", BLUR = "blur",
DRAG_OVER = "dragover", DRAG_OVER = "dragover",
DROP = "drop", DROP = "drop",
@@ -65,25 +63,15 @@ export const CLASSES = {
// 1-based in case we ever do `if(element.fontFamily)` // 1-based in case we ever do `if(element.fontFamily)`
export const FONT_FAMILY = { export const FONT_FAMILY = {
Virgil: 1, 1: "Virgil",
Helvetica: 2, 2: "Helvetica",
Cascadia: 3, 3: "Cascadia",
REDACTED_REGULAR: 4, } as const;
REDACTED_SCRIPT_REGULAR: 5,
REDACTED_SCRIPT_BOLD: 6,
SCRIBBLE: 7,
BLOKK: 8,
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_FONT_FAMILY: FontFamily = 1;
export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}"; export const DEFAULT_VERSION = "{version}";
@@ -95,13 +83,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = { export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+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 = { export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw", excalidraw: "excalidraw",
@@ -116,7 +98,6 @@ export const STORAGE_KEYS = {
} as const; } as const;
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
@@ -163,19 +144,3 @@ export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500; export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const 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";
+27 -24
View File
@@ -51,12 +51,11 @@
image-rendering: -moz-crisp-edges; // FF image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas); z-index: var(--zIndex-canvas);
// Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
} }
&__canvas { #canvas {
// Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
position: absolute; position: absolute;
} }
@@ -414,6 +413,22 @@
&:active { &:active {
background-color: var(--button-gray-2); background-color: var(--button-gray-2);
} }
&.dropdown-select--floating {
margin: 0.5em;
}
}
.dropdown-select__language.dropdown-select--floating {
bottom: 10px;
:root[dir="ltr"] & {
right: 44px;
}
:root[dir="rtl"] & {
left: 44px;
}
} }
.zIndexButton { .zIndexButton {
@@ -440,38 +455,26 @@
} }
.help-icon { .help-icon {
display: flex;
cursor: pointer; cursor: pointer;
fill: $oc-gray-6; fill: $oc-gray-6;
width: 1.5rem;
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-top: 5px;
background: none; background: none;
color: var(--icon-fill-color); color: var(--icon-fill-color);
svg {
width: 1.5rem;
height: 1.5rem;
}
&:hover { &:hover {
background: none; background: none;
} }
}
.reset-zoom-button { :root[dir="ltr"] & {
padding: 0.2em; margin-right: 14px;
background: transparent; }
color: var(--text-primary-color);
font-family: var(--ui-font);
}
.undo-redo-buttons { :root[dir="rtl"] & {
display: grid; margin-left: 14px;
grid-auto-flow: column; }
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
} }
@include isMobile { @include isMobile {
+13 -153
View File
@@ -1,17 +1,10 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { import { EXPORT_DATA_TYPES } from "../constants";
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL } from "../types"; import { AppState } from "../types";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json"; import { isValidExcalidrawData } from "./json";
import { restore } from "./restore"; import { restore } from "./restore";
import { ImportedLibraryData } from "./types"; import { ImportedLibraryData } from "./types";
@@ -19,22 +12,16 @@ import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => { const parseFileContents = async (blob: Blob | File) => {
let contents: string; let contents: string;
if (blob.type === MIME_TYPES.png) { if (blob.type === "image/png") {
try { try {
return await ( return await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
).decodePngMetadata(blob); ).decodePngMetadata(blob);
} catch (error) { } catch (error) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new Error(t("alerts.imageDoesNotContainScene"));
t("alerts.imageDoesNotContainScene"),
"EncodingError",
);
} else { } else {
throw new DOMException( throw new Error(t("alerts.cannotRestoreFromImage"));
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
} }
} }
} else { } else {
@@ -51,7 +38,7 @@ const parseFileContents = async (blob: Blob | File) => {
}; };
}); });
} }
if (blob.type === MIME_TYPES.svg) { if (blob.type === "image/svg+xml") {
try { try {
return await ( return await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
@@ -60,15 +47,9 @@ const parseFileContents = async (blob: Blob | File) => {
}); });
} catch (error) { } catch (error) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new Error(t("alerts.imageDoesNotContainScene"));
t("alerts.imageDoesNotContainScene"),
"EncodingError",
);
} else { } else {
throw new DOMException( throw new Error(t("alerts.cannotRestoreFromImage"));
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
} }
} }
} }
@@ -87,50 +68,21 @@ export const getMimeType = (blob: Blob | string): string => {
name = blob.name || ""; name = blob.name || "";
} }
if (/\.(excalidraw|json)$/.test(name)) { if (/\.(excalidraw|json)$/.test(name)) {
return MIME_TYPES.json; return "application/json";
} else if (/\.png$/.test(name)) { } else if (/\.png$/.test(name)) {
return MIME_TYPES.png; return "image/png";
} else if (/\.jpe?g$/.test(name)) { } else if (/\.jpe?g$/.test(name)) {
return MIME_TYPES.jpg; return "image/jpeg";
} else if (/\.svg$/.test(name)) { } else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg; return "image/svg+xml";
} }
return ""; return "";
}; };
export const getFileHandleType = (handle: FileSystemHandle | null) => {
if (!handle) {
return null;
}
return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
};
export const isImageFileHandleType = (
type: string | null,
): type is "png" | "svg" => {
return type === "png" || type === "svg";
};
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
const type = getFileHandleType(handle);
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 ( export const loadFromBlob = async (
blob: Blob, blob: Blob,
/** @see restore.localAppState */ /** @see restore.localAppState */
localAppState: AppState | null, localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
try { try {
@@ -143,16 +95,14 @@ export const loadFromBlob = async (
elements: clearElementsForExport(data.elements || []), elements: clearElementsForExport(data.elements || []),
appState: { appState: {
theme: localAppState?.theme, theme: localAppState?.theme,
fileHandle: blob.handle || null, fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
...cleanAppStateForExport(data.appState || {}), ...cleanAppStateForExport(data.appState || {}),
...(localAppState ...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null) ? calculateScrollCenter(data.elements || [], localAppState, null)
: {}), : {}),
}, },
files: data.files,
}, },
localAppState, localAppState,
localElements,
); );
return result; return result;
@@ -192,93 +142,3 @@ export const canvasToBlob = async (
} }
}); });
}; };
/** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File) => {
let id: FileId;
try {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-1",
await file.arrayBuffer(),
);
id =
// convert buffer to byte array
Array.from(new Uint8Array(hashBuffer))
// convert to hex string
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("") as FileId;
} catch (error) {
console.error(error);
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
id = nanoid(40) as FileId;
}
return id;
};
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,
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"] }),
});
const fileType = file.type;
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
}
return new File(
[await reduce.toBlob(file, { max: 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 };
};
+4 -267
View File
@@ -1,19 +1,16 @@
import { deflate, inflate } from "pako"; import { deflate, inflate } from "pako";
import { encryptData, decryptData } from "./encryption";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// byte (binary) strings // byte (binary) strings
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// fast, Buffer-compatible implem // fast, Buffer-compatible implem
export const toByteString = ( export const toByteString = (data: string | Uint8Array): Promise<string> => {
data: string | Uint8Array | ArrayBuffer,
): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const blob = const blob =
typeof data === "string" typeof data === "string"
? new Blob([new TextEncoder().encode(data)]) ? new Blob([new TextEncoder().encode(data)])
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]); : new Blob([data]);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
if (!event.target || typeof event.target.result !== "string") { if (!event.target || typeof event.target.result !== "string") {
@@ -47,14 +44,12 @@ const byteStringToString = (byteString: string) => {
* due to reencoding * due to reencoding
*/ */
export const stringToBase64 = async (str: string, isByteString = false) => { 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 // async to align with stringToBase64
export const base64ToString = async (base64: string, isByteString = false) => { export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString return isByteString ? atob(base64) : byteStringToString(atob(base64));
? window.atob(base64)
: byteStringToString(window.atob(base64));
}; };
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -119,261 +114,3 @@ export const decode = async (data: EncodedData): Promise<string> => {
return decoded; 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) {
console.error(
`Error during decompressing and decrypting the file.`,
encodingMetadata,
);
throw error;
}
};
// -----------------------------------------------------------------------------
-79
View File
@@ -1,79 +0,0 @@
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 () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const getImportedKey = (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: 128,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string,
data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt");
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;
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 getImportedKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
-114
View File
@@ -1,114 +0,0 @@
import {
FileWithHandle,
fileOpen as _fileOpen,
fileSave as _fileSave,
FileSystemHandle,
supported as nativeFileSystemSupported,
} from "browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const INPUT_CHANGE_INTERVAL_MS = 500;
export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[];
description?: string;
multiple?: M;
}): Promise<
M extends false | undefined ? FileWithHandle : FileWithHandle[]
> => {
// an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined
? FileWithHandle
: FileWithHandle[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]);
return mimeTypes;
}, [] as string[]);
const extensions = opts.extensions?.reduce((acc, ext) => {
if (ext === "jpg") {
return acc.concat(".jpg", ".jpeg");
}
return acc.concat(`.${ext}`);
}, [] as string[]);
return _fileOpen({
description: opts.description,
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};
},
}) as Promise<RetType>;
};
export const fileSave = (
blob: Blob,
opts: {
/** supply without the extension */
name: string;
/** file extension */
extension: FILE_EXTENSION;
description?: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
},
) => {
return _fileSave(
blob,
{
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
},
opts.fileHandle,
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };
+1 -1
View File
@@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
// insert metadata before last chunk (iEND) // insert metadata before last chunk (iEND)
chunks.splice(-1, 0, metadataChunk); 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) => { export const decodePngMetadata = async (blob: Blob) => {
+33 -34
View File
@@ -1,15 +1,14 @@
import { fileSave } from "browser-fs-access";
import { import {
copyBlobToClipboardAsPng, copyBlobToClipboardAsPng,
copyTextToSystemClipboard, copyTextToSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export"; import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppState, BinaryFiles } from "../types"; import { AppState } from "../types";
import { canvasToBlob } from "./blob"; import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
export { loadFromBlob } from "./blob"; export { loadFromBlob } from "./blob";
@@ -19,56 +18,56 @@ export const exportCanvas = async (
type: ExportType, type: ExportType,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles,
{ {
exportBackground, exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = 10,
viewBackgroundColor, viewBackgroundColor,
name, name,
fileHandle = null, scale = 1,
}: { }: {
exportBackground: boolean; exportBackground: boolean;
exportPadding?: number; exportPadding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
name: string; name: string;
fileHandle?: FileSystemHandle | null; scale?: number;
}, },
) => { ) => {
if (elements.length === 0) { if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const tempSvg = await exportToSvg( const tempSvg = exportToSvg(elements, {
elements, exportBackground,
{ exportWithDarkMode: appState.exportWithDarkMode,
exportBackground, viewBackgroundColor,
exportWithDarkMode: appState.exportWithDarkMode, exportPadding,
viewBackgroundColor, scale,
exportPadding, metadata:
exportScale: appState.exportScale, appState.exportEmbedScene && type === "svg"
exportEmbedScene: appState.exportEmbedScene && type === "svg", ? await (
}, await import(/* webpackChunkName: "image" */ "./image")
files, ).encodeSvgMetadata({
); text: serializeAsJSON(elements, appState),
})
: undefined,
});
if (type === "svg") { if (type === "svg") {
return await fileSave( await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }), fileName: `${name}.svg`,
{ extensions: [".svg"],
name, });
extension: "svg", return;
fileHandle,
},
);
} else if (type === "clipboard-svg") { } else if (type === "clipboard-svg") {
await copyTextToSystemClipboard(tempSvg.outerHTML); copyTextToSystemClipboard(tempSvg.outerHTML);
return; return;
} }
} }
const tempCanvas = await exportToCanvas(elements, appState, files, { const tempCanvas = exportToCanvas(elements, appState, {
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
exportPadding, exportPadding,
scale,
}); });
tempCanvas.style.display = "none"; tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas); document.body.appendChild(tempCanvas);
@@ -76,19 +75,19 @@ export const exportCanvas = async (
tempCanvas.remove(); tempCanvas.remove();
if (type === "png") { if (type === "png") {
const fileName = `${name}.png`;
if (appState.exportEmbedScene) { if (appState.exportEmbedScene) {
blob = await ( blob = await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
).encodePngMetadata({ ).encodePngMetadata({
blob, blob,
metadata: serializeAsJSON(elements, appState, files, "local"), metadata: serializeAsJSON(elements, appState),
}); });
} }
return await fileSave(blob, { await fileSave(blob, {
name, fileName,
extension: "png", extensions: [".png"],
fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
+38 -68
View File
@@ -1,10 +1,10 @@
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants"; 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 { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types"; import { AppState } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob"; import { loadFromBlob } from "./blob";
import { import {
ExportedDataState, ExportedDataState,
@@ -13,50 +13,16 @@ import {
} from "./types"; } from "./types";
import Library from "./library"; 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 = ( export const serializeAsJSON = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Partial<AppState>, appState: AppState,
files: BinaryFiles,
type: "local" | "database",
): string => { ): string => {
const data: ExportedDataState = { const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw, type: EXPORT_DATA_TYPES.excalidraw,
version: 2, version: 2,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
elements: elements: clearElementsForExport(elements),
type === "local" appState: cleanAppStateForExport(appState),
? clearElementsForExport(elements)
: clearElementsForDatabase(elements),
appState:
type === "local"
? cleanAppStateForExport(appState)
: clearAppStateForDatabase(appState),
files:
type === "local"
? filterOutDeletedFiles(elements, files)
: // will be stripped from JSON
undefined,
}; };
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
@@ -65,35 +31,40 @@ export const serializeAsJSON = (
export const saveAsJSON = async ( export const saveAsJSON = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles,
) => { ) => {
const serialized = serializeAsJSON(elements, appState, files, "local"); const serialized = serializeAsJSON(elements, appState);
const blob = new Blob([serialized], { const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
}); });
const fileHandle = await fileSave(blob, { const fileHandle = await fileSave(
name: appState.name, blob,
extension: "excalidraw", {
description: "Excalidraw file", fileName: `${appState.name}.excalidraw`,
fileHandle: isImageFileHandle(appState.fileHandle) description: "Excalidraw file",
? null extensions: [".excalidraw"],
: appState.fileHandle, },
}); appState.fileHandle,
);
return { fileHandle }; return { fileHandle };
}; };
export const loadFromJSON = async ( export const loadFromJSON = async (localAppState: AppState) => {
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
) => {
const blob = await fileOpen({ const blob = await fileOpen({
description: "Excalidraw files", description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files. // 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); return loadFromBlob(blob, localAppState);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {
@@ -127,16 +98,15 @@ export const saveLibraryAsJSON = async (library: Library) => {
library: libraryItems, library: libraryItems,
}; };
const serialized = JSON.stringify(data, null, 2); const serialized = JSON.stringify(data, null, 2);
await fileSave( const fileName = "library.excalidrawlib";
new Blob([serialized], { const blob = new Blob([serialized], {
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}), });
{ await fileSave(blob, {
name: "library", fileName,
extension: "excalidrawlib", description: "Excalidraw library file",
description: "Excalidraw library file", extensions: [".excalidrawlib"],
}, });
);
}; };
export const importLibraryFromJSON = async (library: Library) => { export const importLibraryFromJSON = async (library: Library) => {
+2 -2
View File
@@ -2,7 +2,7 @@ import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreElements } from "./restore"; import { restoreElements } from "./restore";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type App from "../components/App"; import App from "../components/App";
class Library { class Library {
private libraryCache: LibraryItems | null = null; private libraryCache: LibraryItems | null = null;
@@ -18,7 +18,7 @@ class Library {
}; };
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(restoreElements(libraryItem, null)); const elements = getNonDeletedElements(restoreElements(libraryItem));
return elements.length ? elements : null; return elements.length ? elements : null;
}; };
-40
View File
@@ -1,40 +0,0 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
const fileHandleType = getFileHandleType(fileHandle);
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
throw new Error(
"fileHandle should exist and should be of type svg or png when resaving",
);
}
appState = {
...appState,
exportEmbedScene: true,
};
await exportCanvas(
fileHandleType,
getNonDeletedElements(elements),
appState,
files,
{
exportBackground,
viewBackgroundColor,
name,
fileHandle,
},
);
return { fileHandle };
};
+26 -51
View File
@@ -1,26 +1,21 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
FontFamily,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
FontFamilyValues,
} from "../element/types"; } from "../element/types";
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
getElementMap,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
FONT_FAMILY,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
FONT_FAMILY,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -37,7 +32,6 @@ export const AllowedExcalidrawElementTypes: Record<
diamond: true, diamond: true,
ellipse: true, ellipse: true,
line: true, line: true,
image: true,
arrow: true, arrow: true,
freedraw: true, freedraw: true,
}; };
@@ -45,33 +39,29 @@ export const AllowedExcalidrawElementTypes: Record<
export type RestoredDataState = { export type RestoredDataState = {
elements: ExcalidrawElement[]; elements: ExcalidrawElement[];
appState: RestoredAppState; appState: RestoredAppState;
files: BinaryFiles;
}; };
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) { for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
return FONT_FAMILY[ if (fontFamilyString.includes(fontFamilyName)) {
fontFamilyName as keyof typeof FONT_FAMILY return parseInt(id) as FontFamily;
] as FontFamilyValues; }
} }
return DEFAULT_FONT_FAMILY; return DEFAULT_FONT_FAMILY;
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends ExcalidrawElement, 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>, element: Required<T>,
extra: Pick< extra: Pick<T, K>,
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">>,
): T => { ): T => {
const base: Pick<T, keyof ExcalidrawElement> = { 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 // all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements // newly added elements
version: element.version || 1, version: element.version || 1,
@@ -84,8 +74,8 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? 1, roughness: element.roughness ?? 1,
opacity: element.opacity == null ? 100 : element.opacity, opacity: element.opacity == null ? 100 : element.opacity,
angle: element.angle || 0, angle: element.angle || 0,
x: extra.x ?? element.x ?? 0, x: (extra as Partial<T>).x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0, y: (extra as Partial<T>).y ?? element.y ?? 0,
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor,
width: element.width || 0, width: element.width || 0,
@@ -107,7 +97,7 @@ const restoreElementWithProperties = <
const restoreElement = ( const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element | null => { ): typeof element => {
switch (element.type) { switch (element.type) {
case "text": case "text":
let fontSize = element.fontSize; let fontSize = element.fontSize;
@@ -136,12 +126,6 @@ const restoreElement = (
pressures: element.pressures, pressures: element.pressures,
}); });
} }
case "image":
return restoreElementWithProperties(element, {
status: element.status || "pending",
fileId: element.fileId,
scale: element.scale || [1, 1],
});
case "line": case "line":
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
@@ -197,20 +181,13 @@ const restoreElement = (
export const restoreElements = ( export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const localElementsMap = localElements ? getElementMap(localElements) : null;
return (elements || []).reduce((elements, element) => { return (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) { if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element); const migratedElement = restoreElement(element);
if (migratedElement) { if (migratedElement) {
const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
elements.push(migratedElement); elements.push(migratedElement);
} }
} }
@@ -220,25 +197,25 @@ export const restoreElements = (
export const restoreAppState = ( export const restoreAppState = (
appState: ImportedDataState["appState"], appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null,
): RestoredAppState => { ): RestoredAppState => {
appState = appState || {}; appState = appState || {};
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState; const nextAppState = {} as typeof defaultAppState;
for (const [key, defaultValue] of Object.entries(defaultAppState) as [ for (const [key, val] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState, keyof typeof defaultAppState,
any, any,
][]) { ][]) {
const suppliedValue = appState[key]; const restoredValue = appState[key];
const localValue = localAppState ? localAppState[key] : undefined; const localValue = localAppState ? localAppState[key] : undefined;
(nextAppState as any)[key] = (nextAppState as any)[key] =
suppliedValue !== undefined restoredValue !== undefined
? suppliedValue ? restoredValue
: localValue !== undefined : localValue !== undefined
? localValue ? localValue
: defaultValue; : val;
} }
return { return {
@@ -266,11 +243,9 @@ export const restore = (
* Supply `null` if you can't get access to it. * Supply `null` if you can't get access to it.
*/ */
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
): RestoredDataState => { ): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements, localElements), elements: restoreElements(data?.elements),
appState: restoreAppState(data?.appState, localAppState || null), appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
}; };
}; };

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