Compare commits

..
Author SHA1 Message Date
zsviczianandGitHub 13309a66c5 Update textWysiwyg.tsx 2022-03-14 07:15:21 +01:00
zsviczianandGitHub 531829d95e Update textWysiwyg.tsx 2022-03-14 07:11:12 +01:00
zsviczianandGitHub d3cbceb7fa Update textWysiwyg.tsx 2022-03-13 23:45:03 +01:00
zsviczianandGitHub 73111500d3 Update textWysiwyg.tsx 2022-03-13 23:43:03 +01:00
zsviczianandGitHub 9e17b64e5e Update textWysiwyg.tsx 2022-03-13 23:38:54 +01:00
zsviczianandGitHub 326da61573 Update textWysiwyg.tsx 2022-03-13 23:36:05 +01:00
zsviczianandGitHub 994f2a3f1e Update textWysiwyg.tsx 2022-03-13 23:30:43 +01:00
zsviczianandGitHub 5dbcf64353 Update textWysiwyg.tsx 2022-03-13 23:25:37 +01:00
zsviczianandGitHub eda2320dae Update textWysiwyg.tsx 2022-03-13 23:17:19 +01:00
zsviczianandGitHub b610c04481 Update textWysiwyg.tsx 2022-03-13 23:04:22 +01:00
zsviczianandGitHub d969849357 Update textWysiwyg.tsx 2022-03-13 23:01:04 +01:00
zsviczianandGitHub 9a66fc6c05 Update textWysiwyg.tsx 2022-03-13 22:49:14 +01:00
zsviczianandGitHub 158f169c43 Update textWysiwyg.tsx 2022-03-13 22:28:37 +01:00
zsviczianandGitHub ce27cb6159 Update textWysiwyg.tsx 2022-03-13 22:23:08 +01:00
zsviczianandGitHub 2e04bcd485 Update textWysiwyg.tsx 2022-03-13 21:59:07 +01:00
zsviczianandGitHub 7436f3926b debug iOS 2022-03-13 21:55:21 +01:00
zsviczianandGitHub e429b7048d Update textWysiwyg.tsx 2022-03-11 13:44:25 +01:00
zsviczianandGitHub e61b447413 Update textWysiwyg.tsx 2022-03-11 13:39:19 +01:00
zsviczianandGitHub 73f0d854bf Update MobileMenu.tsx 2022-03-11 13:34:42 +01:00
zsviczianandGitHub cec3cf8334 Update textWysiwyg.tsx 2022-03-11 13:33:15 +01:00
zsviczianandGitHub 8640e75ccf Update constants.ts 2022-03-11 13:28:21 +01:00
zsviczianandGitHub ca7ce64fea Update MobileMenu.tsx 2022-03-11 12:02:07 +01:00
zsviczianandGitHub e3a78fe5df Update MobileMenu.tsx 2022-03-11 11:49:18 +01:00
zsviczianandGitHub 554985f749 Update MobileMenu.tsx 2022-03-11 11:46:53 +01:00
zsviczianandGitHub d3857fbb35 Update MobileMenu.tsx 2022-03-11 11:41:49 +01:00
zsviczianandGitHub 93c72cbb32 Update MobileMenu.tsx 2022-03-11 11:21:55 +01:00
zsviczianandGitHub aeb4d39387 Update MobileMenu.tsx 2022-03-11 11:18:20 +01:00
zsviczianandGitHub a0259360d6 Update MobileMenu.tsx 2022-03-11 11:15:24 +01:00
zsviczianandGitHub 243d8de7a8 Update MobileMenu.tsx 2022-03-11 11:12:50 +01:00
zsviczianandGitHub 81c927bab6 Update MobileMenu.tsx 2022-03-11 11:07:28 +01:00
252 changed files with 9021 additions and 15299 deletions
+4 -14
View File
@@ -4,19 +4,9 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room) REACT_APP_PORTAL_URL=http://localhost:3002
REACT_APP_WS_SERVER_URL=http://localhost:3002 # Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
# set this only if using the collaboration workflow we use on excalidraw.com REACT_APP_WS_SERVER_URL=
REACT_APP_PORTAL_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
-2
View File
@@ -13,5 +13,3 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
# production-only vars # production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
REACT_APP_PLUS_APP=https://app.excalidraw.com
+37
View File
@@ -0,0 +1,37 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- lipis
assignees:
- lipis
open-pull-requests-limit: 20
- package-ecosystem: npm
directory: /src/packages/excalidraw/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
open-pull-requests-limit: 20
- package-ecosystem: npm
directory: /src/packages/utils/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
open-pull-requests-limit: 20
+1 -1
View File
@@ -1,4 +1,4 @@
name: Auto release excalidraw next name: Auto release @excalidraw/excalidraw-next
on: on:
push: push:
branches: branches:
+2 -2
View File
@@ -1,4 +1,4 @@
name: Auto release excalidraw preview name: Auto release preview @excalidraw/excalidraw-preview
on: on:
issue_comment: issue_comment:
types: [created, edited] types: [created, edited]
@@ -6,7 +6,7 @@ on:
jobs: jobs:
Auto-release-excalidraw-preview: Auto-release-excalidraw-preview:
name: Auto release preview name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: React to release comment - name: React to release comment
+9 -36
View File
@@ -34,7 +34,7 @@ Last but not least, we're thankful to these companies for offering their service
## Who's integrating Excalidraw ## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) • [VS Code](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor) [Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
## Documentation ## Documentation
@@ -128,41 +128,14 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
#### Commands #### Commands
##### Install the dependencies | Command | Description |
| ------------------ | --------------------------------- |
``` | `yarn` | Install the dependencies |
yarn | `yarn start` | Run the project |
``` | `yarn fix` | Reformat all files with Prettier |
| `yarn test` | Run tests |
##### Run the project | `yarn test:update` | Update test snapshots |
| `yarn test:code` | Test for formatting with Prettier |
```
yarn start
```
##### Reformat all files with Prettier
```
yarn fix
```
##### Run tests
```
yarn test
```
##### Update test snapshots
```
yarn test:update
```
##### Test for formatting with Prettier
```
yarn test:code
```
#### Docker Compose #### Docker Compose
+6 -5
View File
@@ -1,11 +1,12 @@
rules_version = '2'; rules_version = '2';
service firebase.storage { service firebase.storage {
match /b/{bucket}/o { match /b/{bucket}/o {
match /{files}/rooms/{room}/{file} { match /{migrations} {
allow get, write: if true; match /{scenes}/{scene} {
} allow get, write: if true;
match /{files}/shareLinks/{shareLink}/{file} { // redundant, but let's be explicit'
allow get, write: if true; allow list: if false;
}
} }
} }
} }
+16 -20
View File
@@ -22,23 +22,22 @@
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.2",
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.4.3",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "17.0.39", "@types/react": "17.0.39",
"@types/react-dom": "17.0.11", "@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.24.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.3.3", "nanoid": "3.1.32",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.16", "perfect-freehand": "1.0.16",
@@ -51,7 +50,7 @@
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.49.7",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.5.5" "typescript": "4.5.5"
}, },
@@ -59,19 +58,19 @@
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3", "@types/pako": "1.0.3",
"@types/resize-observer-browser": "0.1.7", "@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "10.0.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest-canvas-mock": "2.4.0", "jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.7", "lint-staged": "12.3.3",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.6.2", "prettier": "2.5.1",
"rewire": "6.0.0" "rewire": "5.0.0"
}, },
"resolutions": { "resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2" "@typescript-eslint/typescript-estree": "5.10.2"
@@ -94,8 +93,7 @@
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build:prebuild": "node ./scripts/prebuild.js", "build": "yarn build:app && yarn build:version",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
@@ -113,8 +111,6 @@
"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", "autorelease": "node scripts/autorelease.js"
"prerelease": "node scripts/prerelease.js",
"release": "node scripts/release.js"
} }
} }
+26 -39
View File
@@ -52,25 +52,6 @@
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
// Redirect only the bare root path, so link/room/library urls are not
// redirected.
//
// Putting into index.html for best performance (can't redirect on server
// due to location.hash checks).
if (
window.location.pathname === "/" &&
!window.location.hash &&
!window.location.search &&
// if its present redirect
document.cookie.includes("excplus-autoredirect=true")
) {
window.location.href = "https://app.excalidraw.com";
}
</script>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<!-- Excalidraw version --> <!-- Excalidraw version -->
@@ -98,22 +79,6 @@
/> />
<link rel="stylesheet" href="fonts.css" type="text/css" /> <link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
<script>
{
const _WebSocket = window.WebSocket;
window.WebSocket = function (url) {
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
console.info(
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
);
} else {
return new _WebSocket(url);
}
};
}
</script>
<% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/"; window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
@@ -159,6 +124,26 @@
user-select: none; user-select: none;
} }
.LoadingMessage {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
#root { #root {
height: 100%; height: 100%;
-webkit-touch-callout: none; -webkit-touch-callout: none;
@@ -167,10 +152,8 @@
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
}
@media screen and (min-width: 1200px) { @media screen and (min-width: 1200px) {
#root {
-webkit-touch-callout: default; -webkit-touch-callout: default;
-webkit-user-select: auto; -webkit-user-select: auto;
-khtml-user-select: auto; -khtml-user-select: auto;
@@ -187,6 +170,10 @@
<header> <header>
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root">
<div class="LoadingMessage">
<span>Loading scene...</span>
</div>
</div>
</body> </body>
</html> </html>
+11 -6
View File
@@ -5,25 +5,22 @@ const core = require("@actions/core");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
const isPreview = process.argv.slice(2)[0] === "preview";
const getShortCommitHash = () => { const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim(); return execSync("git rev-parse --short HEAD").toString().trim();
}; };
const publish = () => { const publish = () => {
const tag = isPreview ? "preview" : "next";
try { try {
execSync(`yarn --frozen-lockfile`); execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`); execSync(`yarn --cwd ${excalidrawDir} publish`);
console.info(`Published ${pkg.name}@${tag}🎉`); console.info("Published 🎉");
core.setOutput( core.setOutput(
"result", "result",
`**Preview version has been shipped** :rocket: `**Preview version has been shipped** :rocket:
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`, You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
); );
} catch (error) { } catch (error) {
core.setOutput("result", "package couldn't be published :warning:!"); core.setOutput("result", "package couldn't be published :warning:!");
@@ -54,19 +51,27 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
} }
// update package.json // update package.json
pkg.name = "@excalidraw/excalidraw-next";
let version = `${pkg.version}-${getShortCommitHash()}`; let version = `${pkg.version}-${getShortCommitHash()}`;
// update readme // update readme
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
const isPreview = process.argv.slice(2)[0] === "preview";
if (isPreview) { if (isPreview) {
// use pullNumber-commithash as the version for preview // use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0]; const pullRequestNumber = process.argv.slice(3)[0];
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`; version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
// replace "excalidraw-next" with "excalidraw-preview"
pkg.name = "@excalidraw/excalidraw-preview";
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
data = data.trim();
} }
pkg.version = version; pkg.version = version;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8"); fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
console.info("Publish in progress..."); console.info("Publish in progress...");
publish(); publish();
}); });
-10
View File
@@ -36,7 +36,6 @@ const crowdinMap = {
"ru-RU": "en-ru", "ru-RU": "en-ru",
"si-LK": "en-silk", "si-LK": "en-silk",
"sk-SK": "en-sk", "sk-SK": "en-sk",
"sl-SI": "en-sl",
"sv-SE": "en-sv", "sv-SE": "en-sv",
"ta-IN": "en-ta", "ta-IN": "en-ta",
"tr-TR": "en-tr", "tr-TR": "en-tr",
@@ -48,8 +47,6 @@ const crowdinMap = {
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "en-cs",
"kk-KZ": "en-kk", "kk-KZ": "en-kk",
"vi-vn": "en-vi",
"mr-in": "en-mr",
}; };
const flags = { const flags = {
@@ -89,7 +86,6 @@ const flags = {
"ru-RU": "🇷🇺", "ru-RU": "🇷🇺",
"si-LK": "🇱🇰", "si-LK": "🇱🇰",
"sk-SK": "🇸🇰", "sk-SK": "🇸🇰",
"sl-SI": "🇸🇮",
"sv-SE": "🇸🇪", "sv-SE": "🇸🇪",
"ta-IN": "🇮🇳", "ta-IN": "🇮🇳",
"tr-TR": "🇹🇷", "tr-TR": "🇹🇷",
@@ -97,9 +93,6 @@ const flags = {
"zh-CN": "🇨🇳", "zh-CN": "🇨🇳",
"zh-HK": "🇭🇰", "zh-HK": "🇭🇰",
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"eu-ES": "🇪🇦",
"vi-VN": "🇻🇳",
"mr-IN": "🇮🇳",
}; };
const languages = { const languages = {
@@ -140,7 +133,6 @@ const languages = {
"ru-RU": "Русский", "ru-RU": "Русский",
"si-LK": "සිංහල", "si-LK": "සිංහල",
"sk-SK": "Slovenčina", "sk-SK": "Slovenčina",
"sl-SI": "Slovenščina",
"sv-SE": "Svenska", "sv-SE": "Svenska",
"ta-IN": "Tamil", "ta-IN": "Tamil",
"tr-TR": "Türkçe", "tr-TR": "Türkçe",
@@ -148,8 +140,6 @@ const languages = {
"zh-CN": "简体中文", "zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)", "zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"vi-VN": "Tiếng Việt",
"mr-IN": "मराठी",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(
-20
View File
@@ -1,20 +0,0 @@
const fs = require("fs");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = "./public/service-worker.js";
const newPath = "./src/service-worker.js";
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();
-37
View File
@@ -1,37 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
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 prerelease = async (nextVersion) => {
try {
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
console.info("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
prerelease(nextVersion);
+25 -30
View File
@@ -1,44 +1,39 @@
const fs = require("fs"); const fs = require("fs");
const { execSync } = require("child_process"); 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 excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8"); const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
const updateReadme = () => { pkg.version = nextVersion;
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw"); const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
// remove note for stable readme
const data = originalReadMe.slice(excalidrawIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
}; };
const publish = () => { const release = async (nextVersion) => {
try { try {
execSync(`yarn --frozen-lockfile`); updateReadme();
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); await updateChangelog(nextVersion);
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); updatePackageVersion(nextVersion);
execSync(`yarn --cwd ${excalidrawDir} publish`); await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
} }
}; };
const release = () => { const nextVersion = process.argv.slice(2)[0];
updateReadme(); if (!nextVersion) {
console.info("Note for stable readme removed"); console.error("Pass the next version to release!");
process.exit(1);
publish(); }
console.info(`Published ${pkg.version}!`); release(nextVersion);
// revert readme after release
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Readme reverted");
};
release();
+27
View File
@@ -0,0 +1,27 @@
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;
+3 -4
View File
@@ -7,7 +7,6 @@ import { t } from "../i18n";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -25,9 +24,9 @@ export const actionAddToLibrary = register({
} }
return app.library return app.library
.getLatestLibrary() .loadLibrary()
.then((items) => { .then((items) => {
return app.library.setLibrary([ return app.library.saveLibrary([
{ {
id: randomId(), id: randomId(),
status: "unpublished", status: "unpublished",
@@ -42,7 +41,7 @@ export const actionAddToLibrary = register({
commitToHistory: false, commitToHistory: false,
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.addedToLibrary") }, toastMessage: t("toast.addedToLibrary"),
}, },
}; };
}) })
-8
View File
@@ -43,7 +43,6 @@ const alignSelectedElements = (
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -73,7 +72,6 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -103,7 +101,6 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -133,8 +130,6 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -164,8 +159,6 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -191,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
-136
View File
@@ -1,136 +0,0 @@
import { VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
getBoundTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
hasBoundTextElement,
isTextBindableContainer,
} from "../element/typeChecks";
import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {
const textElement =
isTextElement(selectedElements[0]) ||
isTextElement(selectedElements[1]);
let bindingContainer;
if (isTextBindableContainer(selectedElements[0])) {
bindingContainer = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[1])) {
bindingContainer = selectedElements[1];
}
if (
textElement &&
bindingContainer &&
getBoundTextElement(bindingContainer) === null
) {
return true;
}
}
return false;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
if (
isTextElement(selectedElements[0]) &&
isTextBindableContainer(selectedElements[1])
) {
textElement = selectedElements[0];
container = selectedElements[1];
} else {
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
});
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return {
elements: updatedElements,
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
};
},
});
+6 -62
View File
@@ -1,5 +1,5 @@
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { eraser, zoomIn, zoomOut } from "../components/icons"; import { zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants"; import { THEME, ZOOM_STEP } from "../constants";
@@ -11,17 +11,15 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState"; import { getDefaultAppState } from "../appState";
import ClearCanvas from "../components/ClearCanvas"; import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
@@ -51,7 +49,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
app.imageCache.clear(); app.imageCache.clear();
return { return {
@@ -62,6 +59,7 @@ export const actionClearCanvas = register({
...getDefaultAppState(), ...getDefaultAppState(),
files: {}, files: {},
theme: appState.theme, theme: appState.theme,
elementLocked: appState.elementLocked,
penMode: appState.penMode, penMode: appState.penMode,
penDetected: appState.penDetected, penDetected: appState.penDetected,
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
@@ -69,10 +67,8 @@ export const actionClearCanvas = register({
gridSize: appState.gridSize, gridSize: appState.gridSize,
showStats: appState.showStats, showStats: appState.showStats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: elementType:
appState.activeTool.type === "image" appState.elementType === "image" ? "selection" : appState.elementType,
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -83,7 +79,6 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
@@ -119,7 +114,6 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
@@ -155,7 +149,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
@@ -254,7 +247,6 @@ const zoomToFitElements = (
export const actionZoomToSelected = register({ export const actionZoomToSelected = register({
name: "zoomToSelection", name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true), perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.TWO && event.code === CODES.TWO &&
@@ -265,7 +257,6 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
@@ -276,7 +267,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { appState: {
@@ -299,49 +289,3 @@ export const actionToggleTheme = register({
), ),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
}); });
export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "eraser",
lastActiveToolBeforeEraser: appState.activeTool,
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
},
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
type="button"
icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })}
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
}}
size={data?.size || "medium"}
></ToolButton>
),
});
+11 -55
View File
@@ -1,23 +1,16 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { import { copyToClipboard } from "../clipboard";
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index"; import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true); copyToClipboard(getNonDeletedElements(elements), appState, app.files);
copyToClipboard(selectedElements, appState, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
@@ -30,7 +23,6 @@ export const actionCopy = register({
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
@@ -41,7 +33,6 @@ export const actionCut = register({
export const actionCopyAsSvg = register({ export const actionCopyAsSvg = register({
name: "copyAsSvg", name: "copyAsSvg",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
@@ -82,7 +73,6 @@ export const actionCopyAsSvg = register({
export const actionCopyAsPng = register({ export const actionCopyAsPng = register({
name: "copyAsPng", name: "copyAsPng",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
@@ -107,16 +97,14 @@ export const actionCopyAsPng = register({
return { return {
appState: { appState: {
...appState, ...appState,
toast: { toastMessage: t("toast.copyToClipboardAsPng", {
message: t("toast.copyToClipboardAsPng", { exportSelection: selectedElements.length
exportSelection: selectedElements.length ? t("toast.selection")
? t("toast.selection") : t("toast.canvas"),
: t("toast.canvas"), exportColorScheme: appState.exportWithDarkMode
exportColorScheme: appState.exportWithDarkMode ? t("buttons.darkMode")
? t("buttons.darkMode") : t("buttons.lightMode"),
: t("buttons.lightMode"), }),
}),
},
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -134,35 +122,3 @@ export const actionCopyAsPng = register({
contextItemLabel: "labels.copyAsPng", contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
}); });
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
});
+1 -3
View File
@@ -12,7 +12,6 @@ import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -59,7 +58,6 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({ export const actionDeleteSelected = register({
name: "deleteSelectedElements", name: "deleteSelectedElements",
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
@@ -135,7 +133,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }), elementType: "selection",
multiElement: null, multiElement: null,
}, },
commitToHistory: isSomeElementSelected( commitToHistory: isSomeElementSelected(
+1 -3
View File
@@ -3,7 +3,7 @@ import {
DistributeVerticallyIcon, DistributeVerticallyIcon,
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../disitrubte";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -39,7 +39,6 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({ export const distributeHorizontally = register({
name: "distributeHorizontally", name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -69,7 +68,6 @@ export const distributeHorizontally = register({
export const distributeVertically = register({ export const distributeVertically = register({
name: "distributeVertically", name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
+6 -10
View File
@@ -22,7 +22,6 @@ import { isBoundToContainer } from "../element/typeChecks";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
@@ -128,15 +127,12 @@ const duplicateElements = (
{ {
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: newElements.reduce( selectedElementIds: newElements.reduce((acc, element) => {
(acc: Record<ExcalidrawElement["id"], true>, element) => { if (!isBoundToContainer(element)) {
if (!isBoundToContainer(element)) { acc[element.id] = true;
acc[element.id] = true; }
} return acc;
return acc; }, {} as any),
},
{},
),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(finalElements),
), ),
+12 -20
View File
@@ -1,3 +1,4 @@
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons"; import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
@@ -7,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } 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 { CheckboxItem } from "../components/CheckboxItem";
@@ -22,8 +23,8 @@ import { Theme } from "../element/types";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false }; return { appState: { ...appState, name: value }, commitToHistory: false };
}, },
PanelComponent: ({ appState, updateData, appProps }) => ( PanelComponent: ({ appState, updateData, appProps }) => (
@@ -40,7 +41,6 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({ export const actionChangeExportScale = register({
name: "changeExportScale", name: "changeExportScale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportScale: value }, appState: { ...appState, exportScale: value },
@@ -89,7 +89,6 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({ export const actionChangeExportBackground = register({
name: "changeExportBackground", name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportBackground: value }, appState: { ...appState, exportBackground: value },
@@ -108,7 +107,6 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({ export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene", name: "changeExportEmbedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportEmbedScene: value }, appState: { ...appState, exportEmbedScene: value },
@@ -130,7 +128,6 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle; const fileHandleExists = !!appState.fileHandle;
@@ -144,15 +141,13 @@ export const actionSaveToActiveFile = register({
appState: { appState: {
...appState, ...appState,
fileHandle, fileHandle,
toast: fileHandleExists toastMessage: fileHandleExists
? { ? fileHandle?.name
message: fileHandle?.name ? t("toast.fileSavedToFilename").replace(
? t("toast.fileSavedToFilename").replace( "{filename}",
"{filename}", `"${fileHandle.name}"`,
`"${fileHandle.name}"`, )
) : t("toast.fileSaved")
: t("toast.fileSaved"),
}
: null, : null,
}, },
}; };
@@ -177,7 +172,6 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({ export const actionSaveFileToDisk = register({
name: "saveFileToDisk", name: "saveFileToDisk",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
try { try {
const { fileHandle } = await saveAsJSON( const { fileHandle } = await saveAsJSON(
@@ -206,7 +200,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs} icon={saveAs}
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
@@ -216,7 +210,6 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({ export const actionLoadScene = register({
name: "loadScene", name: "loadScene",
trackEvent: { category: "export" },
perform: async (elements, appState, _, app) => { perform: async (elements, appState, _, app) => {
try { try {
const { const {
@@ -250,7 +243,7 @@ export const actionLoadScene = register({
icon={load} icon={load}
title={t("buttons.load")} title={t("buttons.load")}
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
onClick={updateData} onClick={updateData}
data-testid="load-button" data-testid="load-button"
/> />
@@ -259,7 +252,6 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({ export const actionExportWithDarkMode = register({
name: "exportWithDarkMode", name: "exportWithDarkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
+15 -43
View File
@@ -1,6 +1,6 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element"; import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils"; import { resetCursor } from "../utils";
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";
@@ -14,12 +14,10 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "../element/binding"; } from "../element/binding";
import { isBindingElement } from "../element/typeChecks"; import { isBindingElement } from "../element/typeChecks";
import { AppState } from "../types";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
trackEvent: false, perform: (elements, appState, _, { canvas, focusContainer }) => {
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
@@ -40,7 +38,6 @@ export const actionFinalize = register({
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
}, },
commitToHistory: true, commitToHistory: true,
@@ -50,12 +47,8 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
const pendingImageElement = if (appState.pendingImageElement) {
appState.pendingImageElementId && mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
} }
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
@@ -126,47 +119,27 @@ export const actionFinalize = register({
); );
} }
if ( if (!appState.elementLocked && appState.elementType !== "freedraw") {
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
} }
if ( if (
(!appState.activeTool.locked && (!appState.elementLocked && appState.elementType !== "freedraw") ||
appState.activeTool.type !== "freedraw") ||
!multiPointElement !multiPointElement
) { ) {
resetCursor(canvas); resetCursor(canvas);
} }
let activeTool: AppState["activeTool"];
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
...appState, ...appState,
cursorButton: "up", elementType:
activeTool: (appState.elementLocked || appState.elementType === "freedraw") &&
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
multiPointElement multiPointElement
? appState.activeTool ? appState.elementType
: activeTool, : "selection",
draggingElement: null, draggingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
@@ -174,16 +147,16 @@ export const actionFinalize = register({
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && multiPointElement &&
!appState.activeTool.locked && !appState.elementLocked &&
appState.activeTool.type !== "freedraw" appState.elementType !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
pendingImageElementId: null, pendingImageElement: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.elementType === "freedraw",
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
@@ -192,7 +165,7 @@ export const actionFinalize = register({
(!appState.draggingElement && appState.multiElement === null))) || (!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={done} icon={done}
@@ -200,7 +173,6 @@ export const actionFinalize = register({
aria-label={t("buttons.done")} aria-label={t("buttons.done")}
onClick={updateData} onClick={updateData}
visible={appState.multiElement != null} visible={appState.multiElement != null}
size={data?.size || "medium"}
/> />
), ),
}); });
-2
View File
@@ -35,7 +35,6 @@ const enableActionFlipVertical = (
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "horizontal"), elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -51,7 +50,6 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({ export const actionFlipVertical = register({
name: "flipVertical", name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "vertical"), elements: flipSelectedElements(elements, appState, "vertical"),
-2
View File
@@ -54,7 +54,6 @@ const enableActionGroup = (
export const actionGroup = register({ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -148,7 +147,6 @@ export const actionGroup = register({
export const actionUngroup = register({ export const actionUngroup = register({
name: "ungroup", name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
-2
View File
@@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()), writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) => keyTest: (event) =>
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history) => ({ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()), writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) => keyTest: (event) =>
-4
View File
@@ -9,7 +9,6 @@ import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({ perform: (_, appState) => ({
appState: { appState: {
...appState, ...appState,
@@ -30,7 +29,6 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({ export const actionToggleEditMenu = register({
name: "toggleEditMenu", name: "toggleEditMenu",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({ perform: (_elements, appState) => ({
appState: { appState: {
...appState, ...appState,
@@ -55,7 +53,6 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({ export const actionFullScreen = register({
name: "toggleFullScreen", name: "toggleFullScreen",
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => { perform: () => {
if (!isFullScreen()) { if (!isFullScreen()) {
allowFullScreen(); allowFullScreen();
@@ -72,7 +69,6 @@ export const actionFullScreen = register({
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) { if (appState.showHelpDialog) {
focusContainer(); focusContainer();
+15 -6
View File
@@ -1,4 +1,4 @@
import { getClientColors } 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";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
@@ -6,7 +6,6 @@ import { register } from "./register";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"]; const point = value as Collaborator["pointer"];
if (!point) { if (!point) {
@@ -31,18 +30,28 @@ export const actionGoToCollaborator = register({
}; };
}, },
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator]; const clientId: string | undefined = data?.id;
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const { background, stroke } = getClientColors(clientId, appState); const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return ( return (
<Avatar <Avatar
color={background} color={background}
border={stroke} border={stroke}
onClick={() => updateData(collaborator.pointer)} onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""} >
src={collaborator.avatarUrl} {shortName}
/> </Avatar>
); );
}, },
}); });
+42 -31
View File
@@ -166,7 +166,11 @@ const changeFontSize = (
let newElement: ExcalidrawTextElement = newElementWith(oldElement, { let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize, fontSize: newFontSize,
}); });
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
newElement = offsetElementAfterFontResize(oldElement, newElement); newElement = offsetElementAfterFontResize(oldElement, newElement);
@@ -194,7 +198,6 @@ const changeFontSize = (
export const actionChangeStrokeColor = register({ export const actionChangeStrokeColor = register({
name: "changeStrokeColor", name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
@@ -244,7 +247,6 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor", name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemBackgroundColor && { ...(value.currentItemBackgroundColor && {
@@ -287,7 +289,6 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -337,7 +338,6 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth", name: "changeStrokeWidth",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -385,7 +385,6 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({ export const actionChangeSloppiness = register({
name: "changeSloppiness", name: "changeSloppiness",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -434,7 +433,6 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({ export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle", name: "changeStrokeStyle",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -482,17 +480,12 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) =>
elements, newElementWith(el, {
appState, opacity: value,
(el) => }),
newElementWith(el, {
opacity: value,
}),
true,
), ),
appState: { ...appState, currentItemOpacity: value }, appState: { ...appState, currentItemOpacity: value },
commitToHistory: true, commitToHistory: true,
@@ -507,6 +500,20 @@ export const actionChangeOpacity = register({
max="100" max="100"
step="10" step="10"
onChange={(event) => updateData(+event.target.value)} onChange={(event) => updateData(+event.target.value)}
onWheel={(event) => {
event.stopPropagation();
const target = event.target as HTMLInputElement;
const STEP = 10;
const MAX = 100;
const MIN = 0;
const value = +target.value;
if (event.deltaY < 0 && value < MAX) {
updateData(value + STEP);
} else if (event.deltaY > 0 && value > MIN) {
updateData(value - STEP);
}
}}
value={ value={
getFormValue( getFormValue(
elements, elements,
@@ -522,7 +529,6 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value, value); return changeFontSize(elements, appState, () => value, value);
}, },
@@ -580,7 +586,6 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
name: "decreaseFontSize", name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) => return changeFontSize(elements, appState, (element) =>
Math.round( Math.round(
@@ -602,7 +607,6 @@ export const actionDecreaseFontSize = register({
export const actionIncreaseFontSize = register({ export const actionIncreaseFontSize = register({
name: "increaseFontSize", name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) => return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
@@ -620,7 +624,6 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(
@@ -634,7 +637,11 @@ export const actionChangeFontFamily = register({
fontFamily: value, fontFamily: value,
}, },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement; return newElement;
} }
@@ -702,7 +709,6 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(
@@ -714,7 +720,11 @@ export const actionChangeTextAlign = register({
oldElement, oldElement,
{ textAlign: value }, { textAlign: value },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement; return newElement;
} }
@@ -775,7 +785,6 @@ export const actionChangeTextAlign = register({
}); });
export const actionChangeVerticalAlign = register({ export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign", name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(
@@ -788,7 +797,11 @@ export const actionChangeVerticalAlign = register({
{ verticalAlign: value }, { verticalAlign: value },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement; return newElement;
} }
@@ -843,7 +856,6 @@ export const actionChangeVerticalAlign = register({
export const actionChangeSharpness = register({ export const actionChangeSharpness = register({
name: "changeSharpness", name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -851,10 +863,10 @@ export const actionChangeSharpness = register({
); );
const shouldUpdateForNonLinearElements = targetElements.length const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el)) ? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.activeTool.type); : !isLinearElementType(appState.elementType);
const shouldUpdateForLinearElements = targetElements.length const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement) ? targetElements.every(isLinearElement)
: isLinearElementType(appState.activeTool.type); : isLinearElementType(appState.elementType);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
@@ -894,8 +906,8 @@ export const actionChangeSharpness = register({
elements, elements,
appState, appState,
(element) => element.strokeSharpness, (element) => element.strokeSharpness,
(canChangeSharpness(appState.activeTool.type) && (canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.activeTool.type) (isLinearElementType(appState.elementType)
? appState.currentItemLinearStrokeSharpness ? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) || : appState.currentItemStrokeSharpness)) ||
null, null,
@@ -908,7 +920,6 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({ export const actionChangeArrowhead = register({
name: "changeArrowhead", name: "changeArrowhead",
trackEvent: false,
perform: ( perform: (
elements, elements,
appState, appState,
+9 -15
View File
@@ -2,11 +2,9 @@ import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
@@ -16,19 +14,15 @@ export const actionSelectAll = register({
{ {
...appState, ...appState,
editingGroupId: null, editingGroupId: null,
selectedElementIds: elements.reduce( selectedElementIds: elements.reduce((map, element) => {
(map: Record<ExcalidrawElement["id"], true>, element) => { if (
if ( !element.isDeleted &&
!element.isDeleted && !(isTextElement(element) && element.containerId)
!(isTextElement(element) && element.containerId) && ) {
!element.locked map[element.id] = true;
) { }
map[element.id] = true; return map;
} }, {} as any),
return map;
},
{},
),
}, },
getNonDeletedElements(elements), getNonDeletedElements(elements),
), ),
+1 -1
View File
@@ -48,7 +48,7 @@ describe("actionStyles", () => {
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C); Keyboard.codeDown(CODES.C);
}); });
const secondRect = JSON.parse(copiedStyles)[0]; const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id); expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset(); mouse.reset();
+24 -61
View File
@@ -6,37 +6,28 @@ import {
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import { getContainerElement } from "../element/textElement";
import { hasBoundTextElement } from "../element/typeChecks";
import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
export const actionCopyStyles = register({ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const elementsCopied = [];
const element = elements.find((el) => appState.selectedElementIds[el.id]); const element = elements.find((el) => appState.selectedElementIds[el.id]);
elementsCopied.push(element);
if (element && hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element);
elementsCopied.push(boundTextElement);
}
if (element) { if (element) {
copiedStyles = JSON.stringify(elementsCopied); copiedStyles = JSON.stringify(element);
} }
return { return {
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.copyStyles") }, toastMessage: t("toast.copyStyles"),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -48,64 +39,36 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({ export const actionPasteStyles = register({
name: "pasteStyles", name: "pasteStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const elementsCopied = JSON.parse(copiedStyles); const pastedElement = JSON.parse(copiedStyles);
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElementIds = selectedElements.map((element) => element.id);
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (selectedElementIds.includes(element.id)) { if (appState.selectedElementIds[element.id]) {
let elementStylesToCopyFrom = pastedElement; const newElement = newElementWith(element, {
if (isTextElement(element) && element.containerId) { backgroundColor: pastedElement?.backgroundColor,
elementStylesToCopyFrom = boundTextElement; strokeWidth: pastedElement?.strokeWidth,
} strokeColor: pastedElement?.strokeColor,
if (!elementStylesToCopyFrom) { strokeStyle: pastedElement?.strokeStyle,
return element; fillStyle: pastedElement?.fillStyle,
} opacity: pastedElement?.opacity,
let newElement = newElementWith(element, { roughness: pastedElement?.roughness,
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
strokeColor: elementStylesToCopyFrom?.strokeColor,
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
}); });
if (isTextElement(newElement) && isTextElement(element)) {
if (isTextElement(newElement)) { mutateElement(newElement, {
newElement = newElementWith(newElement, { fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
fontFamily: textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
let container = null;
if (newElement.containerId) {
container =
selectedElements.find(
(element) =>
isTextElement(newElement) &&
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(newElement, container);
}
if (newElement.type === "arrow") { redrawTextBoundingBox(
newElement = newElementWith(newElement, { newElement,
startArrowhead: elementStylesToCopyFrom.startArrowhead, getContainerElement(newElement),
endArrowhead: elementStylesToCopyFrom.endArrowhead, appState,
}); );
} }
return newElement; return newElement;
} }
return element; return element;
+2 -4
View File
@@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { AppState } from "../types"; import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "grid");
return { return {
appState: { appState: {
...appState, ...appState,
-63
View File
@@ -1,63 +0,0 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
export const actionToggleLock = register({
name: "toggleLock",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
if (!selectedElements.length) {
return false;
}
const operation = getOperation(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: operation === "lock" });
}),
appState,
commitToHistory: true,
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false);
if (selected.length === 1) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
if (selected.length > 1) {
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
},
keyTest: (event, appState, elements) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0
);
},
});
const getOperation = (
elements: readonly ExcalidrawElement[],
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
-1
View File
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
trackEvent: { category: "menu" },
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {
+2 -4
View File
@@ -1,13 +1,11 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "view");
return { return {
appState: { appState: {
...appState, ...appState,
+3 -4
View File
@@ -1,13 +1,12 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "zen");
return { return {
appState: { appState: {
...appState, ...appState,
+44
View File
@@ -0,0 +1,44 @@
import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getBoundTextElement, measureText } from "../element/textElement";
import { ExcalidrawTextElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
-5
View File
@@ -18,7 +18,6 @@ import {
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneLeft(elements, appState), elements: moveOneLeft(elements, appState),
@@ -46,7 +45,6 @@ export const actionSendBackward = register({
export const actionBringForward = register({ export const actionBringForward = register({
name: "bringForward", name: "bringForward",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneRight(elements, appState), elements: moveOneRight(elements, appState),
@@ -74,7 +72,6 @@ export const actionBringForward = register({
export const actionSendToBack = register({ export const actionSendToBack = register({
name: "sendToBack", name: "sendToBack",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllLeft(elements, appState), elements: moveAllLeft(elements, appState),
@@ -109,8 +106,6 @@ export const actionSendToBack = register({
export const actionBringToFront = register({ export const actionBringToFront = register({
name: "bringToFront", name: "bringToFront",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllRight(elements, appState), elements: moveAllRight(elements, appState),
+1 -3
View File
@@ -75,13 +75,11 @@ export {
actionCut, actionCut,
actionCopyAsPng, actionCopyAsPng,
actionCopyAsSvg, actionCopyAsSvg,
copyText,
} from "./actionClipboard"; } from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText } from "./actionUnbindText";
export { actionLink } from "../element/Hyperlink"; export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";
+36 -38
View File
@@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { import {
Action, Action,
ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionName, ActionName,
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
@@ -14,25 +14,21 @@ import { trackEvent } from "../analytics";
const trackAction = ( const trackAction = (
action: Action, action: Action,
source: ActionSource, source: "ui" | "keyboard" | "api",
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any, value: any,
) => { ) => {
if (action.trackEvent) { if (action.trackEvent !== false) {
try { try {
if (typeof action.trackEvent === "object") { if (action.trackEvent === true) {
const shouldTrack = action.trackEvent.predicate trackEvent(
? action.trackEvent.predicate(appState, elements, value) action.name,
: true; source,
if (shouldTrack) { typeof value === "number" || typeof value === "string"
trackEvent( ? String(value)
action.trackEvent.category, : undefined,
action.trackEvent.action || action.name, );
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`, } else {
); action.trackEvent?.(action, source, value);
}
} }
} catch (error) { } catch (error) {
console.error("error while logging action:", error); console.error("error while logging action:", error);
@@ -40,8 +36,8 @@ const trackAction = (
} }
}; };
export class ActionManager { export class ActionManager implements ActionsManagerInterface {
actions = {} as Record<ActionName, Action>; actions = {} as ActionsManagerInterface["actions"];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -110,26 +106,30 @@ export class ActionManager {
} }
} }
const elements = this.getElementsIncludingDeleted(); trackAction(action, "keyboard", null);
const appState = this.getAppState();
const value = null;
trackAction(action, "keyboard", appState, elements, this.app, null);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); this.updater(
this.updater(data[0].perform(elements, appState, value, this.app)); data[0].perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true; return true;
} }
executeAction(action: Action, source: ActionSource = "api") { executeAction(action: Action) {
const elements = this.getElementsIncludingDeleted(); this.updater(
const appState = this.getAppState(); action.perform(
const value = null; this.getElementsIncludingDeleted(),
this.getAppState(),
trackAction(action, source, appState, elements, this.app, value); null,
this.app,
this.updater(action.perform(elements, appState, value, this.app)); ),
);
trackAction(action, "api", null);
} }
/** /**
@@ -147,11 +147,7 @@ export class ActionManager {
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const updateData = (formState?: any) => { const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater( this.updater(
action.perform( action.perform(
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
@@ -160,6 +156,8 @@ export class ActionManager {
this.app, this.app,
), ),
); );
trackAction(action, "ui", formState);
}; };
return ( return (
-2
View File
@@ -29,7 +29,6 @@ export type ShortcutName = SubtypeOf<
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "hyperlink" | "hyperlink"
| "toggleLock"
>; >;
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
@@ -68,7 +67,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")], hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {
+14 -27
View File
@@ -6,8 +6,7 @@ import {
ExcalidrawProps, ExcalidrawProps,
BinaryFiles, BinaryFiles,
} from "../types"; } from "../types";
import { ToolButtonSize } from "../components/ToolButton";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */ /** if false, the action should be prevented */
export type ActionResult = export type ActionResult =
@@ -40,7 +39,6 @@ export type ActionName =
| "paste" | "paste"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg" | "copyAsSvg"
| "copyText"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
| "sendToBack" | "sendToBack"
@@ -108,17 +106,14 @@ export type ActionName =
| "increaseFontSize" | "increaseFontSize"
| "decreaseFontSize" | "decreaseFontSize"
| "unbindText" | "unbindText"
| "hyperlink" | "hyperlink";
| "eraser"
| "bindText"
| "toggleLock";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: AppState; appState: AppState;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
appProps: ExcalidrawProps; appProps: ExcalidrawProps;
data?: Record<string, any>; data?: Partial<{ id: string; size: ToolButtonSize }>;
}; };
export interface Action { export interface Action {
@@ -142,23 +137,15 @@ export interface Action {
appState: AppState, appState: AppState,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent?:
| false | boolean
| { | ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
category: }
| "toolbar"
| "element" export interface ActionsManagerInterface {
| "canvas" actions: Record<ActionName, Action>;
| "export" registerAction: (action: Action) => void;
| "history" handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
| "menu" renderAction: (name: ActionName) => React.ReactElement | null;
| "collab" executeAction: (action: Action) => void;
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
} }
+6 -10
View File
@@ -4,19 +4,15 @@ export const trackEvent =
typeof window !== "undefined" && typeof window !== "undefined" &&
window.gtag window.gtag
? (category: string, action: string, label?: string, value?: number) => { ? (category: string, action: string, label?: string, value?: number) => {
try { window.gtag("event", action, {
window.gtag("event", action, { event_category: category,
event_category: category, event_label: label,
event_label: label, value,
value, });
});
} catch (error) {
console.error("error logging to ga", error);
}
} }
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, action: string, label?: string, value?: number) => {} ? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => { : (category: string, action: string, label?: string, value?: number) => {
// Uncomment the next line to track locally // Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value }); // console.info("Track Event", category, action, label, value);
}; };
+11 -22
View File
@@ -41,12 +41,8 @@ export const getDefaultAppState = (): Omit<
editingElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
activeTool: { elementLocked: false,
type: "selection", elementType: "selection",
customType: null,
locked: false,
lastActiveToolBeforeEraser: null,
},
penMode: false, penMode: false,
penDetected: false, penDetected: false,
errorMessage: null, errorMessage: null,
@@ -58,7 +54,6 @@ export const getDefaultAppState = (): Omit<
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
isLibraryOpen: false, isLibraryOpen: false,
isLibraryMenuDocked: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@@ -81,14 +76,14 @@ export const getDefaultAppState = (): Omit<
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
toast: null, toastMessage: null,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { zoom: {
value: 1 as NormalizedZoomValue, value: 1 as NormalizedZoomValue,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null, pendingImageElement: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
}; };
}; };
@@ -135,9 +130,10 @@ const APP_STATE_STORAGE_CONF = (<
editingElement: { browser: false, export: false, server: false }, editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, elementLocked: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, elementType: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penMode: { browser: false, export: false, server: false },
penDetected: { browser: false, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false }, exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false, server: false },
@@ -147,8 +143,7 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: true, export: false, server: false }, isLibraryOpen: { browser: false, export: false, server: false },
isLibraryMenuDocked: { browser: true, export: false, server: false },
isLoading: { browser: false, export: false, server: false }, isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false },
@@ -173,13 +168,13 @@ const APP_STATE_STORAGE_CONF = (<
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
toast: { browser: false, export: false, server: false }, toastMessage: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true }, viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false }, width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false }, zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false }, pendingImageElement: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
}); });
@@ -218,9 +213,3 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
export const clearAppStateForDatabase = (appState: Partial<AppState>) => { export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server"); return _clearAppStateForStorage(appState, "server");
}; };
export const isEraserActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";
-121
View File
@@ -1,121 +0,0 @@
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {
it.each<[string, number]>([
["1", 1],
["0", 0],
["-1", -1],
["0.1", 0.1],
[".1", 0.1],
["1.", 1],
["424.", 424],
["$1", 1],
["-.1", -0.1],
["-$1", -1],
["$-1", -1],
])("should correctly identify %s as numbers", (given, expected) => {
expect(tryParseNumber(given)).toEqual(expected);
});
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
"should correctly identify %s as not a number",
(given) => {
expect(tryParseNumber(given)).toBeNull();
},
);
});
describe("tryParseCells", () => {
it("Successfully parses a spreadsheet", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("Uses the second column as the label if it is not a number", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("treats the first column as labels if both columns are numbers", () => {
const spreadsheet = [
["time", "value"],
["01", "61"],
["02", "-60"],
["03", "85"],
["04", "-67"],
["05", "54"],
["06", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
});
});
+7 -17
View File
@@ -29,24 +29,18 @@ type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET; reason: string } | { type: typeof NOT_SPREADSHEET; reason: string }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }; | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
/** const tryParseNumber = (s: string): number | null => {
* @private exported for testing const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
*/
export const tryParseNumber = (s: string): number | null => {
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
if (!match) { if (!match) {
return null; return null;
} }
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); return parseFloat(match[1].replace(/,/g, ""));
}; };
const isNumericColumn = (lines: string[][], columnIndex: number) => const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
/** const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
* @private exported for testing
*/
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length; const numCols = cells[0].length;
if (numCols > 2) { if (numCols > 2) {
@@ -77,16 +71,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
}; };
} }
const labelColumnNumeric = isNumericColumn(cells, 0); const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
const valueColumnNumeric = isNumericColumn(cells, 1);
if (!labelColumnNumeric && !valueColumnNumeric) { if (!isNumericColumn(cells, valueColumnIndex)) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
} }
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric const labelColumnIndex = (valueColumnIndex + 1) % 2;
? [0, 1]
: [1, 0];
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null; const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
const rows = hasHeader ? cells.slice(1) : cells; const rows = hasHeader ? cells.slice(1) : cells;
@@ -176,7 +167,6 @@ const commonProps = {
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {
+15 -41
View File
@@ -2,16 +2,16 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types"; import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: readonly NonDeletedExcalidrawElement[]; elements: ExcalidrawElement[];
files: BinaryFiles | undefined; files: BinaryFiles | undefined;
}; };
@@ -56,20 +56,19 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles | null, files: BinaryFiles,
) => { ) => {
// select binded text elements when copying // select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements, elements: selectedElements,
files: files files: selectedElements.reduce((acc, element) => {
? elements.reduce((acc, element) => { if (isInitializedImageElement(element) && files[element.fileId]) {
if (isInitializedImageElement(element) && files[element.fileId]) { acc[element.fileId] = files[element.fileId];
acc[element.fileId] = files[element.fileId]; }
} return acc;
return acc; }, {} as BinaryFiles),
}, {} as BinaryFiles)
: undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
CLIPBOARD = json; CLIPBOARD = json;
@@ -167,35 +166,10 @@ export const parseClipboard = async (
} }
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { export const copyBlobToClipboardAsPng = async (blob: Blob) => {
let promise; await navigator.clipboard.write([
try { new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
// in Safari so far we need to construct the ClipboardItem synchronously ]);
// (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// not await so that we can detect whether the thrown error likely relates
// to a lack of support for the Promise ClipboardItem constructor
promise = navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: blob,
}),
]);
} catch (error: any) {
// if we're using a Promise ClipboardItem, let's try constructing
// with resolution value instead
if (isPromiseLike(blob)) {
await navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: await blob,
}),
]);
} else {
throw error;
}
}
await promise;
}; };
export const copyTextToSystemClipboard = async (text: string | null) => { export const copyTextToSystemClipboard = async (text: string | null) => {
+23 -51
View File
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useIsMobile } from "../components/App";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
@@ -15,28 +15,22 @@ import {
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
activeTool, elementType,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
activeTool: AppState["activeTool"]["type"]; elementType: ExcalidrawElement["type"];
}) => { }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -52,22 +46,19 @@ export const SelectedShapeActions = ({
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const isEditing = Boolean(appState.editingElement); const isEditing = Boolean(appState.editingElement);
const device = useDevice(); const isMobile = useIsMobile();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(activeTool) || hasBackground(elementType) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showChangeBackgroundIcons = const showChangeBackgroundIcons =
hasBackground(activeTool) || hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type)); targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null; let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) { for (const element of targetElements) {
@@ -79,23 +70,23 @@ export const SelectedShapeActions = ({
return ( return (
<div className="panelColumn"> <div className="panelColumn">
{((hasStrokeColor(activeTool) && {((hasStrokeColor(elementType) &&
activeTool !== "image" && elementType !== "image" &&
commonSelectedType !== "image") || commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(activeTool) || {(hasStrokeWidth(elementType) ||
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(activeTool === "freedraw" || {(elementType === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")} renderAction("changeStrokeShape")}
{(hasStrokeStyle(activeTool) || {(hasStrokeStyle(elementType) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
@@ -103,12 +94,12 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeSharpness(activeTool) || {(canChangeSharpness(elementType) ||
targetElements.some((element) => canChangeSharpness(element.type))) && ( targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</> <>{renderAction("changeSharpness")}</>
)} )}
{(hasText(activeTool) || {(hasText(elementType) ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some((element) => hasText(element.type))) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
@@ -123,7 +114,7 @@ export const SelectedShapeActions = ({
(element) => (element) =>
hasBoundTextElement(element) || isBoundToContainer(element), hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")} ) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(activeTool) || {(canHaveArrowheads(elementType) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
@@ -177,11 +168,11 @@ export const SelectedShapeActions = ({
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
{!device.isMobile && renderAction("duplicateSelection")} {!isMobile && renderAction("duplicateSelection")}
{!device.isMobile && renderAction("deleteSelectedElements")} {!isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {targetElements.length === 1 && renderAction("hyperlink")}
</div> </div>
</fieldset> </fieldset>
)} )}
@@ -191,16 +182,14 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
canvas, canvas,
activeTool, elementType,
setAppState, setAppState,
onImageAction, onImageAction,
appState,
}: { }: {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
activeTool: AppState["activeTool"]; elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key }, index) => { {SHAPES.map(({ value, icon, key }, index) => {
@@ -215,37 +204,20 @@ export const ShapesSwitcher = ({
key={value} key={value}
type="radio" type="radio"
icon={icon} icon={icon}
checked={activeTool.type === value} checked={elementType === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)} — ${shortcut}`} title={`${capitalizeString(label)} — ${shortcut}`}
keyBindingLabel={`${index + 1}`} keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={value} data-testid={value}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({ setAppState({
activeTool: nextActiveTool, elementType: value,
multiElement: null, multiElement: null,
selectedElementIds: {}, selectedElementIds: {},
}); });
setCursorForShape(canvas, { setCursorForShape(canvas, value);
...appState,
activeTool: nextActiveTool,
});
if (value === "image") { if (value === "image") {
onImageAction({ pointerType }); onImageAction({ pointerType });
} }
+482 -879
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -12,11 +12,5 @@
cursor: pointer; cursor: pointer;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
} }
} }
+11 -27
View File
@@ -1,36 +1,20 @@
import "./Avatar.scss"; import "./Avatar.scss";
import React, { useState } from "react"; import React from "react";
import { getClientInitials } from "../clients";
type AvatarProps = { type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string; color: string;
border: string; border: string;
name: string;
src?: string;
}; };
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => { export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
const shortName = getClientInitials(name); <div
const [error, setError] = useState(false); className="Avatar"
const loadImg = !error && src; style={{ background: color, border: `1px solid ${border}` }}
const style = loadImg onClick={onClick}
? undefined >
: { background: color, border: `1px solid ${border}` }; {children}
return ( </div>
<div className="Avatar" style={style} onClick={onClick}> );
{loadImg ? (
<img
className="Avatar-img"
src={src}
alt={shortName}
referrerPolicy="no-referrer"
onError={() => setError(true)}
/>
) : (
shortName
)}
</div>
);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App"; import { useIsMobile } from "./App";
import { trash } from "./icons"; import { trash } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
icon={trash} icon={trash}
title={t("buttons.clearReset")} title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")} aria-label={t("buttons.clearReset")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
onClick={toggleDialog} onClick={toggleDialog}
data-testid="clear-canvas-button" data-testid="clear-canvas-button"
/> />
+2 -4
View File
@@ -18,15 +18,13 @@
left: -5px; left: -5px;
} }
min-width: 1em; min-width: 1em;
min-height: 1em;
line-height: 1;
position: absolute; position: absolute;
bottom: -5px; bottom: -5px;
padding: 3px; padding: 3px;
border-radius: 50%; border-radius: 50%;
background-color: $oc-green-6; background-color: $oc-green-6;
color: $oc-white; color: $oc-white;
font-size: 0.6em; font-size: 0.7em;
font-family: "Cascadia"; font-family: var(--ui-font);
} }
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useIsMobile } from "../components/App";
import { users } from "./icons"; import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
@@ -26,9 +26,9 @@ const CollabButton = ({
type="button" type="button"
title={t("labels.liveCollaboration")} title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
> >
{isCollaborating && ( {collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div> <div className="CollabButton-collaborators">{collaboratorCount}</div>
)} )}
</ToolButton> </ToolButton>
+33 -30
View File
@@ -128,33 +128,45 @@ const Picker = ({
}, []); }, []);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
let handled = false; if (event.key === KEYS.TAB) {
if (isArrowKey(event.key)) { const { activeElement } = document;
handled = true; if (event.shiftKey) {
if (activeElement === firstItem.current) {
colorInput.current?.focus();
event.preventDefault();
}
} else if (activeElement === colorInput.current) {
firstItem.current?.focus();
event.preventDefault();
}
} else if (isArrowKey(event.key)) {
const { activeElement } = document; const { activeElement } = document;
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
let isCustom = false; let isCustom = false;
let index = Array.prototype.indexOf.call( let index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--default") gallery!.current!.querySelector(".color-picker-content--default")!
?.children, .children,
activeElement, activeElement,
); );
if (index === -1) { if (index === -1) {
index = Array.prototype.indexOf.call( index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--canvas-colors") gallery!.current!.querySelector(
?.children, ".color-picker-content--canvas-colors",
)!.children,
activeElement, activeElement,
); );
if (index !== -1) { if (index !== -1) {
isCustom = true; isCustom = true;
} }
} }
const parentElement = isCustom const parentSelector = isCustom
? gallery.current?.querySelector(".color-picker-content--canvas-colors") ? gallery!.current!.querySelector(
: gallery.current?.querySelector(".color-picker-content--default"); ".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
if (parentElement && index !== -1) { if (index !== -1) {
const length = parentElement.children.length - (showInput ? 1 : 0); const length = parentSelector!.children.length - (showInput ? 1 : 0);
const nextIndex = const nextIndex =
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT) event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
? (index + 1) % length ? (index + 1) % length
@@ -165,38 +177,30 @@ const Picker = ({
: !isCustom && event.key === KEYS.ARROW_UP : !isCustom && event.key === KEYS.ARROW_UP
? (length + index - 5) % length ? (length + index - 5) % length
: index; : index;
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus(); (parentSelector!.children![nextIndex] as HTMLElement)?.focus();
} }
event.preventDefault(); event.preventDefault();
} else if ( } else if (
keyBindings.includes(event.key.toLowerCase()) && keyBindings.includes(event.key.toLowerCase()) &&
!event[KEYS.CTRL_OR_CMD] &&
!event.altKey &&
!isWritableElement(event.target) !isWritableElement(event.target)
) { ) {
handled = true;
const index = keyBindings.indexOf(event.key.toLowerCase()); const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS; const isCustom = index >= MAX_DEFAULT_COLORS;
const parentElement = isCustom const parentSelector = isCustom
? gallery?.current?.querySelector( ? gallery!.current!.querySelector(
".color-picker-content--canvas-colors", ".color-picker-content--canvas-colors",
) )!
: gallery?.current?.querySelector(".color-picker-content--default"); : gallery!.current!.querySelector(".color-picker-content--default")!;
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index; const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
( (parentSelector!.children![actualIndex] as HTMLElement)?.focus();
parentElement?.children[actualIndex] as HTMLElement | undefined
)?.focus();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
handled = true;
event.preventDefault(); event.preventDefault();
onClose(); onClose();
} }
if (handled) { event.nativeEvent.stopImmediatePropagation();
event.nativeEvent.stopImmediatePropagation(); event.stopPropagation();
event.stopPropagation();
}
}; };
const renderColors = (colors: Array<string>, custom: boolean = false) => { const renderColors = (colors: Array<string>, custom: boolean = false) => {
@@ -260,8 +264,7 @@ const Picker = ({
gallery.current = el; gallery.current = el;
} }
}} }}
// to allow focusing by clicking but not by tabbing tabIndex={0}
tabIndex={-1}
> >
<div className="color-picker-content--default"> <div className="color-picker-content--default">
{renderColors(colors)} {renderColors(colors)}
+1 -3
View File
@@ -70,9 +70,7 @@ const ContextMenu = ({
dangerous: actionName === "deleteSelectedElements", dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState), checkmark: option.checked?.(appState),
})} })}
onClick={() => onClick={() => actionManager.executeAction(option)}
actionManager.executeAction(option, "contextMenu")
}
> >
<div className="context-menu-option__label">{label}</div> <div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut"> <kbd className="context-menu-option__shortcut">
+10 -3
View File
@@ -2,14 +2,13 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer, useDevice } from "../components/App"; import { useExcalidrawContainer, 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"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@@ -65,6 +64,14 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown); return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const queryFocusableElements = (node: HTMLElement) => {
const focusableElements = node.querySelectorAll<HTMLElement>(
"button, a, input, select, textarea, div[tabindex]",
);
return focusableElements ? Array.from(focusableElements) : [];
};
const onClose = () => { const onClose = () => {
(lastActiveElement as HTMLElement).focus(); (lastActiveElement as HTMLElement).focus();
props.onCloseRequest(); props.onCloseRequest();
@@ -87,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose} onClick={onClose}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDevice().isMobile ? back : close} {useIsMobile() ? back : close}
</button> </button>
</h2> </h2>
<div className="Dialog__content">{props.children}</div> <div className="Dialog__content">{props.children}</div>
+2 -10
View File
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Section title={t("helpDialog.shortcuts")}> <Section title={t("helpDialog.shortcuts")}>
<Columns> <Columns>
<Column> <Column>
<ShortcutIsland caption={t("helpDialog.tools")}> <ShortcutIsland caption={t("helpDialog.shapes")}>
<Shortcut <Shortcut
label={t("toolBar.selection")} label={t("toolBar.selection")}
shortcuts={["V", "1"]} shortcuts={["V", "1"]}
@@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["R", "2"]} shortcuts={["R", "2"]}
/> />
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} /> <Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
<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
@@ -159,10 +159,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("toolBar.eraser")}
shortcuts={[getShortcutKey("E")]}
/>
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editSelectedShape")}
shortcuts={[ shortcuts={[
@@ -363,10 +359,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
getShortcutKey(`Alt+${t("helpDialog.drag")}`), getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]} ]}
/> />
<Shortcut
label={t("helpDialog.toggleElementLock")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
/>
<Shortcut <Shortcut
label={t("buttons.undo")} label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]} shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
+6 -14
View File
@@ -11,7 +11,6 @@ import {
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
interface HintViewerProps { interface HintViewerProps {
appState: AppState; appState: AppState;
@@ -20,32 +19,25 @@ interface HintViewerProps {
} }
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) { if (elementType === "arrow" || elementType === "line") {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) { if (!multiMode) {
return t("hints.linearElement"); return t("hints.linearElement");
} }
return t("hints.linearElementMulti"); return t("hints.linearElementMulti");
} }
if (activeTool.type === "freedraw") { if (elementType === "freedraw") {
return t("hints.freeDraw"); return t("hints.freeDraw");
} }
if (activeTool.type === "text") { if (elementType === "text") {
return t("hints.text"); return t("hints.text");
} }
if (appState.activeTool.type === "image" && appState.pendingImageElementId) { if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage"); return t("hints.placeImage");
} }
@@ -77,7 +69,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (activeTool.type === "selection") { if (elementType === "selection") {
if ( if (
appState.draggingElement?.type === "selection" && appState.draggingElement?.type === "selection" &&
!appState.editingElement && !appState.editingElement &&
+6 -8
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom"; import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob"; 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 { useDevice } from "./App"; import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
@@ -18,7 +19,6 @@ import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants"; import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -90,7 +90,7 @@ const ImageExportModal = ({
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
actionManager: ActionManager; actionManager: ActionsManagerInterface;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
@@ -170,9 +170,7 @@ const ImageExportModal = ({
<Stack.Row gap={2}> <Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")} {actionManager.renderAction("changeExportScale")}
</Stack.Row> </Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}> <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
{t("buttons.scale")}
</p>
</div> </div>
<div <div
style={{ style={{
@@ -231,7 +229,7 @@ export const ImageExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
actionManager: ActionManager; actionManager: ActionsManagerInterface;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
@@ -252,7 +250,7 @@ export const ImageExportDialog = ({
icon={exportImage} icon={exportImage}
type="button" type="button"
aria-label={t("buttons.exportImage")} aria-label={t("buttons.exportImage")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")} title={t("buttons.exportImage")}
/> />
{modalIsShown && ( {modalIsShown && (
+1 -1
View File
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
useEffect(() => { useEffect(() => {
const updateLang = async () => { const updateLang = async () => {
await setLanguage(currentLang); await setLanguage(currentLang);
setLoading(false);
}; };
const currentLang = const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang; languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang(); updateLang();
setLoading(false);
}, [props.langCode]); }, [props.langCode]);
return loading ? <LoadingMessage /> : props.children; return loading ? <LoadingMessage /> : props.children;
+9 -12
View File
@@ -1,7 +1,8 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App"; import { useIsMobile } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons"; import { exportFile, exportToFileIcon, link } from "./icons";
@@ -11,9 +12,6 @@ import { Card } from "./Card";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -31,7 +29,7 @@ const JSONExportModal = ({
appState: AppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager; actionManager: ActionsManagerInterface;
onCloseRequest: () => void; onCloseRequest: () => void;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
@@ -56,7 +54,7 @@ const JSONExportModal = ({
aria-label={t("exportDialog.disk_button")} aria-label={t("exportDialog.disk_button")}
showAriaLabel={true} showAriaLabel={true}
onClick={() => { onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui"); actionManager.executeAction(actionSaveFileToDisk);
}} }}
/> />
</Card> </Card>
@@ -72,10 +70,9 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")} title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")} aria-label={t("exportDialog.link_button")}
showAriaLabel={true} showAriaLabel={true}
onClick={() => { onClick={() =>
onExportToBackend(elements, appState, files, canvas); onExportToBackend(elements, appState, files, canvas)
trackEvent("export", "link", `ui (${getFrame()})`); }
}}
/> />
</Card> </Card>
)} )}
@@ -97,7 +94,7 @@ export const JSONExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
actionManager: ActionManager; actionManager: ActionsManagerInterface;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
}) => { }) => {
@@ -117,7 +114,7 @@ export const JSONExportDialog = ({
icon={exportFile} icon={exportFile}
type="button" type="button"
aria-label={t("buttons.export")} aria-label={t("buttons.export")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
title={t("buttons.export")} title={t("buttons.export")}
/> />
{modalIsShown && ( {modalIsShown && (
+1 -55
View File
@@ -1,63 +1,9 @@
@import "open-color/open-color"; @import "open-color/open-color";
@import "../css/variables.module";
.layer-ui__sidebar {
position: absolute;
top: var(--sat);
bottom: var(--sab);
right: var(--sar);
z-index: 5;
box-shadow: var(--shadow-island);
overflow: hidden;
border-radius: var(--border-radius-lg);
margin: var(--space-factor);
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
}
.excalidraw { .excalidraw {
.layer-ui__wrapper.animate {
transition: width 0.1s ease-in-out;
}
.layer-ui__wrapper { .layer-ui__wrapper {
// when the rightside sidebar is docked, we need to resize the UI by its
// width, making the nested UI content shift to the left. To do this,
// we need the UI container to actually have dimensions set, but
// then we also need to disable pointer events else the canvas below
// wouldn't be interactive.
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);
&__top-right { &__top-right {
display: flex; display: flex;
} }
+93 -136
View File
@@ -1,11 +1,12 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { CLASSES } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App";
import { calculateScrollCenter, getSelectedElements } from "../scene"; import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
@@ -25,8 +26,9 @@ import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section"; import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
@@ -35,11 +37,6 @@ import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import { actionToggleZenMode } from "../actions";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -52,13 +49,18 @@ interface LayerUIProps {
onLockToggle: () => void; onLockToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
showThemeBtn: boolean; showThemeBtn: boolean;
toggleZenMode: () => void;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: (
renderCustomFooter?: ExcalidrawProps["renderFooter"]; isMobile: boolean,
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void; focusContainer: () => void;
@@ -66,6 +68,7 @@ interface LayerUIProps {
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
} }
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@@ -77,12 +80,14 @@ const LayerUI = ({
onLockToggle, onLockToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements, onInsertElements,
zenModeEnabled,
showExitZenModeBtn, showExitZenModeBtn,
showThemeBtn, showThemeBtn,
toggleZenMode,
isCollaborating, isCollaborating,
renderTopRightUI, renderTopRightUI,
renderCustomFooter, renderCustomFooter,
renderCustomStats, viewModeEnabled,
libraryReturnUrl, libraryReturnUrl,
UIOptions, UIOptions,
focusContainer, focusContainer,
@@ -90,7 +95,7 @@ const LayerUI = ({
id, id,
onImageAction, onImageAction,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const isMobile = useIsMobile();
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
@@ -117,7 +122,6 @@ const LayerUI = ({
const createExporter = const createExporter =
(type: ExportType): ExportCB => (type: ExportType): ExportCB =>
async (exportedElements) => { async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
@@ -166,7 +170,7 @@ const LayerUI = ({
<Section <Section
heading="canvasActions" heading="canvasActions"
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
@@ -187,7 +191,7 @@ const LayerUI = ({
<Section <Section
heading="canvasActions" heading="canvasActions"
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
@@ -227,7 +231,7 @@ const LayerUI = ({
<Section <Section
heading="selectedShapeActions" heading="selectedShapeActions"
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
<Island <Island
@@ -244,7 +248,7 @@ const LayerUI = ({
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type} elementType={appState.elementType}
/> />
</Island> </Island>
</Section> </Section>
@@ -271,9 +275,7 @@ const LayerUI = ({
<LibraryMenu <LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)} pendingElements={getSelectedElements(elements, appState, true)}
onClose={closeLibrary} onClose={closeLibrary}
onInsertLibraryItems={(libraryItems) => { onInsertShape={onInsertElements}
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
@@ -297,55 +299,52 @@ const LayerUI = ({
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col
gap={4} gap={4}
className={clsx({ className={clsx({ "disable-pointerEvents": zenModeEnabled })}
"disable-pointerEvents": appState.zenModeEnabled,
})}
> >
{appState.viewModeEnabled {viewModeEnabled
? renderViewModeCanvasActions() ? renderViewModeCanvasActions()
: renderCanvasActions()} : renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && ( {!viewModeEnabled && (
<Section heading="shapes"> <Section heading="shapes">
{(heading) => ( {(heading) => (
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={1} gap={1}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": zenModeEnabled,
})} })}
> >
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
onChange={onPenModeToggle} onChange={onPenModeToggle}
title={t("toolBar.penMode")} title={t("toolBar.penMode")}
penDetected={appState.penDetected} penDetected={appState.penDetected}
/> />
<LockButton <LockButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.activeTool.locked} checked={appState.elementLocked}
onChange={() => onLockToggle()} onChange={onLockToggle}
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<Island <Island
padding={1} padding={1}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": zenModeEnabled,
})} })}
> >
<HintViewer <HintViewer
appState={appState} appState={appState}
elements={elements} elements={elements}
isMobile={device.isMobile} isMobile={isMobile}
/> />
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
appState={appState}
canvas={canvas} canvas={canvas}
activeTool={appState.activeTool} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
onImageAction({ onImageAction({
@@ -360,6 +359,7 @@ const LayerUI = ({
setAppState={setAppState} setAppState={setAppState}
/> />
</Stack.Row> </Stack.Row>
{libraryMenu}
</Stack.Col> </Stack.Col>
)} )}
</Section> </Section>
@@ -368,15 +368,27 @@ const LayerUI = ({
className={clsx( className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": zenModeEnabled,
}, },
)} )}
> >
<UserList <UserList>
collaborators={appState.collaborators} {appState.collaborators.size > 0 &&
actionManager={actionManager} Array.from(appState.collaborators)
/> // Collaborator is either not initialized or is actually the current user.
{renderTopRightUI?.(device.isMobile, appState)} .filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(isMobile, appState)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -393,8 +405,7 @@ const LayerUI = ({
className={clsx( className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition", "layer-ui__wrapper__footer-left zen-mode-transition",
{ {
"layer-ui__wrapper__footer-left--transition-left": "layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
appState.zenModeEnabled,
}, },
)} )}
> >
@@ -406,40 +417,17 @@ const LayerUI = ({
zoom={appState.zoom} zoom={appState.zoom}
/> />
</Island> </Island>
{!appState.viewModeEnabled && ( {!viewModeEnabled && (
<> <div
<div className={clsx("undo-redo-buttons zen-mode-transition", {
className={clsx("undo-redo-buttons zen-mode-transition", { "layer-ui__wrapper__footer-left--transition-bottom":
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
appState.zenModeEnabled, })}
})} >
> {actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("undo", { size: "small" })} {actionManager.renderAction("redo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })} </div>
</div>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)} )}
{!appState.viewModeEnabled &&
appState.multiElement &&
device.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
@@ -448,7 +436,7 @@ const LayerUI = ({
"layer-ui__wrapper__footer-center zen-mode-transition", "layer-ui__wrapper__footer-center zen-mode-transition",
{ {
"layer-ui__wrapper__footer-left--transition-bottom": "layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled, zenModeEnabled,
}, },
)} )}
> >
@@ -458,7 +446,7 @@ const LayerUI = ({
className={clsx( className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition", "layer-ui__wrapper__footer-right zen-mode-transition",
{ {
"transition-right disable-pointerEvents": appState.zenModeEnabled, "transition-right disable-pointerEvents": zenModeEnabled,
}, },
)} )}
> >
@@ -468,7 +456,7 @@ const LayerUI = ({
className={clsx("disable-zen-mode", { className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn, "disable-zen-mode--visible": showExitZenModeBtn,
})} })}
onClick={() => actionManager.executeAction(actionToggleZenMode)} onClick={toggleZenMode}
> >
{t("buttons.exitZenMode")} {t("buttons.exitZenMode")}
</button> </button>
@@ -478,7 +466,7 @@ const LayerUI = ({
const dialogs = ( const dialogs = (
<> <>
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage />}
{appState.errorMessage && ( {appState.errorMessage && (
<ErrorDialog <ErrorDialog
message={appState.errorMessage} message={appState.errorMessage}
@@ -507,24 +495,7 @@ const LayerUI = ({
</> </>
); );
const renderStats = () => { return isMobile ? (
if (!appState.showStats) {
return null;
}
return (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
);
};
return device.isMobile ? (
<> <>
{dialogs} {dialogs}
<MobileMenu <MobileMenu
@@ -536,56 +507,42 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick} onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()} onLockToggle={onLockToggle}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
canvas={canvas} canvas={canvas}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter} renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
onImageAction={onImageAction} onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderStats={renderStats}
/> />
</> </>
) : ( ) : (
<> <div
<div className={clsx("layer-ui__wrapper", {
className={clsx("layer-ui__wrapper", { "disable-pointerEvents":
"disable-pointerEvents": appState.draggingElement ||
appState.draggingElement || appState.resizingElement ||
appState.resizingElement || (appState.editingElement && !isTextElement(appState.editingElement)),
(appState.editingElement && })}
!isTextElement(appState.editingElement)), >
})} {dialogs}
style={ {renderFixedSideContainer()}
appState.isLibraryOpen && {renderBottomAppMenu()}
appState.isLibraryMenuDocked && {appState.scrolledOutside && (
device.canDeviceFitSidebar <button
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } className="scroll-back-to-content"
: {} onClick={() => {
} setAppState({
> ...calculateScrollCenter(elements, appState, canvas),
{dialogs} });
{renderFixedSideContainer()} }}
{renderBottomAppMenu()} >
{renderStats()} {t("buttons.scrollBackToContent")}
{appState.scrolledOutside && ( </button>
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
{appState.isLibraryOpen && (
<div className="layer-ui__sidebar">{libraryMenu}</div>
)} )}
</> </div>
); );
}; };
+1 -16
View File
@@ -3,8 +3,6 @@ import clsx from "clsx";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState } from "../types"; import { AppState } from "../types";
import { capitalizeString } from "../utils"; import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
const LIBRARY_ICON = ( const LIBRARY_ICON = (
<svg viewBox="0 0 576 512"> <svg viewBox="0 0 576 512">
@@ -20,7 +18,6 @@ export const LibraryButton: React.FC<{
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean; isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => { }> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
return ( return (
<label <label
className={clsx( className={clsx(
@@ -37,19 +34,7 @@ export const LibraryButton: React.FC<{
type="checkbox" type="checkbox"
name="editor-library" name="editor-library"
onChange={(event) => { onChange={(event) => {
document setAppState({ isLibraryOpen: event.target.checked });
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const nextState = event.target.checked;
setAppState({ isLibraryOpen: nextState });
// track only openings
if (nextState) {
trackEvent(
"library",
"toggleLibrary (open)",
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}} }}
checked={appState.isLibraryOpen} checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))} aria-label={capitalizeString(t("toolBar.library"))}
+10 -59
View File
@@ -2,6 +2,7 @@
.excalidraw { .excalidraw {
.layer-ui__library { .layer-ui__library {
margin: auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -10,41 +11,25 @@
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin: 2px 0 15px 0; margin: 2px 0;
.Spinner {
margin-right: 1rem;
}
button { button {
// 2px from the left to account for focus border of left-most button // 2px from the left to account for focus border of left-most button
margin: 0 2px; margin: 0 2px;
} }
}
}
.layer-ui__sidebar { a {
.layer-ui__library { margin-inline-start: auto;
padding: 0; // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
height: 100%; padding-inline-end: 18px;
} white-space: nowrap;
.library-menu-items-container { }
height: 100%;
width: 100%;
} }
} }
.layer-ui__library-message { .layer-ui__library-message {
padding: 2em 4em; padding: 10px 20px;
min-width: 200px; max-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
span {
font-size: 0.8em;
}
} }
.publish-library-success { .publish-library-success {
@@ -67,38 +52,4 @@
} }
} }
} }
.library-menu-browse-button {
width: 80%;
min-height: 22px;
margin: 0 auto;
margin-top: 1rem;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
border-radius: var(--border-radius-lg);
background-color: var(--color-primary);
color: $oc-white;
text-align: center;
white-space: nowrap;
text-decoration: none !important;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
}
.library-menu-browse-button--mobile {
min-height: 22px;
margin-left: auto;
a {
padding-right: 0;
}
}
} }
+141 -118
View File
@@ -1,12 +1,5 @@
import { import { useRef, useState, useEffect, useCallback, RefObject } from "react";
useRef, import Library from "../data/library";
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n"; import { t } from "../i18n";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
@@ -25,11 +18,7 @@ import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants"; import { EVENT } from "../constants";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { trackEvent } from "../analytics"; import { arrayToMap } from "../utils";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
import { useDevice } from "./App";
const useOnClickOutside = ( const useOnClickOutside = (
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
@@ -64,20 +53,9 @@ const getSelectedItems = (
selectedItems: LibraryItem["id"][], selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id)); ) => libraryItems.filter((item) => selectedItems.includes(item.id));
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<Island padding={1} ref={ref} className="layer-ui__library">
{children}
</Island>
);
});
export const LibraryMenu = ({ export const LibraryMenu = ({
onClose, onClose,
onInsertLibraryItems, onInsertShape,
pendingElements, pendingElements,
onAddToLibrary, onAddToLibrary,
theme, theme,
@@ -91,7 +69,7 @@ export const LibraryMenu = ({
}: { }: {
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onClose: () => void; onClose: () => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
theme: AppState["theme"]; theme: AppState["theme"];
files: BinaryFiles; files: BinaryFiles;
@@ -104,30 +82,17 @@ export const LibraryMenu = ({
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const device = useDevice(); useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
useOnClickOutside( if ((event.target as Element).closest(".ToolIcon__library")) {
ref, return;
useCallback( }
(event) => { onClose();
// If click on the library icon, do nothing. });
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
onClose();
}
},
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
),
);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( if (event.key === KEYS.ESCAPE) {
event.key === KEYS.ESCAPE &&
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
) {
onClose(); onClose();
} }
}; };
@@ -135,8 +100,13 @@ export const LibraryMenu = ({
return () => { return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
}; };
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]); }, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] = const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false); useState(false);
@@ -144,35 +114,55 @@ export const LibraryMenu = ({
url: string; url: string;
authorName: string; authorName: string;
}>(null); }>(null);
const loadingTimerRef = useRef<number | null>(null);
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback( const removeFromLibrary = useCallback(async () => {
async (libraryItems: LibraryItems) => { const items = await library.loadLibrary();
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id), const nextItems = items.filter((item) => !selectedItems.includes(item.id));
); library.saveLibrary(nextItems).catch((error) => {
library.setLibrary(nextItems).catch(() => { setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
}); });
setSelectedItems([]); setSelectedItems([]);
}, setLibraryItems(nextItems);
[library, setAppState, selectedItems, setSelectedItems], }, [library, setAppState, selectedItems, setSelectedItems]);
);
const resetLibrary = useCallback(() => { const resetLibrary = useCallback(() => {
library.resetLibrary(); library.resetLibrary();
setLibraryItems([]);
focusContainer(); focusContainer();
}, [library, focusContainer]); }, [library, focusContainer]);
const addToLibrary = useCallback( const addToLibrary = useCallback(
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => { async (elements: LibraryItem["elements"]) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) { if (elements.some((element) => element.type === "image")) {
return setAppState({ return setAppState({
errorMessage: "Support for adding images to the library coming soon!", errorMessage: "Support for adding images to the library coming soon!",
}); });
} }
const items = await library.loadLibrary();
const nextItems: LibraryItems = [ const nextItems: LibraryItems = [
{ {
status: "unpublished", status: "unpublished",
@@ -180,12 +170,14 @@ export const LibraryMenu = ({
id: randomId(), id: randomId(),
created: Date.now(), created: Date.now(),
}, },
...libraryItems, ...items,
]; ];
onAddToLibrary(); onAddToLibrary();
library.setLibrary(nextItems).catch(() => { library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
}); });
setLibraryItems(nextItems);
}, },
[onAddToLibrary, library, setAppState], [onAddToLibrary, library, setAppState],
); );
@@ -224,7 +216,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]); }, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback( const onPublishLibSuccess = useCallback(
(data, libraryItems: LibraryItems) => { (data) => {
setShowPublishLibraryDialog(false); setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName }); setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice(); const nextLibItems = libraryItems.slice();
@@ -233,71 +225,102 @@ export const LibraryMenu = ({
libItem.status = "published"; libItem.status = "published";
} }
}); });
library.setLibrary(nextLibItems); library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
}, },
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library], [
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
); );
if ( const [lastSelectedItem, setLastSelectedItem] = useState<
libraryItemsData.status === "loading" && LibraryItem["id"] | null
!libraryItemsData.isInitialized >(null);
) {
return (
<LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message">
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</LibraryMenuWrapper>
);
}
return ( return loadingState === "preloading" ? null : (
<LibraryMenuWrapper ref={ref}> <Island padding={1} ref={ref} className="layer-ui__library">
{showPublishLibraryDialog && ( {showPublishLibraryDialog && (
<PublishLibrary <PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)} onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems( libraryItems={getSelectedItems(libraryItems, selectedItems)}
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState} appState={appState}
onSuccess={(data) => onSuccess={onPublishLibSuccess}
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)} onError={(error) => window.alert(error)}
updateItemsInStorage={() => updateItemsInStorage={() => library.saveLibrary(libraryItems)}
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) => onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id)) setSelectedItems(selectedItems.filter((_id) => _id !== id))
} }
/> />
)} )}
{publishLibSuccess && renderPublishSuccess()} {publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"} {loadingState === "loading" ? (
libraryItems={libraryItemsData.libraryItems} <div className="layer-ui__library-message">
onRemoveFromLibrary={() => {t("labels.libraryLoadingMessage")}
removeFromLibrary(libraryItemsData.libraryItems) </div>
} ) : (
onAddToLibrary={(elements) => <LibraryMenuItems
addToLibrary(elements, libraryItemsData.libraryItems) libraryItems={libraryItems}
} onRemoveFromLibrary={removeFromLibrary}
onInsertLibraryItems={onInsertLibraryItems} onAddToLibrary={addToLibrary}
pendingElements={pendingElements} onInsertShape={onInsertShape}
setAppState={setAppState} pendingElements={pendingElements}
appState={appState} setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
library={library} library={library}
theme={theme} theme={theme}
files={files} files={files}
id={id} id={id}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={(ids) => setSelectedItems(ids)} onToggle={(id, event) => {
onPublish={() => setShowPublishLibraryDialog(true)} const shouldSelect = !selectedItems.includes(id);
resetLibrary={resetLibrary}
/> if (shouldSelect) {
</LibraryMenuWrapper> if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setSelectedItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
); );
}; };
+3 -16
View File
@@ -2,17 +2,8 @@
.excalidraw { .excalidraw {
.library-menu-items-container { .library-menu-items-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 0.5rem;
box-sizing: border-box;
.library-actions { .library-actions {
width: 100%;
display: flex; display: flex;
margin-right: auto;
align-items: center;
button .library-actions-counter { button .library-actions-counter {
position: absolute; position: absolute;
@@ -96,16 +87,12 @@
} }
} }
&__items { &__items {
flex: 1; max-height: 50vh;
overflow-y: auto; overflow: auto;
overflow-x: hidden; margin-top: 0.5rem;
margin-bottom: 1rem;
} }
.separator { .separator {
width: 100%;
display: flex;
align-items: center;
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
margin: 0.6em 0.2em; margin: 0.6em 0.2em;
+62 -298
View File
@@ -1,6 +1,6 @@
import { chunk } from "lodash"; import { chunk } from "lodash";
import React, { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json"; import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import Library from "../data/library"; import Library from "../data/library";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -11,57 +11,48 @@ import {
LibraryItem, LibraryItem,
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
import { arrayToMap, muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useDevice } from "./App"; import { useIsMobile } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons"; import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { MIME_TYPES, VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import Spinner from "./Spinner";
import { fileOpen } from "../data/filesystem";
import { SidebarLockButton } from "./SidebarLockButton";
import { trackEvent } from "../analytics";
const LibraryMenuItems = ({ const LibraryMenuItems = ({
isLoading,
libraryItems, libraryItems,
onRemoveFromLibrary, onRemoveFromLibrary,
onAddToLibrary, onAddToLibrary,
onInsertLibraryItems, onInsertShape,
pendingElements, pendingElements,
theme, theme,
setAppState, setAppState,
appState,
libraryReturnUrl, libraryReturnUrl,
library, library,
files, files,
id, id,
selectedItems, selectedItems,
onSelectItems, onToggle,
onPublish, onPublish,
resetLibrary, resetLibrary,
}: { }: {
isLoading: boolean;
libraryItems: LibraryItems; libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void; onAddToLibrary: (elements: LibraryItem["elements"]) => void;
theme: AppState["theme"]; theme: AppState["theme"];
files: BinaryFiles; files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
appState: AppState;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library; library: Library;
id: string; id: string;
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onPublish: () => void; onPublish: () => void;
resetLibrary: () => void; resetLibrary: () => void;
}) => { }) => {
@@ -93,7 +84,9 @@ const LibraryMenuItems = ({
}, [selectedItems, onRemoveFromLibrary, resetLibrary]); }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const device = useDevice();
const isMobile = useIsMobile();
const renderLibraryActions = () => { const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length; const itemsSelected = !!selectedItems.length;
const items = itemsSelected const items = itemsSelected
@@ -104,34 +97,24 @@ const LibraryMenuItems = ({
: t("buttons.resetLibrary"); : t("buttons.resetLibrary");
return ( return (
<div className="library-actions"> <div className="library-actions">
{!itemsSelected && ( {(!itemsSelected || !isMobile) && (
<ToolButton <ToolButton
key="import" key="import"
type="button" type="button"
title={t("buttons.load")} title={t("buttons.load")}
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
icon={load} icon={load}
onClick={async () => { onClick={() => {
try { importLibraryFromJSON(library)
await library.updateLibrary({ .then(() => {
libraryItems: fileOpen({ // Close and then open to get the libraries updated
description: "Excalidraw library files", setAppState({ isLibraryOpen: false });
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 setAppState({ isLibraryOpen: true });
// gets resolved. Else, iOS users cannot open `.excalidraw` files. })
/* .catch(muteFSAbortError)
extensions: [".json", ".excalidrawlib"], .catch((error) => {
*/ setAppState({ errorMessage: error.message });
}),
merge: true,
openLibraryMenu: true,
}); });
} catch (error: any) {
if (error?.name === "AbortError") {
console.warn(error);
return;
}
setAppState({ errorMessage: t("errors.importLibraryError") });
}
}} }}
className="library-actions--load" className="library-actions--load"
/> />
@@ -147,7 +130,7 @@ const LibraryMenuItems = ({
onClick={async () => { onClick={async () => {
const libraryItems = itemsSelected const libraryItems = itemsSelected
? items ? items
: await library.getLatestLibrary(); : await library.loadLibrary();
saveLibraryAsJSON(libraryItems) saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
@@ -179,7 +162,7 @@ const LibraryMenuItems = ({
</ToolButton> </ToolButton>
</> </>
)} )}
{itemsSelected && ( {itemsSelected && !isPublished && (
<Tooltip label={t("hints.publishLibrary")}> <Tooltip label={t("hints.publishLibrary")}>
<ToolButton <ToolButton
type="button" type="button"
@@ -189,7 +172,7 @@ const LibraryMenuItems = ({
className="library-actions--publish" className="library-actions--publish"
onClick={onPublish} onClick={onPublish}
> >
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>} {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
{selectedItems.length > 0 && ( {selectedItems.length > 0 && (
<span className="library-actions-counter"> <span className="library-actions-counter">
{selectedItems.length} {selectedItems.length}
@@ -198,89 +181,17 @@ const LibraryMenuItems = ({
</ToolButton> </ToolButton>
</Tooltip> </Tooltip>
)} )}
{device.isMobile && (
<div className="library-menu-browse-button--mobile">
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
)}
</div> </div>
); );
}; };
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4; const CELLS_PER_ROW = isMobile ? 4 : 6;
const referrer = const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname; libraryReturnUrl || window.location.origin + window.location.pathname;
const isPublished = selectedItems.some(
const [lastSelectedItem, setLastSelectedItem] = useState< (id) => libraryItems.find((item) => item.id === id)?.status === "published",
LibraryItem["id"] | null );
>(null);
const onItemSelectToggle = (
id: LibraryItem["id"],
event: React.MouseEvent,
) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
}
};
const getInsertedElements = (id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements;
};
const createLibraryItemCompo = (params: { const createLibraryItemCompo = (params: {
item: item:
@@ -302,12 +213,8 @@ const LibraryMenuItems = ({
onClick={params.onClick || (() => {})} onClick={params.onClick || (() => {})}
id={params.item?.id || null} id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)} selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={onItemSelectToggle} onToggle={(id, event) => {
onDrag={(id, event) => { onToggle(id, event);
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
}} }}
/> />
</Stack.Col> </Stack.Col>
@@ -327,7 +234,7 @@ const LibraryMenuItems = ({
if (item.id) { if (item.id) {
return createLibraryItemCompo({ return createLibraryItemCompo({
item, item,
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)), onClick: () => onInsertShape(item.elements),
key: item.id, key: item.id,
}); });
} }
@@ -366,192 +273,49 @@ const LibraryMenuItems = ({
}); });
}; };
const unpublishedItems = libraryItems.filter(
(item) => item.status !== "published",
);
const publishedItems = libraryItems.filter( const publishedItems = libraryItems.filter(
(item) => item.status === "published", (item) => item.status === "published",
); );
const unpublishedItems = [
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...libraryItems.filter((item) => item.status !== "published"),
];
const renderLibraryHeader = () => { return (
return ( <div className="library-menu-items-container">
<> {showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header"> <div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()} {renderLibraryActions()}
{device.canDeviceFitSidebar && ( <a
<> href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
<div className="layer-ui__sidebar-lock-button"> window.name || "_blank"
<SidebarLockButton }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
checked={appState.isLibraryMenuDocked} VERSIONS.excalidrawLibrary
onChange={() => { }`}
document target="_excalidraw_libraries"
.querySelector(".layer-ui__wrapper") >
?.classList.add("animate"); {t("labels.libraries")}
const nextState = !appState.isLibraryMenuDocked; </a>
setAppState({ </div>
isLibraryMenuDocked: nextState,
});
trackEvent(
"library",
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
</div>
</>
)}
{!device.isMobile && (
<div className="ToolIcon__icon__close">
<button
className="Modal__close"
onClick={() =>
setAppState({
isLibraryOpen: false,
})
}
aria-label={t("buttons.close")}
>
{close}
</button>
</div>
)}
</div>
</>
);
};
const renderLibraryMenuItems = () => {
return (
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
align="start" align="start"
gap={1} gap={1}
style={{
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0,
}}
> >
<> <>
<div className="separator"> <div className="separator">{t("labels.personalLib")}</div>
{(pendingElements.length > 0 || {renderLibrarySection(unpublishedItems)}
unpublishedItems.length > 0 ||
publishedItems.length > 0) && (
<div>{t("labels.personalLib")}</div>
)}
{isLoading && (
<div
style={{
marginLeft: "auto",
marginRight: "1rem",
display: "flex",
alignItems: "center",
fontWeight: "normal",
}}
>
<div style={{ transform: "translateY(2px)" }}>
<Spinner />
</div>
</div>
)}
</div>
{!pendingElements.length && !unpublishedItems.length ? (
<div
style={{
height: 65,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
<div
style={{
margin: ".6rem 0",
fontSize: ".8em",
width: "70%",
textAlign: "center",
}}
>
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)}
</> </>
<> <>
{(publishedItems.length > 0 || <div className="separator">{t("labels.excalidrawLib")} </div>
(!device.isMobile &&
(pendingElements.length > 0 || unpublishedItems.length > 0))) && ( {renderLibrarySection(publishedItems)}
<div className="separator">{t("labels.excalidrawLib")}</div>
)}
{publishedItems.length > 0 ? (
renderLibrarySection(publishedItems)
) : unpublishedItems.length > 0 ? (
<div
style={{
margin: "1rem 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
</div>
) : null}
</> </>
</Stack.Col> </Stack.Col>
);
};
const renderLibraryFooter = () => {
return (
<a
className="library-menu-browse-button"
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
);
};
return (
<div
className="library-menu-items-container"
style={
device.isMobile
? {
minHeight: "200px",
maxHeight: "70vh",
}
: undefined
}
>
{showRemoveLibAlert && renderRemoveLibAlert()}
{renderLibraryHeader()}
{renderLibraryMenuItems()}
{!device.isMobile && renderLibraryFooter()}
</div> </div>
); );
}; };
+5 -1
View File
@@ -3,7 +3,7 @@
.excalidraw { .excalidraw {
.library-unit { .library-unit {
align-items: center; align-items: center;
border: 1px solid transparent; border: 1px solid var(--button-gray-2);
display: flex; display: flex;
justify-content: center; justify-content: center;
position: relative; position: relative;
@@ -21,6 +21,10 @@
} }
} }
&.theme--dark .library-unit {
border-color: rgb(48, 48, 48);
}
.library-unit__dragger { .library-unit__dragger {
display: flex; display: flex;
align-items: center; align-items: center;
+7 -9
View File
@@ -1,7 +1,8 @@
import clsx from "clsx"; import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App"; import { MIME_TYPES } from "../constants";
import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
@@ -28,7 +29,6 @@ export const LibraryUnit = ({
onClick, onClick,
selected, selected,
onToggle, onToggle,
onDrag,
}: { }: {
id: LibraryItem["id"] | /** for pending item */ null; id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"]; elements?: LibraryItem["elements"];
@@ -37,7 +37,6 @@ export const LibraryUnit = ({
onClick: () => void; onClick: () => void;
selected: boolean; selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void; onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -67,7 +66,7 @@ export const LibraryUnit = ({
}, [elements, files]); }, [elements, files]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile; const isMobile = useIsMobile();
const adder = isPending && ( const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div> <div className="library-unit__adder">{PLUS_ICON}</div>
); );
@@ -100,12 +99,11 @@ export const LibraryUnit = ({
: undefined : undefined
} }
onDragStart={(event) => { onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
}
setIsHovered(false); setIsHovered(false);
onDrag(id, event); event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
JSON.stringify(elements),
);
}} }}
/> />
{adder} {adder}
+3 -23
View File
@@ -1,30 +1,10 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { useState, useEffect } from "react";
import Spinner from "./Spinner";
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
const [isWaiting, setIsWaiting] = useState(!!delay);
useEffect(() => {
if (!delay) {
return;
}
const timer = setTimeout(() => {
setIsWaiting(false);
}, delay);
return () => clearTimeout(timer);
}, [delay]);
if (isWaiting) {
return null;
}
export const LoadingMessage = () => {
// !! KEEP THIS IN SYNC WITH index.html !!
return ( return (
<div className="LoadingMessage"> <div className="LoadingMessage">
<div> <span>{t("labels.loadingScene")}</span>
<Spinner />
</div>
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
</div> </div>
); );
}; };
+37 -45
View File
@@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { Island } from "./Island"; import { Island } from "./Island";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { calculateScrollCenter, getSelectedElements } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
@@ -32,17 +32,14 @@ type MobileMenuProps = {
onPenModeToggle: () => void; onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
isCollaborating: boolean; isCollaborating: boolean;
renderCustomFooter?: ( renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
isMobile: boolean, viewModeEnabled: boolean;
appState: AppState,
) => JSX.Element | null;
showThemeBtn: boolean; showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderStats: () => JSX.Element | null;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@@ -59,10 +56,10 @@ export const MobileMenu = ({
canvas, canvas,
isCollaborating, isCollaborating,
renderCustomFooter, renderCustomFooter,
viewModeEnabled,
showThemeBtn, showThemeBtn,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderStats,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
@@ -75,9 +72,8 @@ export const MobileMenu = ({
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
appState={appState}
canvas={canvas} canvas={canvas}
activeTool={appState.activeTool} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
onImageAction({ onImageAction({
@@ -89,7 +85,7 @@ export const MobileMenu = ({
</Island> </Island>
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton <LockButton
checked={appState.activeTool.locked} checked={appState.elementLocked}
onChange={onLockToggle} onChange={onLockToggle}
title={t("toolBar.lock")} title={t("toolBar.lock")}
isMobile isMobile
@@ -117,29 +113,19 @@ export const MobileMenu = ({
}; };
const renderAppToolbar = () => { const renderAppToolbar = () => {
// Render eraser conditionally in mobile if (viewModeEnabled) {
const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (appState.viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")} {actionManager.renderAction("toggleCanvasMenu")}
</div> </div>
); );
} }
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")} {actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
{showEraser && actionManager.renderAction("eraser")}
{actionManager.renderAction( {actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection", appState.multiElement ? "finalize" : "duplicateSelection",
)} )}
@@ -149,7 +135,7 @@ export const MobileMenu = ({
}; };
const renderCanvasActions = () => { const renderCanvasActions = () => {
if (appState.viewModeEnabled) { if (viewModeEnabled) {
return ( return (
<> <>
{renderJSONExportDialog()} {renderJSONExportDialog()}
@@ -183,8 +169,7 @@ export const MobileMenu = ({
}; };
return ( return (
<> <>
{!appState.viewModeEnabled && renderToolbar()} {!viewModeEnabled && renderToolbar()}
{renderStats()}
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
@@ -203,44 +188,51 @@ export const MobileMenu = ({
{appState.collaborators.size > 0 && ( {appState.collaborators.size > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.collaborators")}</legend> <legend>{t("labels.collaborators")}</legend>
<UserList <UserList mobile>
mobile {Array.from(appState.collaborators)
collaborators={appState.collaborators} // Collaborator is either not initialized or is actually the current user.
actionManager={actionManager} .filter(
/> ([_, client]) => Object.keys(client).length !== 0,
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</React.Fragment>
))}
</UserList>
</fieldset> </fieldset>
)} )}
</Stack.Col> </Stack.Col>
</div> </div>
</Section> </Section>
) : appState.openMenu === "shape" && ) : appState.openMenu === "shape" &&
!appState.viewModeEnabled && !viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? ( showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions"> <Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type} elementType={appState.elementType}
/> />
</Section> </Section>
) : null} ) : null}
<footer className="App-toolbar"> <footer className="App-toolbar">
{renderAppToolbar()} {renderAppToolbar()}
{appState.scrolledOutside && {appState.scrolledOutside && !appState.openMenu && (
!appState.openMenu && <button
!appState.isLibraryOpen && ( className="scroll-back-to-content"
<button onClick={() => {
className="scroll-back-to-content" setAppState({
onClick={() => { ...calculateScrollCenter(elements, appState, canvas),
setAppState({ });
...calculateScrollCenter(elements, appState, canvas), }}
}); >
}} {t("buttons.scrollBackToContent")}
> </button>
{t("buttons.scrollBackToContent")} )}
</button>
)}
</footer> </footer>
</Island> </Island>
</div> </div>
+6 -6
View File
@@ -4,7 +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, useDevice } from "./App"; import { useExcalidrawContainer, useIsMobile } from "./App";
import { AppState } from "../types"; import { AppState } from "../types";
import { THEME } from "../constants"; import { THEME } from "../constants";
@@ -59,17 +59,17 @@ export const Modal = (props: {
const useBodyRoot = (theme: AppState["theme"]) => { const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null); const [div, setDiv] = useState<HTMLDivElement | null>(null);
const device = useDevice(); const isMobile = useIsMobile();
const isMobileRef = useRef(device.isMobile); const isMobileRef = useRef(isMobile);
isMobileRef.current = device.isMobile; isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer(); const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
div.classList.toggle("excalidraw--mobile", device.isMobile); div.classList.toggle("excalidraw--mobile", isMobile);
} }
}, [div, device.isMobile]); }, [div, isMobile]);
useLayoutEffect(() => { useLayoutEffect(() => {
const isDarkTheme = const isDarkTheme =
-1
View File
@@ -2,6 +2,5 @@
.popover { .popover {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
padding: 5px 0 5px;
} }
} }
+1 -53
View File
@@ -1,8 +1,6 @@
import React, { useLayoutEffect, useRef, useEffect } from "react"; import React, { useLayoutEffect, useRef, useEffect } from "react";
import "./Popover.scss"; import "./Popover.scss";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { queryFocusableElements } from "../utils";
import { KEYS } from "../keys";
type Props = { type Props = {
top?: number; top?: number;
@@ -29,67 +27,17 @@ export const Popover = ({
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const container = popoverRef.current;
useEffect(() => {
if (!container) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(container);
const { activeElement } = document;
const currentIndex = focusableElements.findIndex(
(element) => element === activeElement,
);
if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1].focus();
event.preventDefault();
event.stopImmediatePropagation();
} else if (
currentIndex === focusableElements.length - 1 &&
!event.shiftKey
) {
focusableElements[0].focus();
event.preventDefault();
event.stopImmediatePropagation();
}
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [container]);
// ensure the popover doesn't overflow the viewport // ensure the popover doesn't overflow the viewport
useLayoutEffect(() => { useLayoutEffect(() => {
if (fitInViewport && popoverRef.current) { if (fitInViewport && popoverRef.current) {
const element = popoverRef.current; const element = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect(); const { x, y, width, height } = element.getBoundingClientRect();
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
//Position correctly when clicked on rightmost part or the bottom part of viewport
if (x + width - offsetLeft > viewportWidth) { if (x + width - offsetLeft > viewportWidth) {
element.style.left = `${viewportWidth - width - 10}px`; element.style.left = `${viewportWidth - width}px`;
} }
if (y + height - offsetTop > viewportHeight) { if (y + height - offsetTop > viewportHeight) {
element.style.top = `${viewportHeight - height}px`; element.style.top = `${viewportHeight - height}px`;
} }
//Resize to fit viewport on smaller screens
if (height >= viewportHeight) {
element.style.height = `${viewportHeight - 20}px`;
element.style.top = "10px";
element.style.overflowY = "scroll";
}
if (width >= viewportWidth) {
element.style.width = `${viewportWidth}px`;
element.style.left = "0px";
element.style.overflowX = "scroll";
}
} }
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]); }, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
-4
View File
@@ -82,10 +82,6 @@
} }
} }
&-warning {
color: $oc-red-6;
}
&-note { &-note {
padding: 1em; padding: 1em;
font-style: italic; font-style: italic;
-10
View File
@@ -295,11 +295,6 @@ const PublishLibrary = ({
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]); }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
const shouldRenderForm = !!libraryItems.length; const shouldRenderForm = !!libraryItems.length;
const containsPublishedItems = libraryItems.some(
(item) => item.status === "published",
);
return ( return (
<Dialog <Dialog
onCloseRequest={onDialogClose} onCloseRequest={onDialogClose}
@@ -334,11 +329,6 @@ const PublishLibrary = ({
<div className="publish-library-note"> <div className="publish-library-note">
{t("publishDialog.noteItems")} {t("publishDialog.noteItems")}
</div> </div>
{containsPublishedItems && (
<span className="publish-library-note publish-library-warning">
{t("publishDialog.republishWarning")}
</span>
)}
{renderLibraryItems()} {renderLibraryItems()}
<div className="publish-library__fields"> <div className="publish-library__fields">
<label> <label>
-22
View File
@@ -1,22 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.layer-ui__sidebar-lock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
}
.ToolIcon_type_floating .side_lock_icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
background-color: var(--color-primary);
}
}
}
-46
View File
@@ -1,46 +0,0 @@
import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { t } from "../i18n";
import { Tooltip } from "./Tooltip";
import "./SidebarLockButton.scss";
type SidebarLockIconProps = {
checked: boolean;
onChange?(): void;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarLockButton = (props: SidebarLockIconProps) => {
return (
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
</div>{" "}
</label>{" "}
</Tooltip>
);
};
+2 -15
View File
@@ -3,24 +3,11 @@
.excalidraw { .excalidraw {
.single-library-item { .single-library-item {
position: relative; position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg { &__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem; width: 7.5rem;
height: 7.5rem; height: 7.5rem;
border: 1px solid var(--button-gray-2); border: 1px solid var(--button-gray-2);
margin: 0.3rem;
svg { svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -53,7 +40,7 @@
&--remove { &--remove {
position: absolute; position: absolute;
top: 0.2rem; top: 0.2rem;
right: 1rem; right: 1.3rem;
.ToolIcon__icon { .ToolIcon__icon {
margin: 0; margin: 0;
-5
View File
@@ -45,11 +45,6 @@ const SingleLibraryItem = ({
return ( return (
<div className="single-library-item"> <div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" /> <div ref={svgRef} className="single-library-item__svg" />
<ToolButton <ToolButton
aria-label={t("buttons.remove")} aria-label={t("buttons.remove")}
-2
View File
@@ -41,7 +41,6 @@ const ColStack = ({
align, align,
justifyContent, justifyContent,
className, className,
style,
}: StackProps) => { }: StackProps) => {
return ( return (
<div <div
@@ -50,7 +49,6 @@ const ColStack = ({
"--gap": gap, "--gap": gap,
justifyItems: align, justifyItems: align,
justifyContent, justifyContent,
...style,
}} }}
> >
{children} {children}
-1
View File
@@ -7,7 +7,6 @@
right: 12px; right: 12px;
font-size: 12px; font-size: 12px;
z-index: 10; z-index: 10;
pointer-events: all;
h3 { h3 {
margin: 0 24px 8px 0; margin: 0 24px 8px 0;
+6 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useIsMobile } from "../components/App";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons"; import { close } from "./icons";
@@ -16,13 +16,16 @@ export const Stats = (props: {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const device = useDevice(); const isMobile = useIsMobile();
const boundingBox = getCommonBounds(props.elements); const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState); const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements); const selectedBoundingBox = getCommonBounds(selectedElements);
if (device.isMobile && props.appState.openMenu) {
if (isMobile && props.appState.openMenu) {
return null; return null;
} }
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>
+4 -20
View File
@@ -2,9 +2,6 @@
.excalidraw { .excalidraw {
.Toast { .Toast {
$closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem;
animation: fade-in 0.5s; animation: fade-in 0.5s;
background-color: var(--button-gray-1); background-color: var(--button-gray-1);
border-radius: 4px; border-radius: 4px;
@@ -18,24 +15,11 @@
text-align: center; text-align: center;
width: 300px; width: 300px;
z-index: 999999; z-index: 999999;
}
.Toast__message { .Toast__message {
padding: 0 $closeButtonSize + ($closeButtonPadding); color: var(--popup-text-color);
color: var(--popup-text-color); white-space: pre-wrap;
white-space: pre-wrap;
}
.close {
position: absolute;
top: 0;
right: 0;
padding: $closeButtonPadding;
.ToolIcon__icon {
width: $closeButtonSize;
height: $closeButtonSize;
}
}
} }
@keyframes fade-in { @keyframes fade-in {
+12 -37
View File
@@ -1,59 +1,34 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { close } from "./icons"; import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss"; import "./Toast.scss";
import { ToolButton } from "./ToolButton";
const DEFAULT_TOAST_TIMEOUT = 5000;
export const Toast = ({ export const Toast = ({
message, message,
onClose, clearToast,
closable = false,
// To prevent autoclose, pass duration as Infinity
duration = DEFAULT_TOAST_TIMEOUT,
}: { }: {
message: string; message: string;
onClose: () => void; clearToast: () => void;
closable?: boolean;
duration?: number;
}) => { }) => {
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const shouldAutoClose = duration !== Infinity;
const scheduleTimeout = useCallback(() => { const scheduleTimeout = useCallback(
if (!shouldAutoClose) { () =>
return; (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
} [clearToast],
timerRef.current = window.setTimeout(() => onClose(), duration); );
}, [onClose, duration, shouldAutoClose]);
useEffect(() => { useEffect(() => {
if (!shouldAutoClose) {
return;
}
scheduleTimeout(); scheduleTimeout();
return () => clearTimeout(timerRef.current); return () => clearTimeout(timerRef.current);
}, [scheduleTimeout, message, duration, shouldAutoClose]); }, [scheduleTimeout, message]);
const onMouseEnter = shouldAutoClose
? () => clearTimeout(timerRef?.current)
: undefined;
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
return ( return (
<div <div
className="Toast" className="Toast"
onMouseEnter={onMouseEnter} onMouseEnter={() => clearTimeout(timerRef?.current)}
onMouseLeave={onMouseLeave} onMouseLeave={scheduleTimeout}
> >
<p className="Toast__message">{message}</p> <p className="Toast__message">{message}</p>
{closable && (
<ToolButton
icon={close}
aria-label="close"
type="icon"
onClick={onClose}
className="close"
/>
)}
</div> </div>
); );
}; };
-2
View File
@@ -48,7 +48,6 @@ type ToolButtonProps =
type: "radio"; type: "radio";
checked: boolean; checked: boolean;
onChange?(data: { pointerType: PointerType | null }): void; onChange?(data: { pointerType: PointerType | null }): void;
onPointerDown?(data: { pointerType: PointerType }): void;
}); });
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -150,7 +149,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title} title={props.title}
onPointerDown={(event) => { onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null; lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}} }}
onPointerUp={() => { onPointerUp={() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
+6 -4
View File
@@ -155,7 +155,7 @@
} }
width: 2rem; width: 2rem;
height: 2rem; height: 2em;
} }
} }
@@ -212,14 +212,16 @@
} }
} }
.ToolIcon.ToolIcon__library { .ToolIcon.ToolIcon__library {
top: calc(var(--sat) + 100px); top: 100px;
} }
.ToolIcon.ToolIcon__lock { .ToolIcon.ToolIcon__lock {
top: calc(var(--sat) + 60px); margin-inline-end: 0;
top: 60px;
} }
.ToolIcon.ToolIcon__penMode { .ToolIcon.ToolIcon__penMode {
top: calc(var(--sat) + 140px); margin-inline-end: 0;
top: 140px;
} }
} }
+23 -1
View File
@@ -1,5 +1,26 @@
@import "open-color/open-color.scss"; @import "open-color/open-color.scss";
@import "../css/variables.module";
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
.excalidraw { .excalidraw {
.App-toolbar-container { .App-toolbar-container {
@@ -32,6 +53,7 @@
} }
.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;
} }
+1 -1
View File
@@ -2,7 +2,7 @@
// container in body where the actual tooltip is appended to // container in body where the actual tooltip is appended to
.excalidraw-tooltip { .excalidraw-tooltip {
position: fixed; position: absolute;
z-index: 1000; z-index: 1000;
padding: 8px; padding: 8px;
+5 -39
View File
@@ -2,51 +2,17 @@ import "./UserList.scss";
import React from "react"; import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { ActionManager } from "../actions/manager";
export const UserList: React.FC<{ type UserListProps = {
children: React.ReactNode;
className?: string; className?: string;
mobile?: boolean; mobile?: boolean;
collaborators: AppState["collaborators"]; };
actionManager: ActionManager;
}> = ({ className, mobile, collaborators, actionManager }) => {
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
collaborator,
);
});
const avatars =
uniqueCollaborators.size > 0 &&
Array.from(uniqueCollaborators)
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, collaborator]) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
collaborator,
]);
return mobile ? (
<Tooltip
label={collaborator.username || "Unknown user"}
key={clientId}
>
{avatarJSX}
</Tooltip>
) : (
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
);
});
export const UserList = ({ children, className, mobile }: UserListProps) => {
return ( return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}> <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars} {children}
</div> </div>
); );
}; };
-4
View File
@@ -934,7 +934,3 @@ export const editIcon = createIcon(
></path>, ></path>,
{ width: 640, height: 512 }, { width: 640, height: 512 },
); );
export const eraser = createIcon(
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
);
+4 -20
View File
@@ -63,6 +63,8 @@ export const ENV = {
export const CLASSES = { export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
SHAPE_ACTIONS_MOBILE_MENU: "App-mobile-menu",
MOBILE_TOOLBAR: "App-toolbar-content",
}; };
// 1-based in case we ever do `if(element.fontFamily)` // 1-based in case we ever do `if(element.fontFamily)`
@@ -94,9 +96,7 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json", json: "application/json",
svg: "image/svg+xml", svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png", png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
gif: "image/gif", gif: "image/gif",
binary: "application/octet-stream", binary: "application/octet-stream",
@@ -108,14 +108,14 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib", excalidrawLibrary: "excalidrawlib",
} as const; } as const;
export const EXPORT_SOURCE = export const EXPORT_SOURCE = window.location.origin;
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
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 ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
@@ -154,19 +154,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
}, },
}; };
// breakpoints
// -----------------------------------------------------------------------------
// sm screen
export const MQ_SM_MAX_WIDTH = 640;
// md screen
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;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
@@ -200,9 +190,3 @@ export const VERTICAL_ALIGN = {
MIDDLE: "middle", MIDDLE: "middle",
BOTTOM: "bottom", BOTTOM: "bottom",
}; };
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};
+7 -9
View File
@@ -16,17 +16,15 @@
left: 0; left: 0;
z-index: 999; z-index: 999;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
}
.Spinner { .LoadingMessage span {
font-size: 2.8em; background-color: var(--button-gray-1);
} border-radius: 5px;
padding: 0.8em 1.2em;
.LoadingMessage-text { color: var(--popup-text-color);
margin-top: 1em; font-size: 1.3em;
font-size: 0.8em;
}
} }
+2 -39
View File
@@ -290,16 +290,6 @@
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
.eraser {
&.ToolIcon:hover {
--icon-fill-color: #fff;
--keybinding-color: #fff;
}
&.active {
background-color: var(--color-primary);
}
}
} }
.App-toolbar-content { .App-toolbar-content {
@@ -350,6 +340,7 @@
align-items: flex-start; align-items: flex-start;
cursor: default; cursor: default;
pointer-events: none !important; pointer-events: none !important;
z-index: 100;
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: 0.25rem; left: 0.25rem;
@@ -390,7 +381,6 @@
.App-menu__left { .App-menu__left {
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-island);
} }
.dropdown-select { .dropdown-select {
@@ -449,7 +439,6 @@
bottom: 30px; bottom: 30px;
transform: translateX(-50%); transform: translateX(-50%);
padding: 10px 20px; padding: 10px 20px;
pointer-events: all;
} }
.help-icon { .help-icon {
@@ -478,17 +467,7 @@
font-family: var(--ui-font); font-family: var(--ui-font);
} }
.finalize-button { .undo-redo-buttons {
display: grid;
grid-auto-flow: column;
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
}
.undo-redo-buttons,
.eraser-buttons {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
gap: 0.4em; gap: 0.4em;
@@ -568,22 +547,6 @@
display: none; display: none;
} }
} }
// use custom, minimalistic scrollbar
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-thumb {
background: var(--button-gray-2);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--button-gray-3);
}
::-webkit-scrollbar-thumb:active {
background: var(--button-gray-2);
}
} }
.ErrorSplash.excalidraw { .ErrorSplash.excalidraw {

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