Compare commits

..

8 Commits

Author SHA1 Message Date
Aakansha Doshi e9d5b41fbf tweaks
Co-authored-by: David Luzar <luzar.david@gmail.com>
2021-04-11 19:01:38 +05:30
Aakansha Doshi ef5614b697 Update src/packages/excalidraw/CHANGELOG.md 2021-04-11 14:26:50 +05:30
Aakansha Doshi cd5d9111e9 tweak docs 2021-04-11 14:20:24 +05:30
Aakansha Doshi 8467093e83 update 2021-04-11 14:16:00 +05:30
Aakansha Doshi 504ea987b0 update docs 2021-04-11 14:08:50 +05:30
Aakansha Doshi ac08f433e9 update prop name to detectPosition 2021-04-11 13:49:40 +05:30
Aakansha Doshi 4fb906baad disable detectPositionChange by default 2021-04-11 01:40:46 +05:30
Aakansha Doshi bfc8415554 feat: poll to detect if position of excalidraw was updated and allow consumer to disable it 2021-04-10 03:09:56 +05:30
233 changed files with 6858 additions and 15643 deletions
@@ -1,26 +0,0 @@
name: Auto release @excalidraw/excalidraw-next
on:
push:
branches:
- master
jobs:
Auto-release-excalidraw-next:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release
run: |
yarn autorelease
-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 -17
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
@@ -104,20 +102,6 @@ These instructions will get you a copy of the project up and running on your loc
git clone https://github.com/excalidraw/excalidraw.git git clone https://github.com/excalidraw/excalidraw.git
``` ```
#### Install the dependencies
```bash
yarn
```
#### Start the server
```bash
yarn start
```
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Commands #### Commands
| Command | Description | | Command | Description |
-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;
}
}
}
}
+7 -11
View File
@@ -19,25 +19,22 @@
] ]
}, },
"dependencies": { "dependencies": {
"@dwelle/browser-fs-access": "0.21.1",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10", "@testing-library/jest-dom": "5.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/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.16.4",
"clsx": "1.1.1", "clsx": "1.1.1",
"firebase": "8.3.3", "firebase": "8.3.2",
"i18next-browser-languagedetector": "6.1.0", "i18next-browser-languagedetector": "6.1.0",
"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",
"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",
@@ -46,8 +43,8 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.4.1", "roughjs": "4.3.1",
"sass": "1.32.10", "sass": "1.32.8",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.2.4" "typescript": "4.2.4"
}, },
@@ -57,7 +54,7 @@
"@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",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.1.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0", "firebase-tools": "9.9.0",
"husky": "4.3.8", "husky": "4.3.8",
@@ -78,7 +75,7 @@
}, },
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
], ],
"resetMocks": false "resetMocks": false
}, },
@@ -106,7 +103,6 @@
"test:other": "yarn prettier --list-different", "test:other": "yarn prettier --list-different",
"test:typecheck": "tsc", "test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false", "test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app", "test": "yarn test:app"
"autorelease": "node scripts/autorelease.js"
} }
} }
Binary file not shown.
+3 -33
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"
@@ -119,17 +107,15 @@
<!-- FIXME: remove this when we update CRA (fix SW caching) --> <!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style> <style>
body, body {
html {
margin: 0; margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif; Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font); font-family: var(--ui-font);
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
width: 100%; width: 100vw;
height: 100%; height: 100vh;
overflow: hidden;
} }
.visually-hidden { .visually-hidden {
@@ -139,7 +125,6 @@
overflow: hidden; overflow: hidden;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */ white-space: nowrap; /* added line */
user-select: none;
} }
.LoadingMessage { .LoadingMessage {
@@ -164,21 +149,6 @@
} }
#root { #root {
height: 100%; height: 100%;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@media screen and (min-width: 1200px) {
-webkit-touch-callout: default;
-webkit-user-select: auto;
-khtml-user-select: auto;
-moz-user-select: auto;
-ms-user-select: auto;
user-select: auto;
}
} }
</style> </style>
</head> </head>
+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",
-51
View File
@@ -1,51 +0,0 @@
const fs = require("fs");
const { exec, execSync } = require("child_process");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const publish = () => {
try {
execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`);
} catch (e) {
console.error(e);
}
};
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => {
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
});
if (!excalidrawPackageFiles.length) {
process.exit(0);
}
// update package.json
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
pkg.name = "@excalidraw/excalidraw-next";
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
// update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
publish();
});
-9
View File
@@ -37,9 +37,6 @@ const crowdinMap = {
"uk-UA": "en-uk", "uk-UA": "en-uk",
"zh-CN": "en-zhcn", "zh-CN": "en-zhcn",
"zh-TW": "en-zhtw", "zh-TW": "en-zhtw",
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
}; };
const flags = { const flags = {
@@ -77,9 +74,6 @@ const flags = {
"uk-UA": "🇺🇦", "uk-UA": "🇺🇦",
"zh-CN": "🇨🇳", "zh-CN": "🇨🇳",
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿",
"kk-KZ": "🇰🇿",
}; };
const languages = { const languages = {
@@ -117,9 +111,6 @@ const languages = {
"uk-UA": "Українська", "uk-UA": "Українська",
"zh-CN": "简体中文", "zh-CN": "简体中文",
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"lv-LV": "Latviešu",
"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;
+4 -6
View File
@@ -2,20 +2,18 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement } from "../element/newElement";
import { Library } from "../data/library";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
perform: (elements, appState, _, app) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
); );
app.library.loadLibrary().then((items) => { Library.loadLibrary().then((items) => {
app.library.saveLibrary([ Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
...items,
selectedElements.map(deepCopyElement),
]);
}); });
return false; return false;
}, },
+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,
+17 -53
View File
@@ -1,9 +1,9 @@
import React from "react";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { trash, 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 { ZOOM_STEP } from "../constants";
import { THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
@@ -16,14 +16,13 @@ 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";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, viewBackgroundColor: value },
commitToHistory: !!value.viewBackgroundColor, commitToHistory: true,
}; };
}, },
PanelComponent: ({ appState, updateData }) => { PanelComponent: ({ appState, updateData }) => {
@@ -33,11 +32,7 @@ export const actionChangeViewBackgroundColor = register({
label={t("labels.canvasBackground")} label={t("labels.canvasBackground")}
type="canvasBackground" type="canvasBackground"
color={appState.viewBackgroundColor} color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })} onChange={(color) => updateData(color)}
isActive={appState.openPopup === "canvasColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "canvasColorPicker" : null })
}
data-testid="canvas-background-picker" data-testid="canvas-background-picker"
/> />
</div> </div>
@@ -59,6 +54,7 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats, showStats: appState.showStats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
}, },
@@ -108,7 +104,6 @@ export const actionZoomIn = register({
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@@ -143,7 +138,6 @@ export const actionZoomOut = register({
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@@ -170,21 +164,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) &&
@@ -271,28 +260,3 @@ export const actionZoomToFit = register({
!event.altKey && !event.altKey &&
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],
}); });
export const actionToggleTheme = register({
name: "toggleTheme",
perform: (_, appState, value) => {
return {
appState: {
...appState,
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
updateData(theme);
}}
/>
</div>
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});
+2
View File
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
? selectedElements ? selectedElements
: getNonDeletedElements(elements), : getNonDeletedElements(elements),
appState, appState,
app.canvas,
appState, appState,
); );
return { return {
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
? selectedElements ? selectedElements
: getNonDeletedElements(elements), : getNonDeletedElements(elements),
appState, appState,
app.canvas,
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";
+64 -91
View File
@@ -1,25 +1,17 @@
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 { CheckboxItem } from "../components/CheckboxItem"; import { supported } from "browser-fs-access";
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 +31,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) => {
@@ -96,12 +40,14 @@ export const actionChangeExportBackground = register({
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<CheckboxItem <label>
checked={appState.exportBackground} <input
onChange={(checked) => updateData(checked)} type="checkbox"
> checked={appState.exportBackground}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.withBackground")} {t("labels.withBackground")}
</CheckboxItem> </label>
), ),
}); });
@@ -114,35 +60,57 @@ export const actionChangeExportEmbedScene = register({
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<CheckboxItem <label style={{ display: "flex" }}>
checked={appState.exportEmbedScene} <input
onChange={(checked) => updateData(checked)} type="checkbox"
> checked={appState.exportEmbedScene}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")} {t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}> <Tooltip
<div className="excalidraw-tooltip-icon">{questionCircle}</div> label={t("labels.exportEmbedScene_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip> </Tooltip>
</CheckboxItem> </label>
), ),
}); });
export const actionSaveToActiveFile = register({ export const actionChangeShouldAddWatermark = register({
name: "saveToActiveFile", name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, shouldAddWatermark: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<label>
<input
type="checkbox"
checked={appState.shouldAddWatermark}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.addWatermark")}
</label>
),
});
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => { 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)
: await saveAsJSON(elements, appState);
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,16 +128,21 @@ 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="button"
fileName={appState.fileHandle?.name} icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()}
onClick={() => updateData(null)}
data-testid="save-button"
/> />
), ),
}); });
export const actionSaveFileToDisk = register({ export const actionSaveAsScene = register({
name: "saveFileToDisk", name: "saveAsScene",
perform: async (elements, appState, value) => { perform: async (elements, appState, value) => {
try { try {
const { fileHandle } = await saveAsJSON(elements, { const { fileHandle } = await saveAsJSON(elements, {
@@ -193,7 +166,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={!supported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
/> />
@@ -207,7 +180,7 @@ export const actionLoadScene = register({
const { const {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
} = await loadFromJSON(appState, elements); } = await loadFromJSON(appState);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
@@ -256,9 +229,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")}
/> />
+11 -12
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";
@@ -17,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => { perform: (elements, appState, _, { canvas }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
elementId, elementId,
@@ -50,19 +51,19 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); window.document.activeElement.blur();
} }
const multiPointElement = appState.multiElement const multiPointElement = appState.multiElement
? appState.multiElement ? appState.multiElement
: appState.editingElement?.type === "freedraw" : appState.editingElement?.type === "draw"
? appState.editingElement ? appState.editingElement
: null; : null;
if (multiPointElement) { if (multiPointElement) {
// pen and mouse have hover // pen and mouse have hover
if ( if (
multiPointElement.type !== "freedraw" && multiPointElement.type !== "draw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = multiPointElement; const { points, lastCommittedPoint } = multiPointElement;
@@ -85,7 +86,7 @@ export const actionFinalize = register({
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if ( if (
multiPointElement.type === "line" || multiPointElement.type === "line" ||
multiPointElement.type === "freedraw" multiPointElement.type === "draw"
) { ) {
if (isLoop) { if (isLoop) {
const linePoints = multiPointElement.points; const linePoints = multiPointElement.points;
@@ -117,24 +118,22 @@ export const actionFinalize = register({
); );
} }
if (!appState.elementLocked && appState.elementType !== "freedraw") { if (!appState.elementLocked && appState.elementType !== "draw") {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
} }
if ( if (
(!appState.elementLocked && appState.elementType !== "freedraw") || (!appState.elementLocked && appState.elementType !== "draw") ||
!multiPointElement !multiPointElement
) { ) {
resetCursor(canvas); resetCursor(canvas);
} }
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
...appState, ...appState,
elementType: elementType:
(appState.elementLocked || appState.elementType === "freedraw") && (appState.elementLocked || appState.elementType === "draw") &&
multiPointElement multiPointElement
? appState.elementType ? appState.elementType
: "selection", : "selection",
@@ -146,14 +145,14 @@ export const actionFinalize = register({
selectedElementIds: selectedElementIds:
multiPointElement && multiPointElement &&
!appState.elementLocked && !appState.elementLocked &&
appState.elementType !== "freedraw" appState.elementType !== "draw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
}, },
commitToHistory: appState.elementType === "freedraw", commitToHistory: appState.elementType === "draw",
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
+2 -2
View File
@@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types"; import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles"; import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding"; import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@@ -114,7 +114,7 @@ const flipElement = (
const originalAngle = normalizeAngle(element.angle); const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0; let finalOffsetX = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element)) {
finalOffsetX = finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width; element.width;
+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";
+5 -6
View File
@@ -1,8 +1,9 @@
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";
import History, { HistoryEntry } from "../history"; import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isWindows, KEYS } from "../keys"; import { isWindows, KEYS } from "../keys";
@@ -58,7 +59,7 @@ const writeData = (
return { commitToHistory }; return { commitToHistory };
}; };
type ActionCreator = (history: History) => Action; type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
@@ -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,
+2 -4
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";
@@ -69,10 +70,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState) => {
if (appState.showHelpDialog) {
focusContainer();
}
return { return {
appState: { appState: {
...appState, ...appState,
+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;
} }
+32 -57
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";
@@ -12,13 +13,6 @@ import {
FillCrossHatchIcon, FillCrossHatchIcon,
FillHachureIcon, FillHachureIcon,
FillSolidIcon, FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon, SloppinessArchitectIcon,
SloppinessArtistIcon, SloppinessArtistIcon,
SloppinessCartoonistIcon, SloppinessCartoonistIcon,
@@ -26,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,
@@ -47,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";
@@ -102,18 +99,13 @@ export const actionChangeStrokeColor = register({
name: "changeStrokeColor", name: "changeStrokeColor",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemStrokeColor && { elements: changeProperty(elements, appState, (el) =>
elements: changeProperty(elements, appState, (el) => newElementWith(el, {
newElementWith(el, { strokeColor: value,
strokeColor: value.currentItemStrokeColor, }),
}), ),
), appState: { ...appState, currentItemStrokeColor: value },
}), commitToHistory: true,
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -128,11 +120,7 @@ export const actionChangeStrokeColor = register({
(element) => element.strokeColor, (element) => element.strokeColor,
appState.currentItemStrokeColor, appState.currentItemStrokeColor,
)} )}
onChange={(color) => updateData({ currentItemStrokeColor: color })} onChange={updateData}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
/> />
</> </>
), ),
@@ -142,18 +130,13 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor", name: "changeBackgroundColor",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemBackgroundColor && { elements: changeProperty(elements, appState, (el) =>
elements: changeProperty(elements, appState, (el) => newElementWith(el, {
newElementWith(el, { backgroundColor: value,
backgroundColor: value.currentItemBackgroundColor, }),
}), ),
), appState: { ...appState, currentItemBackgroundColor: value },
}), commitToHistory: true,
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -168,11 +151,7 @@ export const actionChangeBackgroundColor = register({
(element) => element.backgroundColor, (element) => element.backgroundColor,
appState.currentItemBackgroundColor, appState.currentItemBackgroundColor,
)} )}
onChange={(color) => updateData({ currentItemBackgroundColor: color })} onChange={updateData}
isActive={appState.openPopup === "backgroundColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
}
/> />
</> </>
), ),
@@ -502,23 +481,19 @@ 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;
icon: JSX.Element;
}[] = [
{ {
value: FONT_FAMILY.Virgil, value: 1,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />, icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
}, },
{ {
value: FONT_FAMILY.Helvetica, value: 2,
text: t("labels.normal"), text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />, icon: <FontFamilyNormalIcon theme={appState.theme} />,
}, },
{ {
value: FONT_FAMILY.Cascadia, value: 3,
text: t("labels.code"), text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />, icon: <FontFamilyCodeIcon theme={appState.theme} />,
}, },
@@ -527,7 +502,7 @@ export const actionChangeFontFamily = register({
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false> <ButtonIconSelect<FontFamily | false>
group="font-family" group="font-family"
options={options} options={options}
value={getFormValue( value={getFormValue(
-3
View File
@@ -1,5 +1,4 @@
import { register } from "./register"; import { register } from "./register";
import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
@@ -14,6 +13,4 @@ export const actionToggleStats = register({
}, },
checked: (appState) => appState.showStats, checked: (appState) => appState.showStats,
contextItemLabel: "stats.title", contextItemLabel: "stats.title",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
}); });
+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,
}; };
+2 -3
View File
@@ -26,7 +26,6 @@ export {
actionZoomOut, actionZoomOut,
actionResetZoom, actionResetZoom,
actionZoomToFit, actionZoomToFit,
actionToggleTheme,
} from "./actionCanvas"; } from "./actionCanvas";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";
@@ -34,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
export { export {
actionChangeProjectName, actionChangeProjectName,
actionChangeExportBackground, actionChangeExportBackground,
actionSaveToActiveFile, actionSaveScene,
actionSaveFileToDisk, actionSaveAsScene,
actionLoadScene, actionLoadScene,
} from "./actionExport"; } from "./actionExport";
+8 -14
View File
@@ -5,21 +5,14 @@ import {
UpdaterFn, UpdaterFn,
ActionName, ActionName,
ActionResult, ActionResult,
PanelComponentProps,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppProps, 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 // This is the <App> component, but for now we don't care about anything but its
// `canvas` state. // `canvas` state.
type App = { type App = { canvas: HTMLCanvasElement | null; props: AppProps };
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"];
@@ -58,7 +51,7 @@ export class ActionManager implements ActionsManagerInterface {
actions.forEach((action) => this.registerAction(action)); actions.forEach((action) => this.registerAction(action));
} }
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) { handleKeyDown(event: KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions) const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
@@ -108,10 +101,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 (
@@ -139,8 +133,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}
/> />
); );
} }
+1 -1
View File
@@ -57,7 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")], gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")], zenMode: [getShortcutKey("Alt+Z")],
stats: [getShortcutKey("Alt+/")], stats: [],
addToLibrary: [], addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")], flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
+14 -27
View File
@@ -1,8 +1,6 @@
import React from "react"; import React from "react";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
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 =
@@ -17,17 +15,11 @@ export type ActionResult =
} }
| 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: AppAPI, app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
@@ -53,7 +45,6 @@ export type ActionName =
| "changeBackgroundColor" | "changeBackgroundColor"
| "changeFillStyle" | "changeFillStyle"
| "changeStrokeWidth" | "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
@@ -67,9 +58,9 @@ export type ActionName =
| "changeProjectName" | "changeProjectName"
| "changeExportBackground" | "changeExportBackground"
| "changeExportEmbedScene" | "changeExportEmbedScene"
| "changeExportScale" | "changeShouldAddWatermark"
| "saveToActiveFile" | "saveScene"
| "saveFileToDisk" | "saveAsScene"
| "loadScene" | "loadScene"
| "duplicateSelection" | "duplicateSelection"
| "deleteSelectedElements" | "deleteSelectedElements"
@@ -100,24 +91,21 @@ export type ActionName =
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode";
| "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?: (
event: React.KeyboardEvent | KeyboardEvent, event: KeyboardEvent,
appState: AppState, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => boolean; ) => boolean;
@@ -132,7 +120,6 @@ export interface Action {
export interface ActionsManagerInterface { export interface ActionsManagerInterface {
actions: Record<ActionName, Action>; actions: Record<ActionName, Action>;
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean; handleKeyDown: (event: KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null; renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
} }
+3 -11
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,
@@ -69,6 +61,7 @@ export const getDefaultAppState = (): Omit<
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectionElement: null, selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showHelpDialog: false, showHelpDialog: false,
showStats: false, showStats: false,
@@ -124,7 +117,6 @@ const APP_STATE_STORAGE_CONF = (<
errorMessage: { browser: false, export: false }, errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false }, exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false }, exportEmbedScene: { browser: true, export: false },
exportScale: { browser: true, export: false },
exportWithDarkMode: { browser: true, export: false }, exportWithDarkMode: { browser: true, export: false },
fileHandle: { browser: false, export: false }, fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true }, gridSize: { browser: true, export: true },
@@ -140,7 +132,6 @@ const APP_STATE_STORAGE_CONF = (<
offsetLeft: { browser: false, export: false }, offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false }, offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false }, openMenu: { browser: true, export: false },
openPopup: { browser: false, export: false },
pasteDialog: { browser: false, export: false }, pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false }, previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false }, resizingElement: { browser: false, export: false },
@@ -150,6 +141,7 @@ const APP_STATE_STORAGE_CONF = (<
selectedElementIds: { browser: true, export: false }, selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false }, selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false }, selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false }, shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false }, showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false }, showStats: { browser: true, export: false },
+3 -1
View File
@@ -6,6 +6,7 @@ import { getSelectedElements } from "./scene";
import { AppState } 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 { canvasToBlob } from "./data/blob";
import { EXPORT_DATA_TYPES } from "./constants"; import { EXPORT_DATA_TYPES } from "./constants";
type ElementsClipboard = { type ElementsClipboard = {
@@ -151,7 +152,8 @@ export const parseClipboard = async (
} }
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob) => { export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([ await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }), new window.ClipboardItem({ "image/png": blob }),
]); ]);
+30 -13
View File
@@ -9,8 +9,7 @@ import {
canHaveArrowheads, canHaveArrowheads,
getTargetElements, getTargetElements,
hasBackground, hasBackground,
hasStrokeStyle, hasStroke,
hasStrokeWidth,
hasText, hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@@ -54,17 +53,10 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(elementType) || {(hasStroke(elementType) ||
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStroke(element.type))) && (
renderAction("changeStrokeWidth")}
{(elementType === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeWidth")}
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")} {renderAction("changeSloppiness")}
</> </>
@@ -151,14 +143,23 @@ 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,
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"];
isLibraryOpen: boolean;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key }, index) => { {SHAPES.map(({ value, icon, key }, index) => {
@@ -192,6 +193,19 @@ export const ShapesSwitcher = ({
/> />
); );
})} })}
<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 });
}}
/>
</> </>
); );
@@ -204,9 +218,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>
);
+572 -755
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { AppState } from "../types"; import { AppState } from "../types";
import { DarkModeToggle } from "./DarkModeToggle";
export const BackgroundPickerAndDarkModeToggle = ({ export const BackgroundPickerAndDarkModeToggle = ({
appState, appState,
@@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
}) => ( }) => (
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")} {actionManager.renderAction("changeViewBackgroundColor")}
{showThemeBtn && actionManager.renderAction("toggleTheme")} {showThemeBtn && (
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
setAppState({ theme });
}}
/>
</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>({
-53
View File
@@ -1,53 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Card {
display: flex;
flex-direction: column;
align-items: center;
max-width: 290px;
margin: 1em;
text-align: center;
.Card-icon {
font-size: 2.6em;
display: flex;
flex: 0 0 auto;
padding: 1.4rem;
border-radius: 50%;
background: var(--card-color);
color: $oc-white;
svg {
width: 2.8rem;
height: 2.8rem;
}
}
.Card-details {
font-size: 0.96em;
min-height: 90px;
padding: 0 1em;
margin-bottom: auto;
}
& .Card-button.ToolIcon_type_button {
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
background-color: var(--card-color);
&:hover {
background-color: var(--card-color-darker);
}
&:active {
background-color: var(--card-color-darkest);
}
.ToolIcon__label {
color: $oc-white;
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
import OpenColor from "open-color";
import "./Card.scss";
export const Card: React.FC<{
color: keyof OpenColor;
}> = ({ children, color }) => {
return (
<div
className="Card"
style={{
["--card-color" as any]: OpenColor[color][7],
["--card-color-darker" as any]: OpenColor[color][8],
["--card-color-darkest" as any]: OpenColor[color][9],
}}
>
{children}
</div>
);
};
-89
View File
@@ -1,89 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Checkbox {
margin: 4px 0.3em;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$oc-blue-4};
}
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
svg {
display: block;
opacity: 0.3;
}
}
&:active {
.Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
}
}
&:hover {
.Checkbox-box {
background-color: fade-out($oc-blue-1, 0.8);
}
}
&.is-checked {
.Checkbox-box {
background-color: #{$oc-blue-1};
svg {
display: block;
}
}
&:hover .Checkbox-box {
background-color: #{$oc-blue-2};
}
}
.Checkbox-box {
width: 22px;
height: 22px;
padding: 0;
flex: 0 0 auto;
margin: 0 1em;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px #{$oc-blue-7};
background-color: transparent;
border-radius: 4px;
color: #{$oc-blue-7};
&:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7};
}
svg {
display: none;
width: 16px;
height: 16px;
stroke-width: 3px;
}
}
.Checkbox-label {
display: flex;
align-items: center;
}
.excalidraw-tooltip-icon {
width: 1em;
height: 1em;
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import React from "react";
import clsx from "clsx";
import { checkIcon } from "./icons";
import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{
checked: boolean;
onChange: (checked: boolean) => void;
}> = ({ children, checked, onChange }) => {
return (
<div
className={clsx("Checkbox", { "is-checked": checked })}
onClick={(event) => {
onChange(!checked);
((event.currentTarget as HTMLDivElement).querySelector(
".Checkbox-box",
) as HTMLButtonElement).focus();
}}
>
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
{checkIcon}
</button>
<div className="Checkbox-label">{children}</div>
</div>
);
};
+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";
+1 -1
View File
@@ -160,7 +160,7 @@
} }
.color-picker-input { .color-picker-input {
width: 11ch; /* length of `transparent` */ width: 12ch; /* length of `transparent` + 1 */
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
background-color: var(--input-bg-color); background-color: var(--input-bg-color);
+32 -42
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;
} }
@@ -116,7 +115,6 @@ const Picker = ({
onClose(); onClose();
} }
event.nativeEvent.stopImmediatePropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}; };
return ( return (
@@ -138,41 +136,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}
@@ -244,16 +237,13 @@ export const ColorPicker = ({
color, color,
onChange, onChange,
label, label,
isActive,
setActive,
}: { }: {
type: "canvasBackground" | "elementBackground" | "elementStroke"; type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
label: string; label: string;
isActive: boolean;
setActive: (active: boolean) => void;
}) => { }) => {
const [isActive, setActive] = React.useState(false);
const pickerButton = React.useRef<HTMLButtonElement>(null); const pickerButton = React.useRef<HTMLButtonElement>(null);
return ( return (
+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";
+27 -17
View File
@@ -1,32 +1,42 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { THEME } from "../constants"; export type Appearence = "light" | "dark";
import { Theme } from "../element/types";
// 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 = props.title
props.title || ? props.title
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")); : props.value === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode");
return ( return (
<ToolButton <label
type="icon" className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() =>
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
data-testid="toggle-dark-mode" data-testid="toggle-dark-mode"
/> title={title}
>
<input
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
onChange={(event) =>
props.onChange(event.target.checked ? "dark" : "light")
}
checked={props.value === "dark"}
aria-label={title}
/>
<div className="ToolIcon__icon">
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
</div>
</label>
); );
}; };
+5 -15
View File
@@ -1,14 +1,13 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect } 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";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types";
export const Dialog = (props: { export const Dialog = (props: {
children: React.ReactNode; children: React.ReactNode;
@@ -17,11 +16,8 @@ export const Dialog = (props: {
onCloseRequest(): void; onCloseRequest(): void;
title: React.ReactNode; title: React.ReactNode;
autofocus?: boolean; autofocus?: boolean;
theme?: AppState["theme"];
}) => { }) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
useEffect(() => { useEffect(() => {
if (!islandNode) { if (!islandNode) {
@@ -69,25 +65,19 @@ export const Dialog = (props: {
return focusableElements ? Array.from(focusableElements) : []; return focusableElements ? Array.from(focusableElements) : [];
}; };
const onClose = () => {
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};
return ( return (
<Modal <Modal
className={clsx("Dialog", props.className)} className={clsx("Dialog", props.className)}
labelledBy="dialog-title" labelledBy="dialog-title"
maxWidth={props.small ? 550 : 800} maxWidth={props.small ? 550 : 800}
onCloseRequest={onClose} onCloseRequest={props.onCloseRequest}
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"
onClick={onClose} onClick={props.onCloseRequest}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useIsMobile() ? back : close} {useIsMobile() ? back : close}
+1 -5
View File
@@ -2,7 +2,6 @@ import React, { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { useExcalidrawContainer } from "./App";
export const ErrorDialog = ({ export const ErrorDialog = ({
message, message,
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
onClose?: () => void; onClose?: () => void;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(!!message); const [modalIsShown, setModalIsShown] = useState(!!message);
const { container: excalidrawContainer } = useExcalidrawContainer();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setModalIsShown(false); setModalIsShown(false);
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
if (onClose) { if (onClose) {
onClose(); onClose();
} }
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element }, [onClose]);
excalidrawContainer?.focus();
}, [onClose, excalidrawContainer]);
return ( return (
<> <>
+27 -59
View File
@@ -28,6 +28,33 @@
justify-content: space-between; justify-content: space-between;
} }
.ExportDialog__name {
grid-column: project-name;
margin: auto;
display: flex;
align-items: center;
.TextInput {
height: calc(1rem - 3px);
width: 200px;
overflow: hidden;
text-align: center;
margin-left: 8px;
text-overflow: ellipsis;
&--readonly {
background: none;
border: none;
&:hover {
background: none;
}
width: auto;
max-width: 200px;
padding-left: 2px;
}
}
}
@include isMobile { @include isMobile {
.ExportDialog { .ExportDialog {
display: flex; display: flex;
@@ -57,63 +84,4 @@
overflow-y: auto; overflow-y: auto;
} }
} }
.ExportDialog--json {
.ExportDialog-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
justify-items: center;
row-gap: 2em;
@media (max-width: 460px) {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
.Card-details {
min-height: 40px;
}
}
.ProjectName {
width: fit-content;
margin: 1em auto;
align-items: flex-start;
flex-direction: column;
.TextInput {
width: auto;
}
}
.ProjectName-label {
margin: 0.625em 0;
font-weight: bold;
}
}
}
button.ExportDialog-imageExportButton {
width: 5rem;
height: 5rem;
margin: 0 0.2em;
border-radius: 1rem;
background-color: var(--button-color);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
0 6px 10px 0 rgba(0, 0, 0, 0.14);
font-family: Cascadia;
font-size: 1.8em;
color: $oc-white;
&:hover {
background-color: var(--button-color-darker);
}
&:active {
background-color: var(--button-color-darkest);
box-shadow: none;
}
svg {
width: 0.9em;
}
}
} }
@@ -6,19 +6,18 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useIsMobile } from "../components/App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types"; import { AppState } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons"; import "./ExportDialog.scss";
import { clipboard, exportFile, link } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss";
import OpenColor from "open-color"; const scales = [1, 2, 3];
import { CheckboxItem } from "./CheckboxItem"; const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -53,37 +52,15 @@ export type ExportCB = (
scale?: number, scale?: number,
) => void; ) => void;
const ExportButton: React.FC<{ const ExportModal = ({
color: keyof OpenColor;
onClick: () => void;
title: string;
shade?: number;
}> = ({ children, title, onClick, color, shade = 6 }) => {
return (
<button
className="ExportDialog-imageExportButton"
style={{
["--button-color" as any]: OpenColor[color][shade],
["--button-color-darker" as any]: OpenColor[color][shade + 1],
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
}}
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</button>
);
};
const ImageExportModal = ({
elements, elements,
appState, appState,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = 10,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
onExportToBackend,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
@@ -92,12 +69,18 @@ const ImageExportModal = ({
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
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,
shouldAddWatermark,
} = appState;
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState) ? getSelectedElements(elements, appState)
@@ -117,6 +100,8 @@ const ImageExportModal = ({
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
exportPadding, exportPadding,
scale,
shouldAddWatermark,
}); });
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
@@ -139,6 +124,8 @@ const ImageExportModal = ({
exportBackground, exportBackground,
exportPadding, exportPadding,
viewBackgroundColor, viewBackgroundColor,
scale,
shouldAddWatermark,
]); ]);
return ( return (
@@ -146,85 +133,106 @@ const ImageExportModal = ({
<div className="ExportDialog__preview" ref={previewRef} /> <div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters && {supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")} actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}> <Stack.Col gap={2} align="center">
<div <div className="ExportDialog__actions">
style={{ <Stack.Row gap={2}>
display: "grid", <ToolButton
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))", type="button"
// dunno why this is needed, but when the items wrap it creates label="PNG"
// an overflow title={t("buttons.exportToPng")}
overflow: "hidden", aria-label={t("buttons.exportToPng")}
}} onClick={() => onExportToPng(exportedElements, scale)}
> />
{actionManager.renderAction("changeExportBackground")} <ToolButton
{someElementIsSelected && ( type="button"
<CheckboxItem label="SVG"
checked={exportSelected} title={t("buttons.exportToSvg")}
onChange={(checked) => setExportSelected(checked)} aria-label={t("buttons.exportToSvg")}
> onClick={() => onExportToSvg(exportedElements, scale)}
{t("labels.onlySelected")} />
</CheckboxItem> {probablySupportsClipboardBlob && (
)} <ToolButton
{actionManager.renderAction("changeExportEmbedScene")} type="button"
icon={clipboard}
title={t("buttons.copyPngToClipboard")}
aria-label={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements, scale)}
/>
)}
{onExportToBackend && (
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
)}
</Stack.Row>
<div className="ExportDialog__name">
{actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2}>
{scales.map((s) => {
const [width, height] = getExportSize(
exportedElements,
exportPadding,
shouldAddWatermark,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="s"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === scale}
onChange={() => setScale(s)}
/>
);
})}
</Stack.Row>
</div> </div>
</div> {actionManager.renderAction("changeExportBackground")}
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}> {someElementIsSelected && (
<Stack.Row gap={2}> <div>
{actionManager.renderAction("changeExportScale")} <label>
</Stack.Row> <input
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p> type="checkbox"
</div> checked={exportSelected}
<div onChange={(event) =>
style={{ setExportSelected(event.currentTarget.checked)
display: "flex", }
alignItems: "center", />{" "}
justifyContent: "center", {t("labels.onlySelected")}
margin: ".6em 0", </label>
}} </div>
>
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton
color="indigo"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements)}
>
PNG
</ExportButton>
<ExportButton
color="red"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements)}
>
SVG
</ExportButton>
{probablySupportsClipboardBlob && (
<ExportButton
title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)}
color="gray"
shade={7}
>
{clipboard}
</ExportButton>
)} )}
</Stack.Row> {actionManager.renderAction("changeExportEmbedScene")}
{actionManager.renderAction("changeShouldAddWatermark")}
</Stack.Col>
</div> </div>
); );
}; };
export const ImageExportDialog = ({ export const ExportDialog = ({
elements, elements,
appState, appState,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = 10,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
onExportToBackend,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
@@ -233,11 +241,14 @@ export const ImageExportDialog = ({
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false); const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setModalIsShown(false); setModalIsShown(false);
triggerButton.current?.focus();
}, []); }, []);
return ( return (
@@ -246,16 +257,17 @@ export const ImageExportDialog = ({
onClick={() => { onClick={() => {
setModalIsShown(true); setModalIsShown(true);
}} }}
data-testid="image-export-button" data-testid="export-button"
icon={exportImage} icon={exportFile}
type="button" type="button"
aria-label={t("buttons.exportImage")} aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")} title={t("buttons.export")}
ref={triggerButton}
/> />
{modalIsShown && ( {modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}> <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<ImageExportModal <ExportModal
elements={elements} elements={elements}
appState={appState} appState={appState}
exportPadding={exportPadding} exportPadding={exportPadding}
@@ -263,6 +275,7 @@ export const ImageExportDialog = ({
onExportToPng={onExportToPng} onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg} onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard} onExportToClipboard={onExportToClipboard}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose} onCloseRequest={handleClose}
/> />
</Dialog> </Dialog>
+10 -9
View File
@@ -1,5 +1,6 @@
.excalidraw { .excalidraw {
.FixedSideContainer { .FixedSideContainer {
--margin: 0.25rem;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
} }
@@ -9,9 +10,9 @@
} }
.FixedSideContainer_side_top { .FixedSideContainer_side_top {
left: var(--space-factor); left: var(--margin);
top: var(--space-factor); top: var(--margin);
right: var(--space-factor); right: var(--margin);
z-index: 2; z-index: 2;
} }
@@ -22,16 +23,16 @@
/* TODO: if these are used, make sure to implement RTL support /* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left { .FixedSideContainer_side_left {
left: var(--space-factor); left: var(--margin);
top: var(--space-factor); top: var(--margin);
bottom: var(--space-factor); bottom: var(--margin);
z-index: 1; z-index: 1;
} }
.FixedSideContainer_side_right { .FixedSideContainer_side_right {
right: var(--space-factor); right: var(--margin);
top: var(--space-factor); top: var(--margin);
bottom: var(--space-factor); bottom: var(--margin);
z-index: 3; z-index: 3;
} }
*/ */
@@ -1,23 +1,15 @@
import oc from "open-color"; import oc from "open-color";
import React from "react"; import React from "react";
import { THEME } from "../../constants";
import { Theme } from "../../element/types";
// https://github.com/tholman/github-corners // https://github.com/tholman/github-corners
export const GitHubCorner = React.memo( export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => ( ({ theme }: { theme: "light" | "dark" }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="40" width="40"
height="40" height="40"
viewBox="0 0 250 250" viewBox="0 0 250 250"
className="rtl-mirror" className="github-corner rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl"
? "marginLeft"
: "marginRight"]: "calc(var(--space-factor) * -1)",
}}
> >
<a <a
href="https://github.com/excalidraw/excalidraw" href="https://github.com/excalidraw/excalidraw"
@@ -27,18 +19,18 @@ export const GitHubCorner = React.memo(
> >
<path <path
d="M0 0l115 115h15l12 27 108 108V0z" d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]} fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
/> />
<path <path
className="octo-arm" className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16" d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }} style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"} fill={theme === "light" ? oc.white : oc.black}
/> />
<path <path
className="octo-body" className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z" d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"} fill={theme === "light" ? oc.white : oc.black}
/> />
</a> </a>
</svg> </svg>
+1 -24
View File
@@ -153,17 +153,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut <Shortcut
label={t("toolBar.freedraw")} label={t("toolBar.draw")}
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("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("Enter"),
t("helpDialog.doubleClick"),
]}
/>
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}
shortcuts={[ shortcuts={[
@@ -238,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.viewMode")} label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]} shortcuts={[getShortcutKey("Alt+R")]}
/> />
<Shortcut
label={t("labels.toggleTheme")}
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
<Shortcut
label={t("stats.title")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
<Column> <Column>
@@ -372,14 +357,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.flipVertical")} label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]} shortcuts={[getShortcutKey("Shift+V")]}
/> />
<Shortcut
label={t("labels.showStroke")}
shortcuts={[getShortcutKey("S")]}
/>
<Shortcut
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
</Columns> </Columns>
+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 -10
View File
@@ -1,10 +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 { isLinearElement, isTextElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
interface Hint { interface Hint {
@@ -22,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.linearElementMulti"); return t("hints.linearElementMulti");
} }
if (elementType === "freedraw") { if (elementType === "draw") {
return t("hints.freeDraw"); return t("hints.freeDraw");
} }
@@ -56,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
View File
@@ -88,7 +88,6 @@ function Picker<T>({
onClose(); onClose();
} }
event.nativeEvent.stopImmediatePropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}; };
return ( return (
+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;
}
}
+1
View File
@@ -2,6 +2,7 @@
.Island { .Island {
--padding: 0; --padding: 0;
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: 4px; border-radius: 4px;
padding: calc(var(--padding) * var(--space-factor)); padding: calc(var(--padding) * var(--space-factor));
-128
View File
@@ -1,128 +0,0 @@
import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState, ExportOpts } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const JSONExportModal = ({
elements,
appState,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const { onExportToBackend } = exportOpts;
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
{exportOpts.saveFileToDisk && (
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
}}
/>
</Card>
)}
{onExportToBackend && (
<Card color="pink">
<div className="Card-icon">{link}</div>
<h2>{t("exportDialog.link_title")}</h2>
<div className="Card-details">{t("exportDialog.link_details")}</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => onExportToBackend(elements, appState, canvas)}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, canvas)}
</div>
</div>
);
};
export const JSONExportDialog = ({
elements,
appState,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
}, []);
return (
<>
<ToolButton
onClick={() => {
setModalIsShown(true);
}}
data-testid="json-export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
title={t("buttons.export")}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}
appState={appState}
actionManager={actionManager}
onCloseRequest={handleClose}
exportOpts={exportOpts}
canvas={canvas}
/>
</Dialog>
)}
</>
);
};
+42 -35
View File
@@ -40,17 +40,50 @@
.layer-ui__wrapper { .layer-ui__wrapper {
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);
&__top-right { .encrypted-icon {
position: relative;
margin-inline-start: 15px;
display: flex; display: flex;
justify-content: center;
align-items: center;
border-radius: var(--space-factor);
color: $oc-green-9;
svg {
width: 1.2rem;
height: 1.2rem;
}
}
&__github-corner {
top: 0;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
position: absolute;
width: 40px;
} }
&__footer { &__footer {
width: 100%; position: absolute;
z-index: 100;
bottom: 0;
&-right { :root[dir="ltr"] & {
z-index: 100; right: 0;
display: flex;
} }
:root[dir="rtl"] & {
left: 0;
}
width: 190px;
} }
.zen-mode-transition { .zen-mode-transition {
@@ -72,15 +105,11 @@
transform: translate(-999px, 0); transform: translate(-999px, 0);
} }
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left { :root[dir="ltr"] &.App-menu_bottom--transition-left {
transform: translate(-76px, 0); transform: translate(-92px, 0);
} }
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left { :root[dir="rtl"] &.App-menu_bottom--transition-left {
transform: translate(76px, 0); transform: translate(92px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
transform: translate(0, 92px);
} }
} }
@@ -108,27 +137,5 @@
transition-delay: 0.8s; transition-delay: 0.8s;
} }
} }
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
}
.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 {
margin-top: auto;
margin-bottom: auto;
margin-inline-end: 1em;
}
} }
} }
+161 -226
View File
@@ -10,6 +10,7 @@ import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
@@ -28,15 +29,16 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { exportFile, load, trash } from "./icons"; import { exportFile, load, shield, trash } from "./icons";
import { Island } from "./Island"; 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";
@@ -45,10 +47,6 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -65,14 +63,15 @@ interface LayerUIProps {
toggleZenMode: () => void; toggleZenMode: () => void;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element; onExportToBackend?: (
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean; viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
} }
const useOnClickOutside = ( const useOnClickOutside = (
@@ -104,34 +103,26 @@ const useOnClickOutside = (
}; };
const LibraryMenuItems = ({ const LibraryMenuItems = ({
libraryItems, library,
onRemoveFromLibrary, onRemoveFromLibrary,
onAddToLibrary, onAddToLibrary,
onInsertShape, onInsertShape,
pendingElements, pendingElements,
theme,
setAppState, setAppState,
setLibraryItems, setLibraryItems,
libraryReturnUrl, libraryReturnUrl,
focusContainer,
library,
id,
}: { }: {
libraryItems: LibraryItems; library: LibraryItems;
pendingElements: LibraryItem; pendingElements: LibraryItem;
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"];
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"];
focusContainer: () => void;
library: Library;
id: string;
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0); const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = isMobile ? 4 : 6; const CELLS_PER_ROW = isMobile ? 4 : 6;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW)); const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = []; const rows = [];
@@ -149,7 +140,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
icon={load} icon={load}
onClick={() => { onClick={() => {
importLibraryFromJSON(library) importLibraryFromJSON()
.then(() => { .then(() => {
// Close and then open to get the libraries updated // Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false }); setAppState({ isLibraryOpen: false });
@@ -161,7 +152,7 @@ const LibraryMenuItems = ({
}); });
}} }}
/> />
{!!libraryItems.length && ( {!!library.length && (
<> <>
<ToolButton <ToolButton
key="export" key="export"
@@ -170,7 +161,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.export")} aria-label={t("buttons.export")}
icon={exportFile} icon={exportFile}
onClick={() => { onClick={() => {
saveLibraryAsJSON(library) saveLibraryAsJSON()
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
setAppState({ errorMessage: error.message }); setAppState({ errorMessage: error.message });
@@ -185,9 +176,8 @@ const LibraryMenuItems = ({
icon={trash} icon={trash}
onClick={() => { onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) { if (window.confirm(t("alerts.resetLibrary"))) {
library.resetLibrary(); Library.resetLibrary();
setLibraryItems([]); setLibraryItems([]);
focusContainer();
} }
}} }}
/> />
@@ -196,7 +186,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=${Library.csrfToken}`}
target="_excalidraw_libraries" target="_excalidraw_libraries"
> >
{t("labels.libraries")} {t("labels.libraries")}
@@ -211,13 +201,13 @@ const LibraryMenuItems = ({
const shouldAddPendingElements: boolean = const shouldAddPendingElements: boolean =
pendingElements.length > 0 && pendingElements.length > 0 &&
!addedPendingElements && !addedPendingElements &&
y + x >= libraryItems.length; y + x >= library.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements; addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push( children.push(
<Stack.Col key={x}> <Stack.Col key={x}>
<LibraryUnit <LibraryUnit
elements={libraryItems[y + x]} elements={library[y + x]}
pendingElements={ pendingElements={
shouldAddPendingElements ? pendingElements : undefined shouldAddPendingElements ? pendingElements : undefined
} }
@@ -225,7 +215,7 @@ const LibraryMenuItems = ({
onClick={ onClick={
shouldAddPendingElements shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements) ? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, libraryItems[y + x]) : onInsertShape.bind(null, library[y + x])
} }
/> />
</Stack.Col>, </Stack.Col>,
@@ -250,23 +240,15 @@ const LibraryMenu = ({
onInsertShape, onInsertShape,
pendingElements, pendingElements,
onAddToLibrary, onAddToLibrary,
theme,
setAppState, setAppState,
libraryReturnUrl, libraryReturnUrl,
focusContainer,
library,
id,
}: { }: {
pendingElements: LibraryItem; pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void; onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void; onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
theme: AppState["theme"];
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => { useOnClickOutside(ref, (event) => {
@@ -292,7 +274,7 @@ const LibraryMenu = ({
resolve("loading"); resolve("loading");
}, 100); }, 100);
}), }),
library.loadLibrary().then((items) => { Library.loadLibrary().then((items) => {
setLibraryItems(items); setLibraryItems(items);
setIsLoading("ready"); setIsLoading("ready");
}), }),
@@ -304,33 +286,24 @@ const LibraryMenu = ({
return () => { return () => {
clearTimeout(loadingTimerRef.current!); clearTimeout(loadingTimerRef.current!);
}; };
}, [library]); }, []);
const removeFromLibrary = useCallback( const removeFromLibrary = useCallback(async (indexToRemove) => {
async (indexToRemove) => { const items = await Library.loadLibrary();
const items = await library.loadLibrary(); const nextItems = items.filter((_, index) => index !== indexToRemove);
const nextItems = items.filter((_, index) => index !== indexToRemove); Library.saveLibrary(nextItems);
library.saveLibrary(nextItems).catch((error) => { setLibraryItems(nextItems);
setLibraryItems(items); }, []);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setLibraryItems(nextItems);
},
[library, setAppState],
);
const addToLibrary = useCallback( const addToLibrary = useCallback(
async (elements: LibraryItem) => { async (elements: LibraryItem) => {
const items = await library.loadLibrary(); const items = await Library.loadLibrary();
const nextItems = [...items, elements]; const nextItems = [...items, elements];
onAddToLibrary(); onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => { Library.saveLibrary(nextItems);
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems); setLibraryItems(nextItems);
}, },
[onAddToLibrary, library, setAppState], [onAddToLibrary],
); );
return loadingState === "preloading" ? null : ( return loadingState === "preloading" ? null : (
@@ -341,7 +314,7 @@ const LibraryMenu = ({
</div> </div>
) : ( ) : (
<LibraryMenuItems <LibraryMenuItems
libraryItems={libraryItems} library={libraryItems}
onRemoveFromLibrary={removeFromLibrary} onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary} onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape} onInsertShape={onInsertShape}
@@ -349,10 +322,6 @@ const LibraryMenu = ({
setAppState={setAppState} setAppState={setAppState}
setLibraryItems={setLibraryItems} setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={theme}
id={id}
/> />
)} )}
</Island> </Island>
@@ -373,77 +342,75 @@ const LayerUI = ({
showThemeBtn, showThemeBtn,
toggleZenMode, toggleZenMode,
isCollaborating, isCollaborating,
renderTopRightUI, onExportToBackend,
renderCustomFooter, renderCustomFooter,
viewModeEnabled, viewModeEnabled,
libraryReturnUrl, libraryReturnUrl,
UIOptions, UIOptions,
focusContainer,
library,
id,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const renderJSONExportDialog = () => { const renderEncryptedIcon = () => (
<a
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
"zen-mode-visibility--hidden": zenModeEnabled,
})}
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield}
</Tooltip>
</a>
);
const renderExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
return null; return null;
} }
return (
<JSONExportDialog
elements={elements}
appState={appState}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
/>
);
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
return null;
}
const createExporter = (type: ExportType): ExportCB => async ( const createExporter = (type: ExportType): ExportCB => async (
exportedElements, exportedElements,
scale,
) => { ) => {
const fileHandle = await exportCanvas(type, exportedElements, appState, { if (canvas) {
exportBackground: appState.exportBackground, await exportCanvas(type, exportedElements, appState, canvas, {
name: appState.name, exportBackground: appState.exportBackground,
viewBackgroundColor: appState.viewBackgroundColor, name: appState.name,
}) viewBackgroundColor: appState.viewBackgroundColor,
.catch(muteFSAbortError) scale,
.catch((error) => { shouldAddWatermark: appState.shouldAddWatermark,
console.error(error); })
setAppState({ errorMessage: error.message }); .catch(muteFSAbortError)
}); .catch((error) => {
console.error(error);
if ( setAppState({ errorMessage: error.message });
appState.exportEmbedScene && });
fileHandle &&
isImageFileHandle(fileHandle)
) {
setAppState({ fileHandle });
} }
}; };
return ( return (
<ImageExportDialog <ExportDialog
elements={elements} elements={elements}
appState={appState} appState={appState}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={createExporter("png")} onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")} onExportToSvg={createExporter("svg")}
onExportToClipboard={createExporter("clipboard")} onExportToClipboard={createExporter("clipboard")}
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
: undefined
}
/> />
); );
}; };
const Separator = () => {
return <div style={{ width: ".625em" }} />;
};
const renderViewModeCanvasActions = () => { const renderViewModeCanvasActions = () => {
return ( return (
<Section <Section
@@ -457,8 +424,9 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}> <Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}> <Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between"> <Stack.Row gap={1} justifyContent="space-between">
{renderJSONExportDialog()} {actionManager.renderAction("saveScene")}
{renderImageExportDialog()} {actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
</Island> </Island>
@@ -477,12 +445,11 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}> <Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}> <Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between"> <Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("clearCanvas")}
<Separator />
{actionManager.renderAction("loadScene")} {actionManager.renderAction("loadScene")}
{renderJSONExportDialog()} {actionManager.renderAction("saveScene")}
{renderImageExportDialog()} {actionManager.renderAction("saveAsScene")}
<Separator /> {renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && ( {onCollabButtonClick && (
<CollabButton <CollabButton
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
@@ -497,9 +464,6 @@ const LayerUI = ({
setAppState={setAppState} setAppState={setAppState}
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
/> />
{appState.fileHandle && (
<>{actionManager.renderAction("saveToActiveFile")}</>
)}
</Stack.Col> </Stack.Col>
</Island> </Island>
</Section> </Section>
@@ -518,8 +482,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
@@ -554,10 +517,6 @@ const LayerUI = ({
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={appState.theme}
id={id}
/> />
) : null; ) : null;
@@ -584,12 +543,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 })}
@@ -601,12 +554,15 @@ const LayerUI = ({
canvas={canvas} canvas={canvas}
elementType={appState.elementType} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/> />
</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}
@@ -614,32 +570,24 @@ const LayerUI = ({
)} )}
</Section> </Section>
)} )}
<div <UserList
className={clsx( className={clsx("zen-mode-transition", {
"layer-ui__wrapper__top-right zen-mode-transition", "transition-right": zenModeEnabled,
{ })}
"transition-right": zenModeEnabled,
},
)}
> >
<UserList> {appState.collaborators.size > 0 &&
{appState.collaborators.size > 0 && Array.from(appState.collaborators)
Array.from(appState.collaborators) // Collaborator is either not initialized or is actually the current user.
// Collaborator is either not initialized or is actually the current user. .filter(([_, client]) => Object.keys(client).length !== 0)
.filter(([_, client]) => Object.keys(client).length !== 0) .map(([clientId, client]) => (
.map(([clientId, client]) => ( <Tooltip
<Tooltip label={client.username || "Unknown user"}
label={client.username || "Unknown user"} key={clientId}
key={clientId} >
> {actionManager.renderAction("goToCollaborator", clientId)}
{actionManager.renderAction("goToCollaborator", { </Tooltip>
id: clientId, ))}
})} </UserList>
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(isMobile, appState)}
</div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
); );
@@ -647,71 +595,61 @@ const LayerUI = ({
const renderBottomAppMenu = () => { const renderBottomAppMenu = () => {
return ( return (
<footer <div
role="contentinfo" className={clsx("App-menu App-menu_bottom zen-mode-transition", {
className="layer-ui__wrapper__footer App-menu App-menu_bottom" "App-menu_bottom--transition-left": zenModeEnabled,
})}
> >
<div <Stack.Col gap={2}>
className={clsx( <Section heading="canvasActions">
"layer-ui__wrapper__footer-left zen-mode-transition", <Island padding={1}>
{ <ZoomActions
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled, renderAction={actionManager.renderAction}
}, zoom={appState.zoom}
)} />
> </Island>
<Stack.Col gap={2}> {renderEncryptedIcon()}
<Section heading="canvasActions"> </Section>
<Island padding={1}> </Stack.Col>
<ZoomActions </div>
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</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>
</Stack.Col>
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
"transition-right disable-pointerEvents": zenModeEnabled,
},
)}
>
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
</footer>
); );
}; };
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner theme={appState.theme} />
</aside>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
className={clsx("zen-mode-transition", {
"transition-right disable-pointerEvents": zenModeEnabled,
})}
>
{renderCustomFooter?.(false)}
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
</footer>
);
const dialogs = ( const dialogs = (
<> <>
{appState.isLoading && <LoadingMessage />} {appState.isLoading && <LoadingMessage />}
@@ -722,11 +660,7 @@ const LayerUI = ({
/> />
)} )}
{appState.showHelpDialog && ( {appState.showHelpDialog && (
<HelpDialog <HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
onClose={() => {
setAppState({ showHelpDialog: false });
}}
/>
)} )}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (
<PasteChartDialog <PasteChartDialog
@@ -751,8 +685,7 @@ const LayerUI = ({
elements={elements} elements={elements}
actionManager={actionManager} actionManager={actionManager}
libraryMenu={libraryMenu} libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog} exportButton={renderExportDialog()}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick} onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle} onLockToggle={onLockToggle}
@@ -775,6 +708,8 @@ const LayerUI = ({
{dialogs} {dialogs}
{renderFixedSideContainer()} {renderFixedSideContainer()}
{renderBottomAppMenu()} {renderBottomAppMenu()}
{renderGitHubCorner()}
{renderFooter()}
{appState.scrolledOutside && ( {appState.scrolledOutside && (
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
-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"))} — 9`}
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="9"
/>
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
</label>
);
};
+15 -20
View File
@@ -1,6 +1,6 @@
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";
@@ -36,27 +36,22 @@ export const LibraryUnit = ({
if (!elementsToRender) { if (!elementsToRender) {
return; return;
} }
let svg: SVGSVGElement; const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
ref.current!.removeChild(child);
}
ref.current!.appendChild(svg);
const current = ref.current!; const current = ref.current!;
(async () => {
svg = await exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
current!.removeChild(child);
}
current!.appendChild(svg);
})();
return () => { return () => {
if (svg) { current.removeChild(svg);
current.removeChild(svg);
}
}; };
}, [elements, pendingElements]); }, [elements, pendingElements]);
+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}
+18 -18
View File
@@ -13,16 +13,14 @@ 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;
actionManager: ActionManager; actionManager: ActionManager;
renderJSONExportDialog: () => React.ReactNode; exportButton: React.ReactNode;
renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
libraryMenu: JSX.Element | null; libraryMenu: JSX.Element | null;
@@ -30,7 +28,7 @@ type MobileMenuProps = {
onLockToggle: () => void; onLockToggle: () => void;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
isCollaborating: boolean; isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean; viewModeEnabled: boolean;
showThemeBtn: boolean; showThemeBtn: boolean;
}; };
@@ -40,8 +38,7 @@ export const MobileMenu = ({
elements, elements,
libraryMenu, libraryMenu,
actionManager, actionManager,
renderJSONExportDialog, exportButton,
renderImageExportDialog,
setAppState, setAppState,
onCollabButtonClick, onCollabButtonClick,
onLockToggle, onLockToggle,
@@ -65,15 +62,15 @@ export const MobileMenu = ({
canvas={canvas} canvas={canvas}
elementType={appState.elementType} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<LockButton <LockIcon
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>
@@ -110,17 +107,19 @@ export const MobileMenu = ({
if (viewModeEnabled) { if (viewModeEnabled) {
return ( return (
<> <>
{renderJSONExportDialog()} {actionManager.renderAction("saveScene")}
{renderImageExportDialog()} {actionManager.renderAction("saveAsScene")}
{exportButton}
</> </>
); );
} }
return ( return (
<> <>
{actionManager.renderAction("clearCanvas")}
{actionManager.renderAction("loadScene")} {actionManager.renderAction("loadScene")}
{renderJSONExportDialog()} {actionManager.renderAction("saveScene")}
{renderImageExportDialog()} {actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && ( {onCollabButtonClick && (
<CollabButton <CollabButton
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
@@ -156,7 +155,7 @@ export const MobileMenu = ({
<div className="panelColumn"> <div className="panelColumn">
<Stack.Col gap={4}> <Stack.Col gap={4}>
{renderCanvasActions()} {renderCanvasActions()}
{renderCustomFooter?.(true, appState)} {renderCustomFooter?.(true)}
{appState.collaborators.size > 0 && ( {appState.collaborators.size > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.collaborators")}</legend> <legend>{t("labels.collaborators")}</legend>
@@ -168,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>
+3 -5
View File
@@ -26,7 +26,8 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 1; z-index: 1;
background-color: transparentize($oc-black, 0.3); background-color: transparentize($oc-black, 0.7);
backdrop-filter: blur(2px);
} }
.Modal__content { .Modal__content {
@@ -44,16 +45,13 @@
// for modals, reset blurry bg // for modals, reset blurry bg
background: var(--island-bg-color); background: var(--island-bg-color);
backdrop-filter: none;
border: 1px solid var(--dialog-border-color); border: 1px solid var(--dialog-border-color);
box-shadow: 0 2px 10px transparentize($oc-black, 0.75); box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
border-radius: 6px; border-radius: 6px;
box-sizing: border-box; box-sizing: border-box;
&:focus {
outline: none;
}
@include isMobile { @include isMobile {
max-width: 100%; max-width: 100%;
border: 0; border: 0;
+7 -15
View File
@@ -4,9 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App"; import { useIsMobile } from "../components/App";
import { AppState } from "../types";
import { THEME } from "../constants";
export const Modal = (props: { export const Modal = (props: {
className?: string; className?: string;
@@ -14,10 +12,8 @@ export const Modal = (props: {
maxWidth?: number; maxWidth?: number;
onCloseRequest(): void; onCloseRequest(): void;
labelledBy: string; labelledBy: string;
theme?: AppState["theme"];
}) => { }) => {
const { theme = THEME.LIGHT } = props; const modalRoot = useBodyRoot();
const modalRoot = useBodyRoot(theme);
if (!modalRoot) { if (!modalRoot) {
return null; return null;
@@ -26,7 +22,6 @@ export const Modal = (props: {
const handleKeydown = (event: React.KeyboardEvent) => { const handleKeydown = (event: React.KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) { if (event.key === KEYS.ESCAPE) {
event.nativeEvent.stopImmediatePropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
props.onCloseRequest(); props.onCloseRequest();
} }
}; };
@@ -43,7 +38,6 @@ export const Modal = (props: {
<div <div
className="Modal__content" className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }} style={{ "--max-width": `${props.maxWidth}px` }}
tabIndex={0}
> >
{props.children} {props.children}
</div> </div>
@@ -52,15 +46,13 @@ export const Modal = (props: {
); );
}; };
const useBodyRoot = (theme: AppState["theme"]) => { const useBodyRoot = () => {
const [div, setDiv] = useState<HTMLDivElement | null>(null); const [div, setDiv] = useState<HTMLDivElement | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMobileRef = useRef(isMobile); const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile; isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
div.classList.toggle("excalidraw--mobile", isMobile); div.classList.toggle("excalidraw--mobile", isMobile);
@@ -68,9 +60,9 @@ const useBodyRoot = (theme: AppState["theme"]) => {
}, [div, isMobile]); }, [div, isMobile]);
useLayoutEffect(() => { useLayoutEffect(() => {
const isDarkTheme = const isDarkTheme = !!document
!!excalidrawContainer?.classList.contains("theme--dark") || .querySelector(".excalidraw")
theme === "dark"; ?.classList.contains("theme--dark");
const div = document.createElement("div"); const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container"); div.classList.add("excalidraw", "excalidraw-modal-container");
@@ -87,7 +79,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
return () => { return () => {
document.body.removeChild(div); document.body.removeChild(div);
}; };
}, [excalidrawContainer, theme]); }, []);
return div; return div;
}; };
+11 -12
View File
@@ -34,21 +34,20 @@ const ChartPreviewBtn = (props: {
0, 0,
); );
setChartElements(elements); setChartElements(elements);
let svg: SVGSVGElement;
const svg = exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { previewNode.appendChild(svg);
svg = await exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
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);
-25
View File
@@ -1,25 +0,0 @@
.ProjectName {
margin: auto;
display: flex;
align-items: center;
.TextInput {
height: calc(1rem - 3px);
width: 200px;
overflow: hidden;
text-align: center;
margin-left: 8px;
text-overflow: ellipsis;
&--readonly {
background: none;
border: none;
&:hover {
background: none;
}
width: auto;
max-width: 200px;
padding-left: 2px;
}
}
}
+38 -36
View File
@@ -1,10 +1,6 @@
import "./TextInput.scss"; import "./TextInput.scss";
import React, { useState } from "react"; import React, { Component } from "react";
import { focusNearestParent } from "../utils";
import "./ProjectName.scss";
import { useExcalidrawContainer } from "./App";
type Props = { type Props = {
value: string; value: string;
@@ -13,19 +9,21 @@ 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 = {
focusNearestParent(event.target); fileName: this.props.value,
};
private handleBlur = (event: any) => {
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 +33,29 @@ export const ProjectName = (props: Props) => {
} }
}; };
return ( public render() {
<div className="ProjectName"> return (
<label className="ProjectName-label" htmlFor="filename"> <>
{`${props.label}${props.isNameEditable ? "" : ":"}`} <label htmlFor="file-name">
</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="file-name"
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="file-name">
</div> {this.props.value}
); </span>
}; )}
</>
);
}
}
+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)
) : ( ) : (
+1 -1
View File
@@ -6,7 +6,7 @@
top: 64px; top: 64px;
right: 12px; right: 12px;
font-size: 12px; font-size: 12px;
z-index: 10; z-index: 999;
h3 { h3 {
margin: 0 24px 8px 0; margin: 0 24px 8px 0;
+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";
+16 -24
View File
@@ -2,9 +2,8 @@ import "./ToolIcon.scss";
import React from "react"; import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
export type ToolButtonSize = "small" | "medium"; type ToolIconSize = "s" | "m";
type ToolButtonBaseProps = { type ToolButtonBaseProps = {
icon?: React.ReactNode; icon?: React.ReactNode;
@@ -15,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;
@@ -30,24 +29,21 @@ type ToolButtonProps =
children?: React.ReactNode; children?: React.ReactNode;
onClick?(): void; onClick?(): void;
}) })
| (ToolButtonBaseProps & {
type: "icon";
children?: React.ReactNode;
onClick?(): void;
})
| (ToolButtonBaseProps & { | (ToolButtonBaseProps & {
type: "radio"; type: "radio";
checked: boolean; checked: boolean;
onChange?(): 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}`;
if (props.type === "button" || props.type === "icon") { if (props.type === "button") {
return ( return (
<button <button
className={clsx( className={clsx(
@@ -60,7 +56,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
{ {
ToolIcon: !props.hidden, ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected, "ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
}, },
)} )}
data-testid={props["data-testid"]} data-testid={props["data-testid"]}
@@ -71,16 +66,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
onClick={props.onClick} onClick={props.onClick}
ref={innerRef} ref={innerRef}
> >
{(props.icon || props.label) && ( <div className="ToolIcon__icon" aria-hidden="true">
<div className="ToolIcon__icon" aria-hidden="true"> {props.icon || props.label}
{props.icon || props.label} {props.keyBindingLabel && (
{props.keyBindingLabel && ( <span className="ToolIcon__keybinding">
<span className="ToolIcon__keybinding"> {props.keyBindingLabel}
{props.keyBindingLabel} </span>
</span> )}
)} </div>
</div>
)}
{props.showAriaLabel && ( {props.showAriaLabel && (
<div className="ToolIcon__label">{props["aria-label"]}</div> <div className="ToolIcon__label">{props["aria-label"]}</div>
)} )}
@@ -98,7 +91,7 @@ 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={props.onChange} onChange={props.onChange}
checked={props.checked} checked={props.checked}
ref={innerRef} ref={innerRef}
@@ -116,5 +109,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
ToolButton.defaultProps = { ToolButton.defaultProps = {
visible: true, visible: true,
className: "", className: "",
size: "medium",
}; };
+23 -48
View File
@@ -8,26 +8,9 @@
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;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
.ToolIcon--plain {
background-color: transparent;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
} }
.ToolIcon__icon { .ToolIcon__icon {
@@ -60,9 +43,9 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.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;
} }
@@ -74,6 +57,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);
} }
@@ -86,14 +77,6 @@
} }
} }
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&--show { &--show {
visibility: visible; visibility: visible;
} }
@@ -111,9 +94,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 {
@@ -141,21 +121,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;
} }
@@ -186,9 +157,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;
@@ -213,13 +185,16 @@
position: static; position: static;
} }
} }
.ToolIcon.ToolIcon__library { }
top: 100px;
}
.ToolIcon.ToolIcon__lock { .TooltipIcon {
margin-inline-end: 0; width: 0.9em;
top: 60px; height: 0.9em;
margin-left: 5px;
margin-top: 1px;
@include isMobile {
display: none;
} }
} }
+49 -36
View File
@@ -1,45 +1,58 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw {
.Tooltip {
position: relative;
}
// container in body where the actual tooltip is appended to .Tooltip__label {
.excalidraw-tooltip { --arrow-size: 4px;
position: absolute; visibility: hidden;
z-index: 1000; background: $oc-black;
color: $oc-white;
text-align: center;
border-radius: 6px;
padding: 8px;
position: absolute;
z-index: 10;
font-size: 13px;
line-height: 1.5;
font-weight: 500;
// extra pixel offset for unknown reasons
left: calc(50% + var(--arrow-size) / 2 - 1px);
transform: translateX(-50%);
word-wrap: break-word;
padding: 8px; &::after {
border-radius: 6px; content: "";
box-sizing: border-box; border: var(--arrow-size) solid transparent;
pointer-events: none; position: absolute;
word-wrap: break-word; left: calc(50% - var(--arrow-size));
}
background: $oc-black; &--above {
bottom: calc(100% + var(--arrow-size) + 3px);
line-height: 1.5; &::after {
text-align: center; border-top-color: $oc-black;
font-size: 13px; top: 100%;
font-weight: 500; }
color: $oc-white; }
display: none; &--below {
top: calc(100% + var(--arrow-size) + 3px);
&.excalidraw-tooltip--visible { &::after {
display: block; border-bottom-color: $oc-black;
} bottom: 100%;
} }
}
// wraps the element we want to apply the tooltip to }
.excalidraw-tooltip-wrapper {
display: flex; .Tooltip:hover .Tooltip__label {
height: 100%; visibility: visible;
} }
.excalidraw-tooltip-icon { .Tooltip__label:hover {
width: 0.9em; visibility: visible;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
display: flex;
@include isMobile {
display: none;
} }
} }
+20 -82
View File
@@ -1,93 +1,31 @@
import "./Tooltip.scss"; import "./Tooltip.scss";
import React, { useEffect } from "react"; import React from "react";
const getTooltipDiv = () => {
const existingDiv = document.querySelector<HTMLDivElement>(
".excalidraw-tooltip",
);
if (existingDiv) {
return existingDiv;
}
const div = document.createElement("div");
document.body.appendChild(div);
div.classList.add("excalidraw-tooltip");
return div;
};
const updateTooltip = (
item: HTMLDivElement,
tooltip: HTMLDivElement,
label: string,
long: boolean,
) => {
tooltip.classList.add("excalidraw-tooltip--visible");
tooltip.style.minWidth = long ? "50ch" : "10ch";
tooltip.style.maxWidth = long ? "50ch" : "15ch";
tooltip.textContent = label;
const {
x: itemX,
bottom: itemBottom,
top: itemTop,
width: itemWidth,
} = item.getBoundingClientRect();
const {
width: labelWidth,
height: labelHeight,
} = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
const left = itemX + itemWidth / 2 - labelWidth / 2;
const offsetLeft =
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
const top = itemBottom + margin;
const offsetTop =
top + labelHeight >= viewportHeight
? itemBottom - itemTop + labelHeight + margin * 2
: 0;
Object.assign(tooltip.style, {
top: `${top - offsetTop}px`,
left: `${left - offsetLeft}px`,
});
};
type TooltipProps = { type TooltipProps = {
children: React.ReactNode; children: React.ReactNode;
label: string; label: string;
position?: "above" | "below";
long?: boolean; long?: boolean;
}; };
export const Tooltip = ({ children, label, long = false }: TooltipProps) => { export const Tooltip = ({
useEffect(() => { children,
return () => label,
getTooltipDiv().classList.remove("excalidraw-tooltip--visible"); position = "below",
}, []); long = false,
}: TooltipProps) => (
return ( <div className="Tooltip">
<div <span
className="excalidraw-tooltip-wrapper" className={
onPointerEnter={(event) => position === "above"
updateTooltip( ? "Tooltip__label Tooltip__label--above"
event.currentTarget as HTMLDivElement, : "Tooltip__label Tooltip__label--below"
getTooltipDiv(),
label,
long,
)
}
onPointerLeave={() =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
} }
style={{ width: long ? "50ch" : "10ch" }}
> >
{children} {label}
</div> </span>
); {children}
}; </div>
);
+1 -2
View File
@@ -2,8 +2,7 @@
.UserList { .UserList {
pointer-events: none; pointer-events: none;
/*github corner*/ /*github corner*/
padding: var(--space-factor) var(--space-factor) var(--space-factor) padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
var(--space-factor);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
+506 -441
View File
File diff suppressed because it is too large Load Diff
+11 -24
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,20 +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",
}; } as const;
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}";
@@ -90,7 +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",
} as const; };
export const EXPORT_DATA_TYPES = { export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw", excalidraw: "excalidraw",
@@ -98,8 +91,6 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib", excalidrawLibrary: "excalidrawlib",
} as const; } as const;
export const EXPORT_SOURCE = window.location.origin;
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const; } as const;
@@ -111,6 +102,7 @@ export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000; export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const DETECT_POSITION_CHANGE_INTERVAL = 500;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -138,19 +130,14 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: { canvasActions: {
changeViewBackgroundColor: true, changeViewBackgroundColor: true,
clearCanvas: true, clearCanvas: true,
export: { saveFileToDisk: true }, export: true,
loadScene: true, loadScene: true,
saveToActiveFile: true, saveAsScene: true,
saveScene: true,
theme: true, theme: true,
saveAsImage: true,
}, },
}; };
export const MQ_MAX_WIDTH_PORTRAIT = 730; export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; 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 EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
-1
View File
@@ -5,7 +5,6 @@
overflow: hidden; overflow: hidden;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */ white-space: nowrap; /* added line */
user-select: none;
} }
.LoadingMessage { .LoadingMessage {
+50 -30
View File
@@ -19,10 +19,6 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
&:focus {
outline: none;
}
// serves 2 purposes: // serves 2 purposes:
// 1. prevent selecting text outside the component when double-clicking or // 1. prevent selecting text outside the component when double-clicking or
// dragging inside it (e.g. on canvas) // dragging inside it (e.g. on canvas)
@@ -51,12 +47,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;
} }
@@ -333,8 +328,8 @@
.App-menu_bottom { .App-menu_bottom {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
grid-template-columns: min-content auto min-content; grid-template-columns: 1fr auto 1fr;
grid-gap: 15px; grid-gap: 4px;
align-items: flex-start; align-items: flex-start;
cursor: default; cursor: default;
pointer-events: none !important; pointer-events: none !important;
@@ -359,6 +354,10 @@
} }
} }
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all;
}
.App-menu_bottom > *:first-child { .App-menu_bottom > *:first-child {
justify-self: flex-start; justify-self: flex-start;
} }
@@ -414,6 +413,24 @@
&:active { &:active {
background-color: var(--button-gray-2); background-color: var(--button-gray-2);
} }
&.dropdown-select--floating {
position: absolute;
margin: 0.5em;
}
}
.dropdown-select__language.dropdown-select--floating {
position: absolute;
bottom: 10px;
:root[dir="ltr"] & {
right: 44px;
}
:root[dir="rtl"] & {
left: 44px;
}
} }
.zIndexButton { .zIndexButton {
@@ -440,38 +457,27 @@
} }
.help-icon { .help-icon {
display: flex; position: absolute;
cursor: pointer; cursor: pointer;
fill: $oc-gray-6; fill: $oc-gray-6;
bottom: 14px;
width: 1.5rem;
padding: 0; padding: 0;
margin: 0; margin: 0;
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; right: 14px;
background: transparent; }
color: var(--text-primary-color);
font-family: var(--ui-font);
}
.undo-redo-buttons { :root[dir="rtl"] & {
display: grid; left: 14px;
grid-auto-flow: column; }
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
} }
@include isMobile { @include isMobile {
@@ -490,6 +496,20 @@
} }
} }
.github-corner {
position: absolute;
top: 0;
z-index: 2;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
}
.zen-mode-visibility { .zen-mode-visibility {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
+2 -4
View File
@@ -14,12 +14,11 @@
--focus-highlight-color: #{$oc-blue-2}; --focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: #{$oc-black}; --icon-fill-color: #{$oc-black};
--icon-green-fill-color: #{$oc-green-9}; --icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white}; --input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-3}; --input-border-color: #{$oc-gray-3};
--input-hover-bg-color: #{$oc-gray-1}; --input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7}; --input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.96); --island-bg-color: rgba(255, 255, 255, 0.9);
--keybinding-color: #{$oc-gray-5}; --keybinding-color: #{$oc-gray-5};
--link-color: #{$oc-blue-7}; --link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)}; --overlay-bg-color: #{transparentize($oc-white, 0.12)};
@@ -57,12 +56,11 @@
--focus-highlight-color: #{$oc-blue-6}; --focus-highlight-color: #{$oc-blue-6};
--icon-fill-color: #{$oc-gray-4}; --icon-fill-color: #{$oc-gray-4};
--icon-green-fill-color: #{$oc-green-4}; --icon-green-fill-color: #{$oc-green-4};
--default-bg-color: #121212;
--input-bg-color: #121212; --input-bg-color: #121212;
--input-border-color: #2e2e2e; --input-border-color: #2e2e2e;
--input-hover-bg-color: #181818; --input-hover-bg-color: #181818;
--input-label-color: #{$oc-gray-2}; --input-label-color: #{$oc-gray-2};
--island-bg-color: rgba(30, 30, 30, 0.98); --island-bg-color: #1e1e1e;
--keybinding-color: #{$oc-gray-6}; --keybinding-color: #{$oc-gray-6};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)}; --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c; --popup-bg-color: #2c2c2c;
+3 -26
View File
@@ -1,15 +1,13 @@
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants"; import { EXPORT_DATA_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement } 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 } 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 { LibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => { const parseFileContents = async (blob: Blob | File) => {
let contents: string; let contents: string;
@@ -81,30 +79,10 @@ export const getMimeType = (blob: Blob | string): string => {
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 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 {
@@ -117,7 +95,7 @@ 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)
@@ -125,7 +103,6 @@ export const loadFromBlob = async (
}, },
}, },
localAppState, localAppState,
localElements,
); );
return result; return result;
@@ -137,7 +114,7 @@ export const loadFromBlob = async (
export const loadLibraryFromBlob = async (blob: Blob) => { export const loadLibraryFromBlob = async (blob: Blob) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
const data: ImportedLibraryData = JSON.parse(contents); const data: LibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) { if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
-122
View File
@@ -1,122 +0,0 @@
import {
FileWithHandle,
fileOpen as _fileOpen,
fileSave as _fileSave,
FileSystemHandle,
supported as nativeFileSystemSupported,
} from "@dwelle/browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION =
| "jpg"
| "png"
| "svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
jpg: "image/jpeg",
png: "image/png",
svg: "image/svg+xml",
json: "application/json",
excalidraw: MIME_TYPES.excalidraw,
excalidrawlib: MIME_TYPES.excalidrawlib,
};
const INPUT_CHANGE_INTERVAL_MS = 500;
export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[];
description?: string;
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(FILE_TYPE_TO_MIME_TYPE[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 };
+38 -25
View File
@@ -1,15 +1,14 @@
import { fileSave } from "browser-fs-access";
import { import {
copyBlobToClipboardAsPng, copyCanvasToClipboardAsPng,
copyTextToSystemClipboard, copyTextToSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { DEFAULT_EXPORT_PADDING } 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 } 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,43 +18,51 @@ export const exportCanvas = async (
type: ExportType, type: ExportType,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
canvas: HTMLCanvasElement,
{ {
exportBackground, exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = 10,
viewBackgroundColor, viewBackgroundColor,
name, name,
fileHandle = null, scale = 1,
shouldAddWatermark,
}: { }: {
exportBackground: boolean; exportBackground: boolean;
exportPadding?: number; exportPadding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
name: string; name: string;
fileHandle?: FileSystemHandle | null; scale?: number;
shouldAddWatermark: boolean;
}, },
) => { ) => {
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(elements, { const tempSvg = exportToSvg(elements, {
exportBackground, exportBackground,
exportWithDarkMode: appState.exportWithDarkMode, exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor, viewBackgroundColor,
exportPadding, exportPadding,
exportScale: appState.exportScale, scale,
exportEmbedScene: appState.exportEmbedScene && type === "svg", shouldAddWatermark,
metadata:
appState.exportEmbedScene && type === "svg"
? await (
await import(/* webpackChunkName: "image" */ "./image")
).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: "image/svg+xml" }), 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;
} }
} }
@@ -64,13 +71,15 @@ export const exportCanvas = async (
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
exportPadding, exportPadding,
scale,
shouldAddWatermark,
}); });
tempCanvas.style.display = "none"; tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas); document.body.appendChild(tempCanvas);
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (type === "png") { if (type === "png") {
const fileName = `${name}.png`;
let blob = await canvasToBlob(tempCanvas);
if (appState.exportEmbedScene) { if (appState.exportEmbedScene) {
blob = await ( blob = await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
@@ -80,14 +89,13 @@ export const exportCanvas = async (
}); });
} }
return await fileSave(blob, { await fileSave(blob, {
name, fileName,
extension: "png", extensions: [".png"],
fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
await copyBlobToClipboardAsPng(blob); await copyCanvasToClipboardAsPng(tempCanvas);
} catch (error) { } catch (error) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error; throw error;
@@ -95,4 +103,9 @@ export const exportCanvas = async (
throw new Error(t("alerts.couldNotCopyToClipboard")); throw new Error(t("alerts.couldNotCopyToClipboard"));
} }
} }
// clean up the DOM
if (tempCanvas !== canvas) {
tempCanvas.remove();
}
}; };
+48 -53
View File
@@ -1,32 +1,28 @@
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob"; import { loadFromBlob } from "./blob";
import { Library } from "./library";
import { import { ImportedDataState } from "./types";
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
} from "./types";
import Library from "./library";
export const serializeAsJSON = ( export const serializeAsJSON = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Partial<AppState>, appState: AppState,
): string => { ): string =>
const data: ExportedDataState = { JSON.stringify(
type: EXPORT_DATA_TYPES.excalidraw, {
version: 2, type: EXPORT_DATA_TYPES.excalidraw,
source: EXPORT_SOURCE, version: 2,
elements: clearElementsForExport(elements), source: window.location.origin,
appState: cleanAppStateForExport(appState), elements: clearElementsForExport(elements),
}; appState: cleanAppStateForExport(appState),
},
return JSON.stringify(data, null, 2); null,
}; 2,
);
export const saveAsJSON = async ( export const saveAsJSON = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -37,21 +33,19 @@ export const saveAsJSON = async (
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
@@ -66,7 +60,7 @@ export const loadFromJSON = async (
], ],
*/ */
}); });
return loadFromBlob(blob, localAppState, localElements); return loadFromBlob(blob, localAppState);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {
@@ -91,28 +85,29 @@ export const isValidLibrary = (json: any) => {
); );
}; };
export const saveLibraryAsJSON = async (library: Library) => { export const saveLibraryAsJSON = async () => {
const libraryItems = await library.loadLibrary(); const library = await Library.loadLibrary();
const data: ExportedLibraryData = { const serialized = JSON.stringify(
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: 1,
source: EXPORT_SOURCE,
library: libraryItems,
};
const serialized = JSON.stringify(data, null, 2);
await fileSave(
new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,
}),
{ {
name: "library", type: EXPORT_DATA_TYPES.excalidrawLibrary,
extension: "excalidrawlib", version: 1,
description: "Excalidraw library file", library,
}, },
null,
2,
); );
const fileName = "library.excalidrawlib";
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,
});
await fileSave(blob, {
fileName,
description: "Excalidraw library file",
extensions: [".excalidrawlib"],
});
}; };
export const importLibraryFromJSON = async (library: Library) => { export const importLibraryFromJSON = async () => {
const blob = await fileOpen({ const blob = await fileOpen({
description: "Excalidraw library files", description: "Excalidraw library 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
@@ -121,5 +116,5 @@ export const importLibraryFromJSON = async (library: Library) => {
extensions: [".json", ".excalidrawlib"], extensions: [".json", ".excalidrawlib"],
*/ */
}); });
await library.importLibrary(blob); await Library.importLibrary(blob);
}; };
+32 -45
View File
@@ -1,29 +1,22 @@
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreElements } from "./restore"; import { restoreElements } from "./restore";
import { STORAGE_KEYS } from "../constants";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type App from "../components/App"; import { NonDeleted, ExcalidrawElement } from "../element/types";
import { nanoid } from "nanoid";
class Library { export class Library {
private libraryCache: LibraryItems | null = null; private static libraryCache: LibraryItems | null = null;
private app: App; public static csrfToken = nanoid();
constructor(app: App) { static resetLibrary = () => {
this.app = app; Library.libraryCache = null;
} localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
resetLibrary = async () => {
await this.app.props.onLibraryChange?.([]);
this.libraryCache = [];
};
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
return elements.length ? elements : null;
}; };
/** imports library (currently merges, removing duplicates) */ /** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob) { static async importLibrary(blob: Blob) {
const libraryFile = await loadLibraryFromBlob(blob); const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !libraryFile.library) { if (!libraryFile || !libraryFile.library) {
return; return;
@@ -53,41 +46,37 @@ class Library {
}); });
}; };
const existingLibraryItems = await this.loadLibrary(); const existingLibraryItems = await Library.loadLibrary();
const filtered = libraryFile.library!.reduce((acc, libraryItem) => { const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
const restoredItem = this.restoreLibraryItem(libraryItem); const restored = getNonDeletedElements(restoreElements(libraryItem));
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) { if (isUniqueitem(existingLibraryItems, restored)) {
acc.push(restoredItem); acc.push(restored);
} }
return acc; return acc;
}, [] as Mutable<LibraryItems>); }, [] as (readonly NonDeleted<ExcalidrawElement>[])[]);
await this.saveLibrary([...existingLibraryItems, ...filtered]); Library.saveLibrary([...existingLibraryItems, ...filtered]);
} }
loadLibrary = (): Promise<LibraryItems> => { static loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (this.libraryCache) { if (Library.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache))); return resolve(JSON.parse(JSON.stringify(Library.libraryCache)));
} }
try { try {
const libraryItems = this.app.libraryItemsFromStorage; const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
if (!libraryItems) { if (!data) {
return resolve([]); return resolve([]);
} }
const items = libraryItems.reduce((acc, item) => { const items = (JSON.parse(data) as LibraryItems).map((elements) =>
const restoredItem = this.restoreLibraryItem(item); restoreElements(elements),
if (restoredItem) { ) as Mutable<LibraryItems>;
acc.push(item);
}
return acc;
}, [] as Mutable<LibraryItems>);
// clone to ensure we don't mutate the cached library elements in the app // clone to ensure we don't mutate the cached library elements in the app
this.libraryCache = JSON.parse(JSON.stringify(items)); Library.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items); resolve(items);
} catch (error) { } catch (error) {
@@ -97,19 +86,17 @@ class Library {
}); });
}; };
saveLibrary = async (items: LibraryItems) => { static saveLibrary = (items: LibraryItems) => {
const prevLibraryItems = this.libraryCache; const prevLibraryItems = Library.libraryCache;
try { try {
const serializedItems = JSON.stringify(items); const serializedItems = JSON.stringify(items);
// cache optimistically so that the app has access to the latest // cache optimistically so that consumers have access to the latest
// immediately // immediately
this.libraryCache = JSON.parse(serializedItems); Library.libraryCache = JSON.parse(serializedItems);
await this.app.props.onLibraryChange?.(items); localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
} catch (error) { } catch (error) {
this.libraryCache = prevLibraryItems; Library.libraryCache = prevLibraryItems;
throw error; console.error(error);
} }
}; };
} }
export default Library;
-38
View File
@@ -1,38 +0,0 @@
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
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,
{
exportBackground,
viewBackgroundColor,
name,
fileHandle,
},
);
return { fileHandle };
};
+33 -103
View File
@@ -1,72 +1,36 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
FontFamily,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
FontFamilyValues,
} from "../element/types"; } from "../element/types";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types"; import { DataState, 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 { bumpVersion } from "../element/mutateElement";
type RestoredAppState = Omit< const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
AppState, for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
"offsetTop" | "offsetLeft" | "width" | "height" if (fontFamilyString.includes(fontFamilyName)) {
>; return parseInt(id) as FontFamily;
}
export const AllowedExcalidrawElementTypes: Record<
ExcalidrawElement["type"],
true
> = {
selection: true,
text: true,
rectangle: true,
diamond: true,
ellipse: true,
line: true,
arrow: true,
freedraw: true,
};
export type RestoredDataState = {
elements: ExcalidrawElement[];
appState: RestoredAppState;
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
return FONT_FAMILY[
fontFamilyName as keyof typeof FONT_FAMILY
] as FontFamilyValues;
} }
return DEFAULT_FONT_FAMILY; return DEFAULT_FONT_FAMILY;
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <T extends ExcalidrawElement>(
T extends ExcalidrawElement,
K extends keyof Omit<
Required<T>,
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
>
>(
element: Required<T>, element: Required<T>,
extra: Pick<T, K>, extra: Omit<Required<T>, keyof ExcalidrawElement>,
): T => { ): T => {
const base: Pick<T, keyof ExcalidrawElement> = { const base: Pick<T, keyof ExcalidrawElement> = {
type: (extra as Partial<T>).type || element.type, 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,
@@ -79,8 +43,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 as Partial<T>).x ?? element.x ?? 0, x: element.x || 0,
y: (extra as Partial<T>).y ?? element.y ?? 0, y: element.y || 0,
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor,
width: element.width || 0, width: element.width || 0,
@@ -123,51 +87,28 @@ const restoreElement = (
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
}); });
case "freedraw": {
return restoreElementWithProperties(element, {
points: element.points,
lastCommittedPoint: null,
simulatePressure: element.simulatePressure,
pressures: element.pressures,
});
}
case "line":
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw": case "draw":
case "line":
case "arrow": { case "arrow": {
const { const {
startArrowhead = null, startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null, endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element; } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
type:
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
? "line"
: element.type,
startBinding: element.startBinding, startBinding: element.startBinding,
endBinding: element.endBinding, endBinding: element.endBinding,
points:
// migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points,
lastCommittedPoint: null, lastCommittedPoint: null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
points,
x,
y,
}); });
} }
// generic elements // generic elements
@@ -186,20 +127,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 = 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);
} }
} }
@@ -209,32 +143,29 @@ 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 => { ): DataState["appState"] => {
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 {
...nextAppState, ...nextAppState,
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
? nextAppState.elementType
: "selection",
// Migrates from previous version where appState.zoom was a number // Migrates from previous version where appState.zoom was a number
zoom: zoom:
typeof appState.zoom === "number" typeof appState.zoom === "number"
@@ -255,10 +186,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, ): DataState => {
): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements, localElements), elements: restoreElements(data?.elements),
appState: restoreAppState(data?.appState, localAppState || null), appState: restoreAppState(data?.appState, localAppState || null),
}; };
}; };
+15 -19
View File
@@ -1,30 +1,26 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, LibraryItems } from "../types"; import { AppState, LibraryItems } from "../types";
import type { cleanAppStateForExport } from "../appState";
export interface ExportedDataState { export interface DataState {
type: string; type?: string;
version: number; version?: string;
source: string; source?: string;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>; appState: Omit<AppState, "offsetTop" | "offsetLeft" | "width" | "height">;
} }
export interface ImportedDataState { export interface ImportedDataState {
type?: string;
version?: string;
source?: string;
elements?: DataState["elements"] | null;
appState?: Partial<DataState["appState"]> | null;
scrollToContent?: boolean;
}
export interface LibraryData {
type?: string; type?: string;
version?: number; version?: number;
source?: string; source?: string;
elements?: readonly ExcalidrawElement[] | null; library?: LibraryItems;
appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems;
} }
export interface ExportedLibraryData {
type: string;
version: number;
source: string;
library: LibraryItems;
}
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}

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