Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle 3c83a322b6 feat: support image background editor [wip] 2021-12-20 21:50:16 +01:00
253 changed files with 8412 additions and 19939 deletions
+1 -6
View File
@@ -4,10 +4,5 @@ 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_SOCKET_SERVER_URL=http://localhost:3000
REACT_APP_WS_SERVER_URL=http://localhost:3002
# set this only if using the collaboration workflow we use on excalidraw.com
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"}'
+1 -5
View File
@@ -4,11 +4,7 @@ REACT_APP_BACKEND_V2_POST_URL=https://json.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
REACT_APP_PORTAL_URL=https://portal.excalidraw.com REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars # production-only vars
@@ -23,5 +23,4 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release - name: Auto release
run: | run: |
yarn add @actions/core
yarn autorelease yarn autorelease
-55
View File
@@ -1,55 +0,0 @@
name: Auto release preview @excalidraw/excalidraw-preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
+29
View File
@@ -0,0 +1,29 @@
name: Build packages
on:
push:
branches:
- master
pull_request:
jobs:
packages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Install dependencies
run: |
yarn --frozen-lockfile
yarn --cwd src/packages/excalidraw
yarn --cwd src/packages/utils
- name: Build @excalidraw/excalidraw
run: |
yarn --cwd src/packages/excalidraw run pack
- name: Build @excalidraw/utils
run: |
yarn --cwd src/packages/utils run pack
-4
View File
@@ -23,7 +23,3 @@ static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
src/packages/excalidraw/types src/packages/excalidraw/types
src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js
+8 -43
View File
@@ -32,10 +32,6 @@ Last but not least, we're thankful to these companies for offering their service
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com) [![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## 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/) •
## Documentation ## Documentation
### Shortcuts ### Shortcuts
@@ -122,47 +118,16 @@ yarn start
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor. Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Collaboration
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
#### Commands #### Commands
##### 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;
}
} }
} }
} }
+15 -15
View File
@@ -21,24 +21,23 @@
"dependencies": { "dependencies": {
"@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.15.1",
"@testing-library/react": "12.1.2", "@testing-library/react": "12.1.2",
"@tldraw/vec": "1.4.3", "@tldraw/vec": "1.1.5",
"@types/jest": "27.4.0", "@types/jest": "27.0.3",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "17.0.39", "@types/react": "17.0.37",
"@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.23.0",
"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.2", "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.30",
"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,30 +50,31 @@
"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.49.7", "sass": "1.43.5",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.5.5" "typescript": "4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/chai": "4.2.22",
"@types/lodash.throttle": "4.1.6", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3", "@types/pako": "1.0.2",
"@types/resize-observer-browser": "0.1.6", "@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6", "chai": "4.3.4",
"dotenv": "10.0.0", "dotenv": "10.0.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.23.0",
"husky": "7.0.4", "husky": "7.0.4",
"jest-canvas-mock": "2.3.1", "jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.7", "lint-staged": "12.1.2",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.5.1", "prettier": "2.5.0",
"rewire": "5.0.0" "rewire": "5.0.0"
}, },
"resolutions": { "resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2" "@typescript-eslint/typescript-estree": "5.3.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
+32 -4
View File
@@ -72,6 +72,12 @@
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
rel="preconnect"
crossorigin="anonymous"
/>
<link <link
rel="manifest" rel="manifest"
href="manifest.json" href="manifest.json"
@@ -124,6 +130,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;
@@ -132,10 +158,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;
@@ -152,6 +176,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>
+5 -30
View File
@@ -1,6 +1,5 @@
const fs = require("fs"); const fs = require("fs");
const { exec, execSync } = require("child_process"); const { exec, execSync } = require("child_process");
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`;
@@ -16,25 +15,18 @@ const publish = () => {
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`); execSync(`yarn --cwd ${excalidrawDir} publish`);
console.info("Published 🎉");
core.setOutput(
"result",
`**Preview version has been shipped** :rocket:
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:!");
console.error(error); console.error(error);
process.exit(1);
} }
}; };
// get files changed between prev and head commit // get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => { exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) { if (error || stderr) {
console.error(error); console.error(error);
core.setOutput("result", ":warning: Package couldn't be published!");
process.exit(1); process.exit(1);
} }
const changedFiles = stdout.trim().split("\n"); const changedFiles = stdout.trim().split("\n");
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/; const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
@@ -45,33 +37,16 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
); );
}); });
if (!excalidrawPackageFiles.length) { if (!excalidrawPackageFiles.length) {
console.info("Skipping release as no valid diff found");
core.setOutput("result", "Skipping release as no valid diff found");
process.exit(0); process.exit(0);
} }
// update package.json // update package.json
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
pkg.name = "@excalidraw/excalidraw-next"; pkg.name = "@excalidraw/excalidraw-next";
let version = `${pkg.version}-${getShortCommitHash()}`;
// update readme
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
const isPreview = process.argv.slice(2)[0] === "preview";
if (isPreview) {
// use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0];
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;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8"); fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
// update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8"); fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
console.info("Publish in progress...");
publish(); publish();
}); });
-5
View File
@@ -11,7 +11,6 @@ const crowdinMap = {
"de-DE": "en-de", "de-DE": "en-de",
"el-GR": "en-el", "el-GR": "en-el",
"es-ES": "en-es", "es-ES": "en-es",
"eu-ES": "en-eu",
"fa-IR": "en-fa", "fa-IR": "en-fa",
"fi-FI": "en-fi", "fi-FI": "en-fi",
"fr-FR": "en-fr", "fr-FR": "en-fr",
@@ -43,7 +42,6 @@ const crowdinMap = {
"zh-CN": "en-zhcn", "zh-CN": "en-zhcn",
"zh-HK": "en-zhhk", "zh-HK": "en-zhhk",
"zh-TW": "en-zhtw", "zh-TW": "en-zhtw",
"lt-LT": "en-lt",
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "en-cs",
"kk-KZ": "en-kk", "kk-KZ": "en-kk",
@@ -71,7 +69,6 @@ const flags = {
"kab-KAB": "🏳", "kab-KAB": "🏳",
"kk-KZ": "🇰🇿", "kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷", "ko-KR": "🇰🇷",
"lt-LT": "🇱🇹",
"lv-LV": "🇱🇻", "lv-LV": "🇱🇻",
"my-MM": "🇲🇲", "my-MM": "🇲🇲",
"nb-NO": "🇳🇴", "nb-NO": "🇳🇴",
@@ -105,7 +102,6 @@ const languages = {
"de-DE": "Deutsch", "de-DE": "Deutsch",
"el-GR": "Ελληνικά", "el-GR": "Ελληνικά",
"es-ES": "Español", "es-ES": "Español",
"eu-ES": "Euskara",
"fa-IR": "فارسی", "fa-IR": "فارسی",
"fi-FI": "Suomi", "fi-FI": "Suomi",
"fr-FR": "Français", "fr-FR": "Français",
@@ -118,7 +114,6 @@ const languages = {
"kab-KAB": "Taqbaylit", "kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі", "kk-KZ": "Қазақ тілі",
"ko-KR": "한국어", "ko-KR": "한국어",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu", "lv-LV": "Latviešu",
"my-MM": "Burmese", "my-MM": "Burmese",
"nb-NO": "Norsk bokmål", "nb-NO": "Norsk bokmål",
+11 -18
View File
@@ -20,7 +20,7 @@ const headerForType = {
perf: "Performance", perf: "Performance",
build: "Build", build: "Build",
}; };
const badCommits = [];
const getCommitHashForLastVersion = async () => { const getCommitHashForLastVersion = async () => {
try { try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`; const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
@@ -53,26 +53,19 @@ const getLibraryCommitsSinceLastRelease = async () => {
const messageWithoutType = commit.slice(indexOfColon + 1).trim(); const messageWithoutType = commit.slice(indexOfColon + 1).trim();
const messageWithCapitalizeFirst = const messageWithCapitalizeFirst =
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1); messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
const prMatch = commit.match(/\(#([0-9]*)\)/); const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
if (prMatch) {
const prNumber = prMatch[1];
// return if the changelog already contains the pr number which would happen for package updates // return if the changelog already contains the pr number which would happen for package updates
if (existingChangeLog.includes(prNumber)) { if (existingChangeLog.includes(prNumber)) {
return; return;
}
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
} else {
badCommits.push(commit);
commitList[type].push(messageWithCapitalizeFirst);
} }
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
}); });
console.info("Bad commits:", badCommits);
return commitList; return commitList;
}; };
+2 -3
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",
-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,
};
},
});
+37 -111
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";
@@ -9,26 +9,24 @@ import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene"; import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { 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 },
commitToHistory: !!value.viewBackgroundColor, commitToHistory: !!value.viewBackgroundColor,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ appState, updateData }) => {
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<ColorPicker <ColorPicker
@@ -41,8 +39,6 @@ export const actionChangeViewBackgroundColor = register({
updateData({ openPopup: active ? "canvasColorPicker" : null }) updateData({ openPopup: active ? "canvasColorPicker" : null })
} }
data-testid="canvas-background-picker" data-testid="canvas-background-picker"
elements={elements}
appState={appState}
/> />
</div> </div>
); );
@@ -51,7 +47,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,17 +57,12 @@ export const actionClearCanvas = register({
...getDefaultAppState(), ...getDefaultAppState(),
files: {}, files: {},
theme: appState.theme, theme: appState.theme,
penMode: appState.penMode, elementLocked: appState.elementLocked,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
showStats: appState.showStats, showStats: appState.showStats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -83,19 +73,17 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
trackEvent: { category: "canvas" }, perform: (_elements, appState) => {
perform: (_elements, appState, _, app) => { const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
return { return {
appState: { appState: {
...appState, ...appState,
...getStateForZoom( zoom,
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -119,19 +107,18 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
trackEvent: { category: "canvas" }, perform: (_elements, appState) => {
perform: (_elements, appState, _, app) => { const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
return { return {
appState: { appState: {
...appState, ...appState,
...getStateForZoom( zoom,
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -155,18 +142,18 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
trackEvent: { category: "canvas" }, perform: (_elements, appState) => {
perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
...appState, ...appState,
...getStateForZoom( zoom: getNewZoom(
1 as NormalizedZoomValue,
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ {
viewportX: appState.width / 2 + appState.offsetLeft, x: appState.width / 2,
viewportY: appState.height / 2 + appState.offsetTop, y: appState.height / 2,
nextZoom: getNormalizedZoom(1),
}, },
appState,
), ),
}, },
commitToHistory: false, commitToHistory: false,
@@ -225,12 +212,14 @@ const zoomToFitElements = (
? getCommonBounds(selectedElements) ? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements); : getCommonBounds(nonDeletedElements);
const newZoom = { const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
value: zoomValueToFitBoundsOnViewport(commonBounds, { width: appState.width,
width: appState.width, height: appState.height,
height: appState.height, });
}), const newZoom = getNewZoom(zoomValue, appState.zoom, {
}; left: appState.offsetLeft,
top: appState.offsetTop,
});
const [x1, y1, x2, y2] = commonBounds; const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
@@ -254,7 +243,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 +253,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 +263,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,63 +285,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) => {
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.type !== "eraser") {
if (appState.activeTool.type === "custom") {
activeTool.lastActiveToolBeforeEraser = {
type: "custom",
customType: appState.activeTool.customType,
};
} else {
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
}
}
if (isEraserActive(appState)) {
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
) {
activeTool.type = "custom";
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
} else {
activeTool.type = "eraser";
}
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>
),
});
+4 -46
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,10 +23,9 @@ 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, data, app);
}, },
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
@@ -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 {
@@ -132,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 -2
View File
@@ -58,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 {
@@ -134,7 +133,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: { ...appState.activeTool, type: "selection" }, elementType: "selection",
multiElement: null, multiElement: null,
}, },
commitToHistory: isSomeElementSelected( commitToHistory: isSomeElementSelected(
+3 -7
View File
@@ -7,7 +7,7 @@ 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";
import { CODES, KEYS } from "../keys"; import { CODES } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
@@ -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,
@@ -50,8 +49,7 @@ export const distributeHorizontally = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.altKey && event.code === CODES.H,
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
@@ -69,7 +67,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,
@@ -80,8 +77,7 @@ export const distributeVertically = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.altKey && event.code === CODES.V,
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
-1
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) {
+5 -11
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 { useDeviceType } 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;
@@ -175,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(
@@ -204,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={useDeviceType().isMobile} showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
@@ -214,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 {
@@ -248,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={useDeviceType().isMobile} showAriaLabel={useIsMobile()}
onClick={updateData} onClick={updateData}
data-testid="load-button" data-testid="load-button"
/> />
@@ -257,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 },
+63 -35
View File
@@ -14,11 +14,60 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "../element/binding"; } from "../element/binding";
import { isBindingElement } from "../element/typeChecks"; import { isBindingElement } from "../element/typeChecks";
import { ExcalidrawImageElement } from "../element/types";
import { imageFromImageData } from "../element/image";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
trackEvent: false, perform: (
perform: (elements, appState, _, { canvas, focusContainer }) => { elements,
appState,
_,
{ canvas, focusContainer, imageCache, addFiles },
) => {
if (appState.editingImageElement) {
const { elementId, imageData } = appState.editingImageElement;
const editingImageElement = elements.find((el) => el.id === elementId) as
| ExcalidrawImageElement
| undefined;
if (editingImageElement?.fileId) {
const cachedImageData = imageCache.get(editingImageElement.fileId);
if (cachedImageData) {
const { image, dataURL } = imageFromImageData(imageData);
imageCache.set(editingImageElement.fileId, {
...cachedImageData,
image,
});
addFiles([
{
id: editingImageElement.fileId,
dataURL,
mimeType: cachedImageData.mimeType,
created: Date.now(),
},
]);
return {
appState: {
...appState,
editingImageElement: null,
},
commitToHistory: false,
};
}
}
return {
appState: {
...appState,
editingImageElement: null,
},
commitToHistory: false,
};
}
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
@@ -39,7 +88,6 @@ export const actionFinalize = register({
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
}, },
commitToHistory: true, commitToHistory: true,
@@ -121,47 +169,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);
} }
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
) {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.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,
@@ -169,8 +197,8 @@ 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,
@@ -178,16 +206,17 @@ export const actionFinalize = register({
: appState.selectedElementIds, : appState.selectedElementIds,
pendingImageElement: null, pendingImageElement: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.elementType === "freedraw",
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || (appState.editingLinearElement !== null ||
appState.editingImageElement !== null ||
(!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}
@@ -195,7 +224,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"}
/> />
), ),
}); });
+1 -3
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"),
@@ -157,7 +155,7 @@ const flipElement = (
// calculate new x-coord for transformation // calculate new x-coord for transformation
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width; newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
resizeSingleElement( resizeSingleElement(
new Map().set(element.id, element), element,
true, true,
element, element,
usingNWHandle ? "nw" : "ne", usingNWHandle ? "nw" : "ne",
+5 -21
View File
@@ -17,9 +17,8 @@ import {
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { randomId } from "../random"; import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@@ -54,7 +53,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,18 +146,12 @@ 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) {
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => { const nextElements = elements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
const nextGroupIds = removeFromSelectedGroups( const nextGroupIds = removeFromSelectedGroups(
element.groupIds, element.groupIds,
appState.selectedGroupIds, appState.selectedGroupIds,
@@ -171,19 +163,11 @@ export const actionUngroup = register({
groupIds: nextGroupIds, groupIds: nextGroupIds,
}); });
}); });
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
);
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return { return {
appState: updateAppState, appState: selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
elements: nextElements, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };
-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) =>
+75
View File
@@ -0,0 +1,75 @@
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { backgroundIcon } from "../components/icons";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import { isInitializedImageElement } from "../element/typeChecks";
import Scene from "../scene/Scene";
export const actionEditImageAlpha = register({
name: "editImageAlpha",
perform: async (elements, appState, _, app) => {
if (appState.editingImageElement) {
return {
appState: {
...appState,
editingImageElement: null,
},
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(elements, appState);
const selectedElement = selectedElements[0];
if (
selectedElements.length === 1 &&
isInitializedImageElement(selectedElement)
) {
const imgData = app.imageCache.get(selectedElement.fileId);
if (!imgData) {
return false;
}
const image = await imgData.image;
const { width, height } = image;
const canvas = document.createElement("canvas");
canvas.height = height;
canvas.width = width;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
Scene.mapElementToScene(selectedElement.id, app.scene);
return {
appState: {
...appState,
editingImageElement: {
editorType: "alpha",
elementId: selectedElement.id,
origImageData: imageData,
imageData,
pointerDownState: { screenX: 0, screenY: 0, sampledPixel: null },
},
},
commitToHistory: false,
};
}
return false;
},
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
icon={backgroundIcon}
label="Edit Image Alpha"
className={appState.editingImageElement ? "active" : ""}
title={"Edit image alpha"}
aria-label={"Edit image alpha"}
onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
-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();
+5 -5
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) {
@@ -43,15 +42,16 @@ export const actionGoToCollaborator = register({
} }
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.src} {shortName}
/> </Avatar>
); );
}, },
}); });
+100 -336
View File
@@ -30,31 +30,19 @@ import {
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignRightIcon, TextAlignRightIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
getNonDeletedElements, getNonDeletedElements,
isTextElement, isTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { import { isLinearElement, isLinearElementType } from "../element/typeChecks";
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
isBoundToContainer,
isLinearElement,
isLinearElementType,
} from "../element/typeChecks";
import { import {
Arrowhead, Arrowhead,
ExcalidrawElement, ExcalidrawElement,
@@ -62,37 +50,27 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements,
getTargetElements, getTargetElements,
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
callback: (element: ExcalidrawElement) => ExcalidrawElement, callback: (element: ExcalidrawElement) => ExcalidrawElement,
includeBoundText = false,
) => { ) => {
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText),
);
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || appState.selectedElementIds[element.id] ||
element.id === appState.editingElement?.id element.id === appState.editingElement?.id
) { ) {
return callback(element); return callback(element);
@@ -122,94 +100,18 @@ const getFormValue = function <T>(
); );
}; };
const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
) => {
if (isBoundToContainer(nextElement)) {
return nextElement;
}
return mutateElement(
nextElement,
{
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
};
const changeFontSize = (
elements: readonly ExcalidrawElement[],
appState: AppState,
getNewFontSize: (element: ExcalidrawTextElement) => number,
fallbackValue?: ExcalidrawTextElement["fontSize"],
) => {
const newFontSizes = new Set<number>();
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
// update state only if we've set all select text elements to
// the same font size
currentItemFontSize:
newFontSizes.size === 1
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
};
};
// -----------------------------------------------------------------------------
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 && {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, return hasStrokeColor(el.type)
appState, ? newElementWith(el, {
(el) => { strokeColor: value.currentItemStrokeColor,
return hasStrokeColor(el.type) })
? newElementWith(el, { : el;
strokeColor: value.currentItemStrokeColor, }),
})
: el;
},
true,
),
}), }),
appState: { appState: {
...appState, ...appState,
@@ -235,8 +137,6 @@ export const actionChangeStrokeColor = register({
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null }) updateData({ openPopup: active ? "strokeColorPicker" : null })
} }
elements={elements}
appState={appState}
/> />
</> </>
), ),
@@ -244,7 +144,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 && {
@@ -278,8 +177,6 @@ export const actionChangeBackgroundColor = register({
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null }) updateData({ openPopup: active ? "backgroundColorPicker" : null })
} }
elements={elements}
appState={appState}
/> />
</> </>
), ),
@@ -287,7 +184,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 +233,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 +280,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 +328,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,7 +375,6 @@ 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, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -503,6 +395,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,
@@ -518,9 +424,25 @@ 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 {
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontSize: value,
});
redrawTextBoundingBox(element);
return element;
}
return el;
}),
appState: {
...appState,
currentItemFontSize: value,
},
commitToHistory: true,
};
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
@@ -532,40 +454,27 @@ export const actionChangeFontSize = register({
value: 16, value: 16,
text: t("labels.small"), text: t("labels.small"),
icon: <FontSizeSmallIcon theme={appState.theme} />, icon: <FontSizeSmallIcon theme={appState.theme} />,
testId: "fontSize-small",
}, },
{ {
value: 20, value: 20,
text: t("labels.medium"), text: t("labels.medium"),
icon: <FontSizeMediumIcon theme={appState.theme} />, icon: <FontSizeMediumIcon theme={appState.theme} />,
testId: "fontSize-medium",
}, },
{ {
value: 28, value: 28,
text: t("labels.large"), text: t("labels.large"),
icon: <FontSizeLargeIcon theme={appState.theme} />, icon: <FontSizeLargeIcon theme={appState.theme} />,
testId: "fontSize-large",
}, },
{ {
value: 36, value: 36,
text: t("labels.veryLarge"), text: t("labels.veryLarge"),
icon: <FontSizeExtraLargeIcon theme={appState.theme} />, icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
testId: "fontSize-veryLarge",
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.fontSize,
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
appState.currentItemFontSize || DEFAULT_FONT_SIZE, appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -574,70 +483,21 @@ export const actionChangeFontSize = register({
), ),
}); });
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
// get previous value before relative increase (doesn't work fully
// due to rounding and float precision issues)
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.COMMA needed for MacOS
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
);
},
});
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.PERIOD needed for MacOS
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
);
},
});
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, if (isTextElement(el)) {
appState, const element: ExcalidrawTextElement = newElementWith(el, {
(oldElement) => { fontFamily: value,
if (isTextElement(oldElement)) { });
const newElement: ExcalidrawTextElement = newElementWith( redrawTextBoundingBox(element);
oldElement, return element;
{ }
fontFamily: value,
},
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemFontFamily: value, currentItemFontFamily: value,
@@ -677,16 +537,7 @@ export const actionChangeFontFamily = register({
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.fontFamily,
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -698,26 +549,19 @@ 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(elements, appState, (el) => {
elements, if (isTextElement(el)) {
appState, const element: ExcalidrawTextElement = newElementWith(el, {
(oldElement) => { textAlign: value,
if (isTextElement(oldElement)) { });
const newElement: ExcalidrawTextElement = newElementWith( redrawTextBoundingBox(element);
oldElement, return element;
{ textAlign: value }, }
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemTextAlign: value, currentItemTextAlign: value,
@@ -725,121 +569,42 @@ export const actionChangeTextAlign = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => (
return ( <fieldset>
<fieldset> <legend>{t("labels.textAlign")}</legend>
<legend>{t("labels.textAlign")}</legend> <ButtonIconSelect<TextAlign | false>
<ButtonIconSelect<TextAlign | false> group="text-align"
group="text-align" options={[
options={[ {
{ value: "left",
value: "left", text: t("labels.left"),
text: t("labels.left"), icon: <TextAlignLeftIcon theme={appState.theme} />,
icon: <TextAlignLeftIcon theme={appState.theme} />, },
}, {
{ value: "center",
value: "center", text: t("labels.center"),
text: t("labels.center"), icon: <TextAlignCenterIcon theme={appState.theme} />,
icon: <TextAlignCenterIcon theme={appState.theme} />, },
}, {
{ value: "right",
value: "right", text: t("labels.right"),
text: t("labels.right"), icon: <TextAlignRightIcon theme={appState.theme} />,
icon: <TextAlignRightIcon theme={appState.theme} />, },
}, ]}
]} value={getFormValue(
value={getFormValue( elements,
elements, appState,
appState, (element) => isTextElement(element) && element.textAlign,
(element) => { appState.currentItemTextAlign,
if (isTextElement(element)) { )}
return element.textAlign; onChange={(value) => updateData(value)}
} />
const boundTextElement = getBoundTextElement(element); </fieldset>
if (boundTextElement) { ),
return boundTextElement.textAlign;
}
return null;
},
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{ verticalAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<ButtonIconSelect<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
},
]}
value={getFormValue(elements, appState, (element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
})}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
}); });
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),
@@ -847,10 +612,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, {
@@ -890,8 +655,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,
@@ -904,7 +669,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,
+1 -3
View File
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
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;
@@ -18,8 +17,7 @@ export const actionSelectAll = register({
selectedElementIds: elements.reduce((map, element) => { selectedElementIds: elements.reduce((map, element) => {
if ( if (
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId)
element.locked === false
) { ) {
map[element.id] = true; map[element.id] = true;
} }
-71
View File
@@ -1,71 +0,0 @@
import ExcalidrawApp from "../excalidraw-app";
import { t } from "../i18n";
import { CODES } from "../keys";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { fireEvent, render, screen } from "../tests/test-utils";
import { copiedStyles } from "./actionStyles";
const { h } = window;
const mouse = new Pointer("mouse");
describe("actionStyles", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("should copy & paste styles via keyboard", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
// Change some styles of second rectangle
UI.clickLabeledElement("Stroke");
UI.clickLabeledElement(t("colors.c92a2a"));
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.e64980"));
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
fireEvent.click(screen.getByTitle("Bold"));
// Stroke style
fireEvent.click(screen.getByTitle("Dotted"));
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" },
});
mouse.reset();
API.setSelectedElements([h.elements[1]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C);
});
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
API.setSelectedElements([h.elements[0]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.V);
});
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
});
+2 -6
View File
@@ -12,14 +12,12 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getContainerElement } from "../element/textElement";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
export const actionCopyStyles = register({ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const element = elements.find((el) => appState.selectedElementIds[el.id]); const element = elements.find((el) => appState.selectedElementIds[el.id]);
if (element) { if (element) {
@@ -40,7 +38,6 @@ 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 pastedElement = JSON.parse(copiedStyles); const pastedElement = JSON.parse(copiedStyles);
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
@@ -58,14 +55,13 @@ export const actionPasteStyles = register({
opacity: pastedElement?.opacity, opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness, roughness: pastedElement?.roughness,
}); });
if (isTextElement(newElement) && isTextElement(element)) { if (isTextElement(newElement)) {
mutateElement(newElement, { mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE, fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY, fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
redrawTextBoundingBox(newElement);
redrawTextBoundingBox(newElement, getContainerElement(newElement));
} }
return newElement; return newElement;
} }
+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,
-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 -5
View File
@@ -17,7 +17,6 @@ export {
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -75,13 +74,10 @@ 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 { actionEditImageAlpha } from "./actionImageEditing";
export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";
+21 -59
View File
@@ -1,47 +1,18 @@
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";
import { MODES } from "../constants"; import { MODES } from "../constants";
import { trackEvent } from "../analytics";
const trackAction = ( export class ActionManager implements ActionsManagerInterface {
action: Action, actions = {} as ActionsManagerInterface["actions"];
source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any,
) => {
if (action.trackEvent) {
try {
if (typeof action.trackEvent === "object") {
const shouldTrack = action.trackEvent.predicate
? action.trackEvent.predicate(appState, elements, value)
: true;
if (shouldTrack) {
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
);
}
}
} catch (error) {
console.error("error while logging action:", error);
}
}
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -94,15 +65,9 @@ export class ActionManager {
), ),
); );
if (data.length !== 1) { if (data.length === 0) {
if (data.length > 1) {
console.warn("Canceling as multiple actions match this shortcut", data);
}
return false; return false;
} }
const action = data[0];
const { viewModeEnabled } = this.getAppState(); const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) { if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) { if (!Object.values(MODES).includes(data[0].name)) {
@@ -110,26 +75,27 @@ export class ActionManager {
} }
} }
const elements = this.getElementsIncludingDeleted();
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)); ),
);
} }
/** /**
@@ -147,11 +113,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(),
+2 -4
View File
@@ -2,9 +2,7 @@ import { Action } from "./types";
export let actions: readonly Action[] = []; export let actions: readonly Action[] = [];
export const register = <T extends Action>(action: T) => { export const register = (action: Action): Action => {
actions = actions.concat(action); actions = actions.concat(action);
return action as T & { return action;
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
};
}; };
+3 -10
View File
@@ -1,10 +1,8 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin } from "../keys"; import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
export type ShortcutName = SubtypeOf< export type ShortcutName =
ActionName,
| "cut" | "cut"
| "copy" | "copy"
| "paste" | "paste"
@@ -27,10 +25,7 @@ export type ShortcutName = SubtypeOf<
| "addToLibrary" | "addToLibrary"
| "viewMode" | "viewMode"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical";
| "hyperlink"
| "toggleLock"
>;
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -67,12 +62,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipHorizontal: [getShortcutKey("Shift+H")], flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name]; const shortcuts = shortcutMap[name];
// if multiple shortcuts available, take the first one // if multiple shortcuts availiable, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
}; };
+10 -36
View File
@@ -8,8 +8,6 @@ import {
} from "../types"; } from "../types";
import { ToolButtonSize } from "../components/ToolButton"; 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 =
| { | {
@@ -41,7 +39,6 @@ export type ActionName =
| "paste" | "paste"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg" | "copyAsSvg"
| "copyText"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
| "sendToBack" | "sendToBack"
@@ -85,7 +82,6 @@ export type ActionName =
| "zoomToSelection" | "zoomToSelection"
| "changeFontFamily" | "changeFontFamily"
| "changeTextAlign" | "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen" | "toggleFullScreen"
| "toggleShortcuts" | "toggleShortcuts"
| "group" | "group"
@@ -106,13 +102,7 @@ export type ActionName =
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
| "increaseFontSize" | "editImageAlpha";
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -132,34 +122,18 @@ export interface Action {
appState: AppState, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => boolean; ) => boolean;
contextItemLabel?: contextItemLabel?: string;
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
) => string);
contextItemPredicate?: ( contextItemPredicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: }
| false
| { export interface ActionsManagerInterface {
category: actions: Record<ActionName, Action>;
| "toolbar" registerAction: (action: Action) => void;
| "element" handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
| "canvas" renderAction: (name: ActionName) => React.ReactElement | null;
| "export" executeAction: (action: Action) => void;
| "history"
| "menu"
| "collab"
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
} }
+22 -1
View File
@@ -1,7 +1,6 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds"; import { Box, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -31,6 +30,28 @@ export const alignElements = (
}); });
}; };
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const calculateTranslation = ( const calculateTranslation = (
group: ExcalidrawElement[], group: ExcalidrawElement[],
selectionBoundingBox: Box, selectionBoundingBox: Box,
+9 -13
View File
@@ -3,20 +3,16 @@ export const trackEvent =
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" && typeof window !== "undefined" &&
window.gtag window.gtag
? (category: string, action: string, label?: string, value?: number) => { ? (category: string, name: string, label?: string, value?: number) => {
try { window.gtag("event", name, {
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, name: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => { : (category: string, name: 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, name, label, value);
}; };
+7 -21
View File
@@ -41,13 +41,9 @@ export const getDefaultAppState = (): Omit<
editingElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
activeTool: { editingImageElement: null,
type: "selection", elementLocked: false,
locked: false, elementType: "selection",
lastActiveToolBeforeEraser: null,
},
penMode: false,
penDetected: false,
errorMessage: null, errorMessage: null,
exportBackground: true, exportBackground: true,
exportScale: defaultExportScale, exportScale: defaultExportScale,
@@ -82,12 +78,9 @@ export const getDefaultAppState = (): Omit<
toastMessage: null, toastMessage: null,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElement: null, pendingImageElement: null,
showHyperlinkPopup: false,
}; };
}; };
@@ -133,9 +126,9 @@ 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 }, editingImageElement: { browser: false, export: false, server: false },
penMode: { browser: true, export: false, server: false }, elementLocked: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, elementType: { browser: true, 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 },
@@ -177,7 +170,6 @@ const APP_STATE_STORAGE_CONF = (<
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 },
pendingImageElement: { browser: false, export: false, server: false }, pendingImageElement: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
@@ -215,9 +207,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";
+3 -9
View File
@@ -1,10 +1,5 @@
import colors from "./colors"; import colors from "./colors";
import { import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types"; import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
@@ -108,7 +103,7 @@ const transposeCells = (cells: string[][]) => {
}; };
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadsheets, tsv, csv. // Copy/paste from excel, spreadhseets, tsv, csv.
// For now we only accept 2 columns with an optional header // For now we only accept 2 columns with an optional header
// Check for tab separated values // Check for tab separated values
@@ -166,8 +161,7 @@ const commonProps = {
strokeSharpness: "sharp", strokeSharpness: "sharp",
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: "middle",
locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {
+16 -42
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;
@@ -125,7 +124,7 @@ const getSystemClipboard = async (
}; };
/** /**
* Attempts to parse clipboard. Prefers system clipboard. * Attemps to parse clipboard. Prefers system clipboard.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
@@ -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) => {
+31 -59
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 { useDeviceType } from "../components/App"; import { useIsMobile } from "../components/App";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
@@ -19,50 +19,37 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { isImageElement } 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),
appState, appState,
); );
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement); const isEditing = Boolean(appState.editingElement);
const deviceType = useDeviceType(); 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) {
@@ -74,23 +61,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")}
@@ -98,12 +85,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")}
@@ -114,15 +101,18 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{targetElements.some( {(canHaveArrowheads(elementType) ||
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
<fieldset>
<div className="buttonList">
{targetElements.some((element) => isImageElement(element)) &&
renderAction("editImageAlpha")}
</div>
</fieldset>
{renderAction("changeOpacity")} {renderAction("changeOpacity")}
<fieldset> <fieldset>
@@ -135,7 +125,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
{targetElements.length > 1 && !isSingleElementBoundContainer && ( {targetElements.length > 1 && (
<fieldset> <fieldset>
<legend>{t("labels.align")}</legend> <legend>{t("labels.align")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -168,15 +158,14 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
)} )}
{!isEditing && targetElements.length > 0 && ( {!isMobile && !isEditing && targetElements.length > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
{!deviceType.isMobile && renderAction("duplicateSelection")} {renderAction("duplicateSelection")}
{!deviceType.isMobile && renderAction("deleteSelectedElements")} {renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
</div> </div>
</fieldset> </fieldset>
)} )}
@@ -186,16 +175,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) => {
@@ -210,35 +197,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 = { ...activeTool, 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 });
} }
+350 -1074
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%;
}
} }
} }
+10 -18
View File
@@ -1,28 +1,20 @@
import "./Avatar.scss"; import "./Avatar.scss";
import React 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 style = src className="Avatar"
? undefined style={{ background: color, border: `1px solid ${border}` }}
: { background: color, border: `1px solid ${border}` }; onClick={onClick}
return ( >
<div className="Avatar" style={style} onClick={onClick}> {children}
{src ? ( </div>
<img className="Avatar-img" src={src} alt={shortName} /> );
) : (
shortName
)}
</div>
);
};
+1 -2
View File
@@ -7,7 +7,7 @@ export const ButtonIconSelect = <T extends Object>({
onChange, onChange,
group, group,
}: { }: {
options: { value: T; text: string; icon: JSX.Element; testId?: string }[]; options: { value: T; text: string; icon: JSX.Element }[];
value: T | null; value: T | null;
onChange: (value: T) => void; onChange: (value: T) => void;
group: string; group: string;
@@ -24,7 +24,6 @@ export const ButtonIconSelect = <T extends Object>({
name={group} name={group}
onChange={() => onChange(option.value)} onChange={() => onChange(option.value)}
checked={value === option.value} checked={value === option.value}
data-testid={option.testId}
/> />
{option.icon} {option.icon}
</label> </label>
+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 { useDeviceType } 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={useDeviceType().isMobile} showAriaLabel={useIsMobile()}
onClick={toggleDialog} onClick={toggleDialog}
data-testid="clear-canvas-button" data-testid="clear-canvas-button"
/> />
+2 -2
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 { useDeviceType } from "../components/App"; import { useIsMobile } from "../components/App";
import { users } from "./icons"; import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
@@ -26,7 +26,7 @@ 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={useDeviceType().isMobile} showAriaLabel={useIsMobile()}
> >
{collaboratorCount > 0 && ( {collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div> <div className="CollabButton-collaborators">{collaboratorCount}</div>
+1 -21
View File
@@ -46,7 +46,7 @@
top: -11px; top: -11px;
} }
.color-picker-content--default { .color-picker-content {
padding: 0.5rem; padding: 0.5rem;
display: grid; display: grid;
grid-template-columns: repeat(5, auto); grid-template-columns: repeat(5, auto);
@@ -59,26 +59,6 @@
} }
} }
.color-picker-content--canvas {
display: flex;
flex-direction: column;
padding: 0.25rem;
&-title {
color: $oc-gray-6;
font-size: 12px;
padding: 0 0.25rem;
}
&-colors {
padding: 0.5rem 0;
.color-picker-swatch {
margin: 0 0.25rem;
}
}
}
.color-picker-content .color-input-container { .color-picker-content .color-input-container {
grid-column: 1 / span 5; grid-column: 1 / span 5;
} }
+42 -154
View File
@@ -7,53 +7,6 @@ import { isArrowKey, KEYS } from "../keys";
import { t, getLanguage } from "../i18n"; import { t, getLanguage } from "../i18n";
import { isWritableElement } from "../utils"; import { isWritableElement } from "../utils";
import colors from "../colors"; import colors from "../colors";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
const MAX_CUSTOM_COLORS = 5;
const MAX_DEFAULT_COLORS = 15;
export const getCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
) => {
const customColors: string[] = [];
const updatedElements = elements
.filter((element) => !element.isDeleted)
.sort((ele1, ele2) => ele2.updated - ele1.updated);
let index = 0;
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colorType = elementColorTypeMap[type] as
| "backgroundColor"
| "strokeColor";
while (
index < updatedElements.length &&
customColors.length < MAX_CUSTOM_COLORS
) {
const element = updatedElements[index];
if (
customColors.length < MAX_CUSTOM_COLORS &&
isCustomColor(element[colorType], type) &&
!customColors.includes(element[colorType])
) {
customColors.push(element[colorType]);
}
index++;
}
return customColors;
};
const isCustomColor = (
color: string,
type: "elementBackground" | "elementStroke",
) => {
return !colors[type].includes(color);
};
const isValidColor = (color: string) => { const isValidColor = (color: string) => {
const style = new Option().style; const style = new Option().style;
@@ -82,7 +35,6 @@ const keyBindings = [
["1", "2", "3", "4", "5"], ["1", "2", "3", "4", "5"],
["q", "w", "e", "r", "t"], ["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"], ["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat(); ].flat();
const Picker = ({ const Picker = ({
@@ -93,7 +45,6 @@ const Picker = ({
label, label,
showInput = true, showInput = true,
type, type,
elements,
}: { }: {
colors: string[]; colors: string[];
color: string | null; color: string | null;
@@ -102,20 +53,12 @@ const Picker = ({
label: string; label: string;
showInput: boolean; showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke"; type: "canvasBackground" | "elementBackground" | "elementStroke";
elements: readonly ExcalidrawElement[];
}) => { }) => {
const firstItem = React.useRef<HTMLButtonElement>(); const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>(); const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>(); const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>(); const colorInput = React.useRef<HTMLInputElement>();
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getCustomColors(elements, type);
});
React.useEffect(() => { React.useEffect(() => {
// After the component is first mounted focus on first input // After the component is first mounted focus on first input
if (activeItem.current) { if (activeItem.current) {
@@ -142,42 +85,23 @@ const Picker = ({
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
const { activeElement } = document; const { activeElement } = document;
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
let isCustom = false; const index = Array.prototype.indexOf.call(
let index = Array.prototype.indexOf.call( gallery!.current!.children,
gallery!.current!.querySelector(".color-picker-content--default")!
.children,
activeElement, activeElement,
); );
if (index === -1) {
index = Array.prototype.indexOf.call(
gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!.children,
activeElement,
);
if (index !== -1) {
isCustom = true;
}
}
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
if (index !== -1) { if (index !== -1) {
const length = parentSelector!.children.length - (showInput ? 1 : 0); const length = gallery!.current!.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
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT) : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
? (length + index - 1) % length ? (length + index - 1) % length
: !isCustom && event.key === KEYS.ARROW_DOWN : event.key === KEYS.ARROW_DOWN
? (index + 5) % length ? (index + 5) % length
: !isCustom && event.key === KEYS.ARROW_UP : event.key === KEYS.ARROW_UP
? (length + index - 5) % length ? (length + index - 5) % length
: index; : index;
(parentSelector!.children![nextIndex] as HTMLElement)?.focus(); (gallery!.current!.children![nextIndex] as any).focus();
} }
event.preventDefault(); event.preventDefault();
} else if ( } else if (
@@ -185,15 +109,7 @@ const Picker = ({
!isWritableElement(event.target) !isWritableElement(event.target)
) { ) {
const index = keyBindings.indexOf(event.key.toLowerCase()); const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS; (gallery!.current!.children![index] as any).focus();
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
(parentSelector!.children![actualIndex] as HTMLElement)?.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) {
event.preventDefault(); event.preventDefault();
@@ -203,50 +119,6 @@ const Picker = ({
event.stopPropagation(); event.stopPropagation();
}; };
const renderColors = (colors: Array<string>, custom: boolean = false) => {
return colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
const keyBinding = custom
? keyBindings[i + MAX_DEFAULT_COLORS]
: keyBindings[i];
const label = custom
? _colorWithoutHash
: t(`colors.${_colorWithoutHash}`);
return (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${label}${
!isTransparent(_color) ? ` (${_color})` : ""
}${keyBinding.toUpperCase()}`}
aria-label={label}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
ref={(el) => {
if (!custom && el && i === 0) {
firstItem.current = el;
}
if (el && _color === color) {
activeItem.current = el;
}
}}
onFocus={() => {
onChange(_color);
}}
>
{isTransparent(_color) ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBinding}</span>
</button>
);
});
};
return ( return (
<div <div
className={`color-picker color-picker-type-${type}`} className={`color-picker color-picker-type-${type}`}
@@ -266,20 +138,41 @@ const Picker = ({
}} }}
tabIndex={0} tabIndex={0}
> >
<div className="color-picker-content--default"> {colors.map((_color, i) => {
{renderColors(colors)} const _colorWithoutHash = _color.replace("#", "");
</div> return (
{!!customColors.length && ( <button
<div className="color-picker-content--canvas"> className="color-picker-swatch"
<span className="color-picker-content--canvas-title"> onClick={(event) => {
{t("labels.canvasColors")} (event.currentTarget as HTMLButtonElement).focus();
</span> onChange(_color);
<div className="color-picker-content--canvas-colors"> }}
{renderColors(customColors, true)} title={`${t(`colors.${_colorWithoutHash}`)}${
</div> !isTransparent(_color) ? ` (${_color})` : ""
</div> }${keyBindings[i].toUpperCase()}`}
)} aria-label={t(`colors.${_colorWithoutHash}`)}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
ref={(el) => {
if (el && i === 0) {
firstItem.current = el;
}
if (el && _color === color) {
activeItem.current = el;
}
}}
onFocus={() => {
onChange(_color);
}}
>
{isTransparent(_color) ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBindings[i]}</span>
</button>
);
})}
{showInput && ( {showInput && (
<ColorInput <ColorInput
color={color} color={color}
@@ -353,8 +246,6 @@ export const ColorPicker = ({
label, label,
isActive, isActive,
setActive, setActive,
elements,
appState,
}: { }: {
type: "canvasBackground" | "elementBackground" | "elementStroke"; type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null; color: string | null;
@@ -362,8 +253,6 @@ export const ColorPicker = ({
label: string; label: string;
isActive: boolean; isActive: boolean;
setActive: (active: boolean) => void; setActive: (active: boolean) => void;
elements: readonly ExcalidrawElement[];
appState: AppState;
}) => { }) => {
const pickerButton = React.useRef<HTMLButtonElement>(null); const pickerButton = React.useRef<HTMLButtonElement>(null);
@@ -405,7 +294,6 @@ export const ColorPicker = ({
label={label} label={label}
showInput={false} showInput={false}
type={type} type={type}
elements={elements}
/> />
</Popover> </Popover>
) : null} ) : null}
+4 -20
View File
@@ -11,7 +11,6 @@ import {
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { AppState } from "../types"; import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
export type ContextMenuOption = "separator" | Action; export type ContextMenuOption = "separator" | Action;
@@ -22,7 +21,6 @@ type ContextMenuProps = {
left: number; left: number;
actionManager: ActionManager; actionManager: ActionManager;
appState: Readonly<AppState>; appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
}; };
const ContextMenu = ({ const ContextMenu = ({
@@ -32,7 +30,6 @@ const ContextMenu = ({
left, left,
actionManager, actionManager,
appState, appState,
elements,
}: ContextMenuProps) => { }: ContextMenuProps) => {
return ( return (
<Popover <Popover
@@ -40,10 +37,6 @@ const ContextMenu = ({
top={top} top={top}
left={left} left={left}
fitInViewport={true} fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
> >
<ul <ul
className="context-menu" className="context-menu"
@@ -55,14 +48,9 @@ const ContextMenu = ({
} }
const actionName = option.name; const actionName = option.name;
let label = ""; const label = option.contextItemLabel
if (option.contextItemLabel) { ? t(option.contextItemLabel)
if (typeof option.contextItemLabel === "function") { : "";
label = t(option.contextItemLabel(elements, appState));
} else {
label = t(option.contextItemLabel);
}
}
return ( return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}> <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button <button
@@ -70,9 +58,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">
@@ -111,7 +97,6 @@ type ContextMenuParams = {
actionManager: ContextMenuProps["actionManager"]; actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>; appState: Readonly<AppState>;
container: HTMLElement; container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
}; };
const handleClose = (container: HTMLElement) => { const handleClose = (container: HTMLElement) => {
@@ -140,7 +125,6 @@ export default {
onCloseRequest={() => handleClose(params.container)} onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager} actionManager={params.actionManager}
appState={params.appState} appState={params.appState}
elements={params.elements}
/>, />,
getContextMenuNode(params.container), getContextMenuNode(params.container),
); );
+2 -2
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer, useDeviceType } 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";
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose} onClick={onClose}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDeviceType().isMobile ? back : close} {useIsMobile() ? back : close}
</button> </button>
</h2> </h2>
<div className="Dialog__content">{props.children}</div> <div className="Dialog__content">{props.children}</div>
+3 -35
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,20 +149,16 @@ 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
label={t("toolBar.freedraw")} label={t("toolBar.freedraw")}
shortcuts={["Shift + P", "X", "7"]} shortcuts={["Shift+P", "7"]}
/> />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<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={[
@@ -209,10 +205,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]} shortcuts={[getShortcutKey("CtrlOrCmd")]}
/> />
<Shortcut
label={t("toolBar.link")}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/>
</ShortcutIsland> </ShortcutIsland>
<ShortcutIsland caption={t("helpDialog.view")}> <ShortcutIsland caption={t("helpDialog.view")}>
<Shortcut <Shortcut
@@ -268,18 +260,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.multiSelect")} label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]} shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/> />
<Shortcut
label={t("helpDialog.deepSelect")}
shortcuts={[
getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
]}
/>
<Shortcut
label={t("helpDialog.deepBoxSelect")}
shortcuts={[
getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
]}
/>
<Shortcut <Shortcut
label={t("labels.moveCanvas")} label={t("labels.moveCanvas")}
shortcuts={[ shortcuts={[
@@ -363,10 +343,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")]}
@@ -406,14 +382,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("G")]}
/> />
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
</Columns> </Columns>
+17 -34
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.pendingImageElement) { if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage"); return t("hints.placeImage");
} }
@@ -69,27 +61,6 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.rotate"); return t("hints.rotate");
} }
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing");
}
if (activeTool.type === "selection") {
if (
appState.draggingElement?.type === "selection" &&
!appState.editingElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
}
if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
}
}
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) { if (isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
@@ -104,6 +75,18 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
} }
} }
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing");
}
if (elementType === "selection" && !selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
}
return null; return null;
}; };
+5 -5
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 { useDeviceType } 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;
@@ -229,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;
@@ -250,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={useDeviceType().isMobile} showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")} title={t("buttons.exportImage")}
/> />
{modalIsShown && ( {modalIsShown && (
+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 { useDeviceType } 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={useDeviceType().isMobile} showAriaLabel={useIsMobile()}
title={t("buttons.export")} title={t("buttons.export")}
/> />
{modalIsShown && ( {modalIsShown && (
+21 -62
View File
@@ -6,6 +6,7 @@ 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";
@@ -35,9 +36,6 @@ import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDeviceType } from "../components/App";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -48,7 +46,6 @@ interface LayerUIProps {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void; onCollabButtonClick?: () => void;
onLockToggle: () => void; onLockToggle: () => void;
onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean; zenModeEnabled: boolean;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
@@ -68,7 +65,6 @@ interface LayerUIProps {
library: Library; library: Library;
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderCustomElementWidget?: (appState: AppState) => void;
} }
const LayerUI = ({ const LayerUI = ({
@@ -80,7 +76,6 @@ const LayerUI = ({
elements, elements,
onCollabButtonClick, onCollabButtonClick,
onLockToggle, onLockToggle,
onPenModeToggle,
onInsertElements, onInsertElements,
zenModeEnabled, zenModeEnabled,
showExitZenModeBtn, showExitZenModeBtn,
@@ -96,9 +91,8 @@ const LayerUI = ({
library, library,
id, id,
onImageAction, onImageAction,
renderCustomElementWidget,
}: LayerUIProps) => { }: LayerUIProps) => {
const deviceType = useDeviceType(); const isMobile = useIsMobile();
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
@@ -125,7 +119,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,
@@ -242,7 +235,7 @@ const LayerUI = ({
className={CLASSES.SHAPE_ACTIONS_MENU} className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2} padding={2}
style={{ style={{
// we want to make sure this doesn't overflow so subtracting 200 // we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer // which is approximately height of zoom footer and top left menu items with some buffer
// if active file name is displayed, subtracting 248 to account for its height // if active file name is displayed, subtracting 248 to account for its height
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`, maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
@@ -252,7 +245,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>
@@ -320,17 +313,10 @@ const LayerUI = ({
"zen-mode": zenModeEnabled, "zen-mode": zenModeEnabled,
})} })}
> >
<PenModeButton
zenModeEnabled={zenModeEnabled}
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton <LockButton
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.activeTool.locked} checked={appState.elementLocked}
onChange={() => onLockToggle()} onChange={onLockToggle}
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<Island <Island
@@ -342,14 +328,13 @@ const LayerUI = ({
<HintViewer <HintViewer
appState={appState} appState={appState}
elements={elements} elements={elements}
isMobile={deviceType.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({
@@ -393,7 +378,7 @@ const LayerUI = ({
</Tooltip> </Tooltip>
))} ))}
</UserList> </UserList>
{renderTopRightUI?.(deviceType.isMobile, appState)} {renderTopRightUI?.(isMobile, appState)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -423,41 +408,16 @@ const LayerUI = ({
/> />
</Island> </Island>
{!viewModeEnabled && ( {!viewModeEnabled && (
<> <div
<div className={clsx("undo-redo-buttons zen-mode-transition", {
className={clsx("undo-redo-buttons zen-mode-transition", { "layer-ui__wrapper__footer-left--transition-bottom":
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
zenModeEnabled, })}
})} >
> {actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("undo", { size: "small" })} {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":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
{renderCustomElementWidget &&
renderCustomElementWidget(appState)}
</div>
</>
)} )}
{!viewModeEnabled &&
appState.multiElement &&
deviceType.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
@@ -496,7 +456,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}
@@ -525,7 +485,7 @@ const LayerUI = ({
</> </>
); );
return deviceType.isMobile ? ( return isMobile ? (
<> <>
{dialogs} {dialogs}
<MobileMenu <MobileMenu
@@ -537,8 +497,7 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick} onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()} onLockToggle={onLockToggle}
onPenModeToggle={onPenModeToggle}
canvas={canvas} canvas={canvas}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter} renderCustomFooter={renderCustomFooter}
+2 -15
View File
@@ -13,10 +13,6 @@
width: 100%; width: 100%;
margin: 2px 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;
@@ -32,17 +28,8 @@
} }
.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 {
+120 -127
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 {
@@ -26,10 +19,6 @@ import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants"; import { EVENT } from "../constants";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
const useOnClickOutside = ( const useOnClickOutside = (
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
@@ -64,17 +53,6 @@ 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,
onInsertShape, onInsertShape,
@@ -124,6 +102,11 @@ export const LibraryMenu = ({
}; };
}, [onClose]); }, [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);
@@ -131,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",
@@ -167,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],
); );
@@ -211,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();
@@ -220,114 +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,
],
); );
const [lastSelectedItem, setLastSelectedItem] = useState< const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null LibraryItem["id"] | null
>(null); >(null);
if ( return loadingState === "preloading" ? null : (
libraryItemsData.status === "loading" && <Island padding={1} ref={ref} className="layer-ui__library">
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message">
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</LibraryMenuWrapper>
);
}
return (
<LibraryMenuWrapper ref={ref}>
{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"}
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) { {loadingState === "loading" ? (
if (event.shiftKey && lastSelectedItem) { <div className="layer-ui__library-message">
const rangeStart = libraryItemsData.libraryItems.findIndex( {t("labels.libraryLoadingMessage")}
(item) => item.id === lastSelectedItem, </div>
); ) : (
const rangeEnd = libraryItemsData.libraryItems.findIndex( <LibraryMenuItems
(item) => item.id === id, libraryItems={libraryItems}
); onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (rangeStart === -1 || rangeEnd === -1) { if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setSelectedItems([...selectedItems, id]); setSelectedItems([...selectedItems, id]);
return;
} }
setLastSelectedItem(id);
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItemsData.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 { } else {
setSelectedItems([...selectedItems, id]); setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
} }
setLastSelectedItem(id); }}
} else { onPublish={() => setShowPublishLibraryDialog(true)}
setLastSelectedItem(null); resetLibrary={resetLibrary}
setSelectedItems(selectedItems.filter((_id) => _id !== id)); />
} )}
}} </Island>
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
</LibraryMenuWrapper>
); );
}; };
+19 -22
View File
@@ -12,7 +12,7 @@ import {
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useDeviceType } from "./App"; import { useIsMobile } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons"; import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
@@ -22,10 +22,8 @@ import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import Spinner from "./Spinner";
const LibraryMenuItems = ({ const LibraryMenuItems = ({
isLoading,
libraryItems, libraryItems,
onRemoveFromLibrary, onRemoveFromLibrary,
onAddToLibrary, onAddToLibrary,
@@ -42,7 +40,6 @@ const LibraryMenuItems = ({
onPublish, onPublish,
resetLibrary, resetLibrary,
}: { }: {
isLoading: boolean;
libraryItems: LibraryItems; libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
@@ -88,7 +85,7 @@ const LibraryMenuItems = ({
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useDeviceType().isMobile; const isMobile = useIsMobile();
const renderLibraryActions = () => { const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length; const itemsSelected = !!selectedItems.length;
@@ -109,10 +106,14 @@ const LibraryMenuItems = ({
icon={load} icon={load}
onClick={() => { onClick={() => {
importLibraryFromJSON(library) importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
console.error(error); setAppState({ errorMessage: error.message });
setAppState({ errorMessage: t("errors.importLibraryError") });
}); });
}} }}
className="library-actions--load" className="library-actions--load"
@@ -129,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) => {
@@ -288,20 +289,16 @@ const LibraryMenuItems = ({
{showRemoveLibAlert && renderRemoveLibAlert()} {showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header"> <div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()} {renderLibraryActions()}
{isLoading ? ( <a
<Spinner /> href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
) : ( window.name || "_blank"
<a }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ VERSIONS.excalidrawLibrary
window.name || "_blank" }`}
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${ target="_excalidraw_libraries"
VERSIONS.excalidrawLibrary >
}`} {t("labels.libraries")}
target="_excalidraw_libraries" </a>
>
{t("labels.libraries")}
</a>
)}
</div> </div>
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
-2
View File
@@ -27,8 +27,6 @@
.library-unit__dragger { .library-unit__dragger {
display: flex; display: flex;
align-items: center;
justify-content: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
+2 -2
View File
@@ -2,7 +2,7 @@ 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 { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { useDeviceType } from "../components/App"; import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
@@ -66,7 +66,7 @@ export const LibraryUnit = ({
}, [elements, files]); }, [elements, files]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDeviceType().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>
); );
+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>
); );
}; };
+4 -25
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";
@@ -17,7 +17,6 @@ import { LockButton } from "./LockButton";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@@ -29,7 +28,6 @@ type MobileMenuProps = {
libraryMenu: JSX.Element | null; libraryMenu: JSX.Element | null;
onCollabButtonClick?: () => void; onCollabButtonClick?: () => void;
onLockToggle: () => void; onLockToggle: () => void;
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
isCollaborating: boolean; isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
@@ -52,7 +50,6 @@ export const MobileMenu = ({
setAppState, setAppState,
onCollabButtonClick, onCollabButtonClick,
onLockToggle, onLockToggle,
onPenModeToggle,
canvas, canvas,
isCollaborating, isCollaborating,
renderCustomFooter, renderCustomFooter,
@@ -72,9 +69,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({
@@ -86,7 +82,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
@@ -96,13 +92,6 @@ export const MobileMenu = ({
setAppState={setAppState} setAppState={setAppState}
isMobile isMobile
/> />
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
</Stack.Row> </Stack.Row>
{libraryMenu} {libraryMenu}
</Stack.Col> </Stack.Col>
@@ -114,12 +103,6 @@ export const MobileMenu = ({
}; };
const renderAppToolbar = () => { const renderAppToolbar = () => {
// Render eraser conditionally in mobile
const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (viewModeEnabled) { if (viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
@@ -127,16 +110,12 @@ export const MobileMenu = ({
</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",
)} )}
@@ -226,7 +205,7 @@ export const MobileMenu = ({
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}
+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, useDeviceType } 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 deviceType = useDeviceType(); const isMobile = useIsMobile();
const isMobileRef = useRef(deviceType.isMobile); const isMobileRef = useRef(isMobile);
isMobileRef.current = deviceType.isMobile; isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer(); const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
div.classList.toggle("excalidraw--mobile", deviceType.isMobile); div.classList.toggle("excalidraw--mobile", isMobile);
} }
}, [div, deviceType.isMobile]); }, [div, isMobile]);
useLayoutEffect(() => { useLayoutEffect(() => {
const isDarkTheme = const isDarkTheme =
-91
View File
@@ -1,91 +0,0 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
type PenModeIconProps = {
title?: string;
name?: string;
checked: boolean;
onChange?(): void;
zenModeEnabled?: boolean;
isMobile?: boolean;
penDetected: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const ICONS = {
CHECKED: (
<svg
width="205"
height="205"
viewBox="0 0 205 205"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m35 195-25-29.17V50h50v115l-25 30" />
<path d="M10 40V10h50v30H10" />
<path d="M125 145h70v50h-70" />
<path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" />
</svg>
),
UNCHECKED: (
<svg
width="205"
height="205"
viewBox="0 0 205 205"
xmlns="http://www.w3.org/2000/svg"
className="unlocked-icon rtl-mirror"
>
<path d="m35 195-25-29.17V50h50v115l-25 30" />
<path d="M10 40V10h50v30H10" />
<path d="M125 145h70v50h-70" />
<path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" />
</svg>
),
};
export const PenModeButton = (props: PenModeIconProps) => {
if (!props.penDetected) {
if (props.isMobile) {
return null;
}
return (
<label
className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
>
<div className="ToolIcon__icon ToolIcon__hidden" />
</label>
);
}
return (
<label
className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
/>
<div className="ToolIcon__icon">
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
</div>
</label>
);
};
+6 -11
View File
@@ -8,10 +8,6 @@ type Props = {
children?: React.ReactNode; children?: React.ReactNode;
onCloseRequest?(event: PointerEvent): void; onCloseRequest?(event: PointerEvent): void;
fitInViewport?: boolean; fitInViewport?: boolean;
offsetLeft?: number;
offsetTop?: number;
viewportWidth?: number;
viewportHeight?: number;
}; };
export const Popover = ({ export const Popover = ({
@@ -20,10 +16,6 @@ export const Popover = ({
top, top,
onCloseRequest, onCloseRequest,
fitInViewport = false, fitInViewport = false,
offsetLeft = 0,
offsetTop = 0,
viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight,
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@@ -32,14 +24,17 @@ export const Popover = ({
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();
if (x + width - offsetLeft > viewportWidth) {
const viewportWidth = window.innerWidth;
if (x + width > viewportWidth) {
element.style.left = `${viewportWidth - width}px`; element.style.left = `${viewportWidth - width}px`;
} }
if (y + height - offsetTop > viewportHeight) { const viewportHeight = window.innerHeight;
if (y + height > viewportHeight) {
element.style.top = `${viewportHeight - height}px`; element.style.top = `${viewportHeight - height}px`;
} }
} }
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]); }, [fitInViewport]);
useEffect(() => { useEffect(() => {
if (onCloseRequest) { if (onCloseRequest) {
+3 -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 { useDeviceType } 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,13 @@ export const Stats = (props: {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const deviceType = useDeviceType(); 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 (deviceType.isMobile && props.appState.openMenu) { if (isMobile && props.appState.openMenu) {
return null; return null;
} }
-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(() => {
+1 -5
View File
@@ -155,7 +155,7 @@
} }
width: 2rem; width: 2rem;
height: 2rem; height: 2em;
} }
} }
@@ -219,10 +219,6 @@
margin-inline-end: 0; margin-inline-end: 0;
top: 60px; top: 60px;
} }
.ToolIcon.ToolIcon__penMode {
margin-inline-end: 0;
top: 140px;
}
} }
.unlocked-icon { .unlocked-icon {
+2 -14
View File
@@ -46,12 +46,6 @@
} }
} }
.ToolIcon__hidden {
box-shadow: none !important;
background-color: transparent !important;
pointer-events: none !important;
}
.ToolIcon.ToolIcon__lock { .ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor); margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating { &.ToolIcon_type_floating {
@@ -87,14 +81,8 @@
.ToolIcon { .ToolIcon {
&:hover { &:hover {
--icon-fill-color: var( --icon-fill-color: var(--color-primary-chubb);
--color-primary-contrast-offset, --keybinding-color: var(--color-primary-chubb);
var(--color-primary)
);
--keybinding-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
} }
&:active { &:active {
--icon-fill-color: #{$oc-gray-9}; --icon-fill-color: #{$oc-gray-9};
+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;
+31 -47
View File
@@ -2,7 +2,7 @@ import "./Tooltip.scss";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
export const getTooltipDiv = () => { const getTooltipDiv = () => {
const existingDiv = document.querySelector<HTMLDivElement>( const existingDiv = document.querySelector<HTMLDivElement>(
".excalidraw-tooltip", ".excalidraw-tooltip",
); );
@@ -15,50 +15,6 @@ export const getTooltipDiv = () => {
return div; return div;
}; };
export const updateTooltipPosition = (
tooltip: HTMLDivElement,
item: {
left: number;
top: number;
width: number;
height: number;
},
position: "bottom" | "top" = "bottom",
) => {
const tooltipRect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
let left = item.left + item.width / 2 - tooltipRect.width / 2;
if (left < 0) {
left = margin;
} else if (left + tooltipRect.width >= viewportWidth) {
left = viewportWidth - tooltipRect.width - margin;
}
let top: number;
if (position === "bottom") {
top = item.top + item.height + margin;
if (top + tooltipRect.height >= viewportHeight) {
top = item.top - tooltipRect.height - margin;
}
} else {
top = item.top - tooltipRect.height - margin;
if (top < 0) {
top = item.top + item.height + margin;
}
}
Object.assign(tooltip.style, {
top: `${top}px`,
left: `${left}px`,
});
};
const updateTooltip = ( const updateTooltip = (
item: HTMLDivElement, item: HTMLDivElement,
tooltip: HTMLDivElement, tooltip: HTMLDivElement,
@@ -71,8 +27,35 @@ const updateTooltip = (
tooltip.textContent = label; tooltip.textContent = label;
const itemRect = item.getBoundingClientRect(); const {
updateTooltipPosition(tooltip, itemRect); x: itemX,
bottom: itemBottom,
top: itemTop,
width: itemWidth,
} = item.getBoundingClientRect();
const { width: labelWidth, height: labelHeight } =
tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
const left = itemX + itemWidth / 2 - labelWidth / 2;
const offsetLeft =
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
const top = itemBottom + margin;
const offsetTop =
top + labelHeight >= viewportHeight
? itemBottom - itemTop + labelHeight + margin * 2
: 0;
Object.assign(tooltip.style, {
top: `${top - offsetTop}px`,
left: `${left - offsetLeft}px`,
});
}; };
type TooltipProps = { type TooltipProps = {
@@ -92,6 +75,7 @@ export const Tooltip = ({
return () => return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible"); getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
}, []); }, []);
return ( return (
<div <div
className="excalidraw-tooltip-wrapper" className="excalidraw-tooltip-wrapper"
+8 -46
View File
@@ -89,6 +89,14 @@ export const trash = createIcon(
{ width: 448, height: 512 }, { width: 448, height: 512 },
); );
export const backgroundIcon = createIcon(
<path
fill="currentColor"
d="M512 320s-64 92.65-64 128c0 35.35 28.66 64 64 64s64-28.65 64-64-64-128-64-128zm-9.37-102.94L294.94 9.37C288.69 3.12 280.5 0 272.31 0s-16.38 3.12-22.62 9.37l-81.58 81.58L81.93 4.76c-6.25-6.25-16.38-6.25-22.62 0L36.69 27.38c-6.24 6.25-6.24 16.38 0 22.62l86.19 86.18-94.76 94.76c-37.49 37.48-37.49 98.26 0 135.75l117.19 117.19c18.74 18.74 43.31 28.12 67.87 28.12 24.57 0 49.13-9.37 67.87-28.12l221.57-221.57c12.5-12.5 12.5-32.75.01-45.25zm-116.22 70.97H65.93c1.36-3.84 3.57-7.98 7.43-11.83l13.15-13.15 81.61-81.61 58.6 58.6c12.49 12.49 32.75 12.49 45.24 0s12.49-32.75 0-45.24l-58.6-58.6 58.95-58.95 162.44 162.44-48.34 48.34z"
></path>,
{ width: 576, height: 512 },
);
export const palette = createIcon( export const palette = createIcon(
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z", "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
@@ -885,40 +893,6 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
), ),
); );
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
transform="matrix(1,0,0,1,0,80)"
d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const publishIcon = createIcon( export const publishIcon = createIcon(
<path <path
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z" d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
@@ -926,15 +900,3 @@ export const publishIcon = createIcon(
/>, />,
{ width: 640, height: 512 }, { width: 640, height: 512 },
); );
export const editIcon = createIcon(
<path
fill="currentColor"
d="M402.3 344.9l32-32c5-5 13.7-1.5 13.7 5.7V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h273.5c7.1 0 10.7 8.6 5.7 13.7l-32 32c-1.5 1.5-3.5 2.3-5.7 2.3H48v352h352V350.5c0-2.1.8-4.1 2.3-5.6zm156.6-201.8L296.3 405.7l-90.4 10c-26.2 2.9-48.5-19.2-45.6-45.6l10-90.4L432.9 17.1c22.9-22.9 59.9-22.9 82.7 0l43.2 43.2c22.9 22.9 22.9 60 .1 82.8zM460.1 174L402 115.9 216.2 301.8l-7.3 65.3 65.3-7.3L460.1 174zm64.8-79.7l-43.2-43.2c-4.1-4.1-10.8-4.1-14.8 0L436 82l58.1 58.1 30.9-30.9c4-4.2 4-10.8-.1-14.9z"
></path>,
{ 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" />,
);
+8 -29
View File
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps, CustomElementConfig } from "./types"; import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types"; import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
@@ -24,7 +24,7 @@ export const POINTER_BUTTON = {
WHEEL: 1, WHEEL: 1,
SECONDARY: 2, SECONDARY: 2,
TOUCH: -1, TOUCH: -1,
} as const; };
export enum EVENT { export enum EVENT {
COPY = "copy", COPY = "copy",
@@ -52,8 +52,6 @@ export enum EVENT {
HASHCHANGE = "hashchange", HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange", VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll", SCROLL = "scroll",
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
} }
export const ENV = { export const ENV = {
@@ -94,9 +92,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,8 +104,11 @@ 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;
export const STORAGE_KEYS = {
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
@@ -120,7 +119,6 @@ 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;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000; export const IDLE_THRESHOLD = 60_000;
@@ -155,17 +153,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
}, },
}; };
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
type: "custom",
customType: "custom",
transformHandles: true,
displayData: { content: "", type: "svg" },
width: 40,
height: 40,
stackedOnTop: false,
onCreate: () => {},
disableContextMenu: false,
};
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;
@@ -195,12 +182,4 @@ export const VERSIONS = {
excalidrawLibrary: 2, excalidrawLibrary: 2,
} as const; } as const;
export const BOUND_TEXT_PADDING = 5; export const PADDING = 30;
export const VERTICAL_ALIGN = {
TOP: "top",
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
+8 -10
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 {
font-size: 2.8em; .LoadingMessage span {
} background-color: var(--button-gray-1);
border-radius: 5px;
.LoadingMessage-text { padding: 0.8em 1.2em;
margin-top: 1em; color: var(--popup-text-color);
font-size: 0.8em; font-size: 1.3em;
}
} }
+1 -21
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 {
@@ -477,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;
+2
View File
@@ -38,6 +38,7 @@
--text-primary-color: #{$oc-gray-8}; --text-primary-color: #{$oc-gray-8};
--color-primary: #6965db; --color-primary: #6965db;
--color-primary-chubb: #625ee0; // to offset Chubb illusion
--color-primary-darker: #5b57d1; --color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1; --color-primary-darkest: #4a47b1;
--color-primary-light: #e2e1fc; --color-primary-light: #e2e1fc;
@@ -84,6 +85,7 @@
--text-primary-color: #{$oc-gray-4}; --text-primary-color: #{$oc-gray-4};
--color-primary: #5650f0; --color-primary: #5650f0;
--color-primary-chubb: #726dff; // to offset Chubb illusion
--color-primary-darker: #4b46d8; --color-primary-darker: #4b46d8;
--color-primary-darkest: #3e39be; --color-primary-darkest: #3e39be;
--color-primary-light: #3f3d64; --color-primary-light: #3f3d64;
+13 -13
View File
@@ -1,16 +1,20 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import {
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types"; import { AppState, DataURL } from "../types";
import { bytesToHexString } from "../utils"; import { bytesToHexString } from "../utils";
import { FileSystemHandle } from "./filesystem"; import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json"; import { isValidExcalidrawData } from "./json";
import { restore, restoreLibraryItems } from "./restore"; import { restore } from "./restore";
import { ImportedLibraryData } from "./types"; import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => { const parseFileContents = async (blob: Blob | File) => {
@@ -159,17 +163,13 @@ export const loadFromBlob = async (
} }
}; };
export const loadLibraryFromBlob = async ( export const loadLibraryFromBlob = async (blob: Blob) => {
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
const data: ImportedLibraryData | undefined = JSON.parse(contents); const data: ImportedLibraryData = JSON.parse(contents);
if (!isValidLibrary(data)) { if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
throw new Error("Invalid library"); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
const libraryItems = data.libraryItems || data.library; return data;
return restoreLibraryItems(libraryItems, defaultStatus);
}; };
export const canvasToBlob = async ( export const canvasToBlob = async (
-2
View File
@@ -13,9 +13,7 @@ type FILE_EXTENSION =
| "gif" | "gif"
| "jpg" | "jpg"
| "png" | "png"
| "excalidraw.png"
| "svg" | "svg"
| "excalidraw.svg"
| "json" | "json"
| "excalidraw" | "excalidraw"
| "excalidrawlib"; | "excalidrawlib";
+1 -3
View File
@@ -105,9 +105,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) { if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match( const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) { if (!match) {
throw new Error("INVALID"); throw new Error("INVALID");
} }
+5 -12
View File
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json"; export { loadFromJSON, saveAsJSON } from "./json";
export const exportCanvas = async ( export const exportCanvas = async (
type: Omit<ExportType, "backend">, type: ExportType,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
@@ -56,7 +56,7 @@ export const exportCanvas = async (
{ {
description: "Export to SVG", description: "Export to SVG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", extension: "svg",
fileHandle, fileHandle,
}, },
); );
@@ -73,10 +73,10 @@ export const exportCanvas = async (
}); });
tempCanvas.style.display = "none"; tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas); document.body.appendChild(tempCanvas);
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (type === "png") { if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (appState.exportEmbedScene) { if (appState.exportEmbedScene) {
blob = await ( blob = await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
@@ -89,24 +89,17 @@ export const exportCanvas = async (
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png", extension: "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob); await copyBlobToClipboardAsPng(blob);
} catch (error: any) { } catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error; throw error;
} }
throw new Error(t("alerts.couldNotCopyToClipboard")); throw new Error(t("alerts.couldNotCopyToClipboard"));
} finally {
tempCanvas.remove();
} }
} else {
tempCanvas.remove();
// shouldn't happen
throw new Error("Unsupported export type");
} }
}; };
+3 -8
View File
@@ -15,7 +15,6 @@ import {
ExportedDataState, ExportedDataState,
ImportedDataState, ImportedDataState,
ExportedLibraryData, ExportedLibraryData,
ImportedLibraryData,
} from "./types"; } from "./types";
import Library from "./library"; import Library from "./library";
@@ -115,7 +114,7 @@ export const isValidExcalidrawData = (data?: {
); );
}; };
export const isValidLibrary = (json: any): json is ImportedLibraryData => { export const isValidLibrary = (json: any) => {
return ( return (
typeof json === "object" && typeof json === "object" &&
json && json &&
@@ -124,18 +123,14 @@ export const isValidLibrary = (json: any): json is ImportedLibraryData => {
); );
}; };
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => { export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = { const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary, type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary, version: VERSIONS.excalidrawLibrary,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
libraryItems, libraryItems,
}; };
return JSON.stringify(data, null, 2); const serialized = JSON.stringify(data, null, 2);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const serialized = serializeLibraryAsJSON(libraryItems);
await fileSave( await fileSave(
new Blob([serialized], { new Blob([serialized], {
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
+92 -176
View File
@@ -1,205 +1,121 @@
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreLibraryItems } from "./restore"; import { restoreElements, restoreLibraryItems } from "./restore";
import { getNonDeletedElements } from "../element";
import type App from "../components/App"; import type App from "../components/App";
import { ImportedDataState } from "./types";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems));
/**
* checks if library item does not exist already in current library
*/
const isUniqueItem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
});
};
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
class Library { class Library {
/** latest libraryItems */ private libraryCache: LibraryItems | null = null;
private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private app: App; private app: App;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
private updateQueue: Promise<LibraryItems>[] = []; resetLibrary = async () => {
await this.app.props.onLibraryChange?.([]);
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => { this.libraryCache = [];
return this.updateQueue[this.updateQueue.length - 1];
}; };
private notifyListeners = () => { restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
if (this.updateQueue.length > 0) { const elements = getNonDeletedElements(
jotaiStore.set(libraryItemsAtom, { restoreElements(libraryItem.elements, null),
status: "loading", );
libraryItems: this.lastLibraryItems, return elements.length ? { ...libraryItem, elements } : null;
isInitialized: this.isInitialized, };
/** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
return;
}
/**
* checks if library item does not exist already in current library
*/
const isUniqueitem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
}); });
} else { };
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, { const existingLibraryItems = await this.loadLibrary();
status: "loaded",
libraryItems: this.lastLibraryItems, const library = libraryFile.libraryItems || libraryFile.library || [];
isInitialized: this.isInitialized, const restoredLibItems = restoreLibraryItems(
}); library,
try { defaultStatus as "published" | "unpublished",
this.app.props.onLibraryChange?.( );
cloneLibraryItems(this.lastLibraryItems), const filteredItems = [];
); for (const item of restoredLibItems) {
} catch (error) { const restoredItem = this.restoreLibraryItem(item as LibraryItem);
console.error(error); if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
filteredItems.push(restoredItem);
} }
} }
};
resetLibrary = () => { await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
return this.setLibrary([]);
};
/**
* imports library (from blob or libraryItems), merging with current library
* (attempting to remove duplicates)
*/
importLibrary(
library:
| Blob
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished",
): Promise<LibraryItems> {
return this.setLibrary(
() =>
new Promise<LibraryItems>(async (resolve, reject) => {
try {
let libraryItems: LibraryItems;
if (library instanceof Blob) {
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
} else {
libraryItems = restoreLibraryItems(await library, defaultStatus);
}
resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
} catch (error) {
reject(error);
}
}),
);
} }
/** loadLibrary = (): Promise<LibraryItems> => {
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
}
try { try {
const libraryItems = await (this.getLastUpdateTask() || const libraryItems = this.app.libraryItemsFromStorage;
this.lastLibraryItems); if (!libraryItems) {
if (this.updateQueue.length > 0) { return resolve([]);
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
} }
} catch (error) {
return resolve(this.lastLibraryItems); const items = libraryItems.reduce((acc, item) => {
const restoredItem = this.restoreLibraryItem(item);
if (restoredItem) {
acc.push(item);
}
return acc;
}, [] as Mutable<LibraryItems>);
// clone to ensure we don't mutate the cached library elements in the app
this.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items);
} catch (error: any) {
console.error(error);
resolve([]);
} }
}); });
}; };
setLibrary = ( saveLibrary = async (items: LibraryItems) => {
/** const prevLibraryItems = this.libraryCache;
* LibraryItems that will replace current items. Can be a function which try {
* will be invoked after all previous tasks are resolved const serializedItems = JSON.stringify(items);
* (this is the prefered way to update the library to avoid race conditions, // cache optimistically so that the app has access to the latest
* but you'll want to manually merge the library items in the callback // immediately
* - which is what we're doing in Library.importLibrary()). this.libraryCache = JSON.parse(serializedItems);
* await this.app.props.onLibraryChange?.(items);
* If supplied promise is rejected with AbortError, we swallow it and } catch (error: any) {
* do not update the library. this.libraryCache = prevLibraryItems;
*/ throw error;
libraryItems: }
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
} catch (error: any) {
reject(error);
}
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
}; };
} }
+17 -58
View File
@@ -10,11 +10,7 @@ import {
NormalizedZoomValue, NormalizedZoomValue,
} from "../types"; } from "../types";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
getNonDeletedElements,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
@@ -34,9 +30,9 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
>; >;
export const AllowedExcalidrawActiveTools: Record< export const AllowedExcalidrawElementTypes: Record<
AppState["activeTool"]["type"], ExcalidrawElement["type"],
boolean true
> = { > = {
selection: true, selection: true,
text: true, text: true,
@@ -47,8 +43,6 @@ export const AllowedExcalidrawActiveTools: Record<
image: true, image: true,
arrow: true, arrow: true,
freedraw: true, freedraw: true,
eraser: false,
custom: true,
}; };
export type RestoredDataState = { export type RestoredDataState = {
@@ -111,8 +105,6 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id })) ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [], : element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(), updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
locked: element.locked ?? false,
}; };
return { return {
@@ -144,7 +136,7 @@ const restoreElement = (
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || element.text, originalText: element.originalText ?? "",
}); });
case "freedraw": { case "freedraw": {
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@@ -199,10 +191,6 @@ const restoreElement = (
y, y,
}); });
} }
case "custom":
return restoreElementWithProperties(element, {
customType: element.customType || "custom",
});
// generic elements // generic elements
case "ellipse": case "ellipse":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
@@ -245,8 +233,10 @@ export const restoreAppState = (
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
): RestoredAppState => { ): RestoredAppState => {
appState = appState || {}; appState = appState || {};
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState; const nextAppState = {} as typeof defaultAppState;
for (const [key, defaultValue] of Object.entries(defaultAppState) as [ for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState, keyof typeof defaultAppState,
any, any,
@@ -260,39 +250,25 @@ export const restoreAppState = (
? localValue ? localValue
: defaultValue; : defaultValue;
} }
const activeTool: any = {
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: "selection",
};
if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
if (nextAppState.activeTool.type === "custom") {
activeTool.type = "custom";
activeTool.customType = nextAppState.activeTool.customType ?? "custom";
} else {
activeTool.type = nextAppState.activeTool.type;
}
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
// reset on fresh restore so as to hide the UI button if penMode not active ? nextAppState.elementType
penDetected: : "selection",
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool,
// Migrates from previous version where appState.zoom was a number // Migrates from previous version where appState.zoom was a number
zoom: zoom:
typeof appState.zoom === "number" typeof appState.zoom === "number"
? { ? {
value: appState.zoom as NormalizedZoomValue, value: appState.zoom as NormalizedZoomValue,
translation: defaultAppState.zoom.translation,
} }
: appState.zoom || defaultAppState.zoom, : appState.zoom || defaultAppState.zoom,
}; };
}; };
export const restore = ( export const restore = (
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null, data: ImportedDataState | null,
/** /**
* Local AppState (`this.state` or initial state from localStorage) so that we * Local AppState (`this.state` or initial state from localStorage) so that we
* don't overwrite local state with default values (when values not * don't overwrite local state with default values (when values not
@@ -309,45 +285,28 @@ export const restore = (
}; };
}; };
const restoreLibraryItem = (libraryItem: LibraryItem) => {
const elements = restoreElements(
getNonDeletedElements(libraryItem.elements),
null,
);
return elements.length ? { ...libraryItem, elements } : null;
};
export const restoreLibraryItems = ( export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [], libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
defaultStatus: LibraryItem["status"], defaultStatus: LibraryItem["status"],
) => { ) => {
const restoredItems: LibraryItem[] = []; const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) { for (const item of libraryItems) {
// migrate older libraries // migrate older libraries
if (Array.isArray(item)) { if (Array.isArray(item)) {
const restoredItem = restoreLibraryItem({ restoredItems.push({
status: defaultStatus, status: defaultStatus,
elements: item, elements: item,
id: randomId(), id: randomId(),
created: Date.now(), created: Date.now(),
}); });
if (restoredItem) {
restoredItems.push(restoredItem);
}
} else { } else {
const _item = item as MarkOptional< const _item = item as MarkOptional<LibraryItem, "id" | "status">;
LibraryItem, restoredItems.push({
"id" | "status" | "created"
>;
const restoredItem = restoreLibraryItem({
..._item, ..._item,
id: _item.id || randomId(), id: _item.id || randomId(),
status: _item.status || defaultStatus, status: _item.status || defaultStatus,
created: _item.created || Date.now(), created: _item.created || Date.now(),
}); });
if (restoredItem) {
restoredItems.push(restoredItem);
}
} }
} }
return restoredItems; return restoredItems;
+48 -2
View File
@@ -1,7 +1,17 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { getMaximumGroups } from "./groups"; import { getCommonBounds } from "./element";
import { getCommonBoundingBox } from "./element/bounds";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
midX: number;
midY: number;
width: number;
height: number;
}
export interface Distribution { export interface Distribution {
space: "between"; space: "between";
@@ -88,3 +98,39 @@ export const distributeElements = (
); );
}); });
}; };
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
midX: (minX + maxX) / 2,
midY: (minY + maxY) / 2,
};
};
-74
View File
@@ -1,74 +0,0 @@
@import "../css/variables.module";
.excalidraw-hyperlinkContainer {
display: flex;
align-items: center;
justify-content: space-between;
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: 100;
background: var(--island-bg-color);
border-radius: var(--border-radius-md);
box-sizing: border-box;
// to account for LS due to rendering icons after new link created
min-height: 42px;
&-input,
button {
z-index: 100;
}
&-input,
&-link {
height: 24px;
padding: 0 8px;
line-height: 24px;
font-size: 0.9rem;
font-weight: 500;
font-family: var(--ui-font);
}
&-input {
width: 18rem;
border: none;
background-color: transparent;
color: var(--text-primary-color);
outline: none;
border: none;
box-shadow: none !important;
}
&-link {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 15rem;
}
button {
color: $oc-blue-6;
background-color: transparent !important;
font-weight: 500;
&.excalidraw-hyperlinkContainer--remove {
color: $oc-red-9;
}
}
.d-none {
display: none;
}
&--remove .ToolIcon__icon svg {
color: $oc-red-6;
}
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
&__buttons {
flex: 0 0 auto;
}
}
-466
View File
@@ -1,466 +0,0 @@
import { AppState, ExcalidrawProps, Point } from "../types";
import {
getShortcutKey,
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
wrapEvent,
} from "../utils";
import { mutateElement } from "./mutateElement";
import { NonDeletedExcalidrawElement } from "./types";
import { register } from "../actions/register";
import { ToolButton } from "../components/ToolButton";
import { editIcon, link, trash } from "../components/icons";
import { t } from "../i18n";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import clsx from "clsx";
import { KEYS } from "../keys";
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
import { rotate } from "../math";
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
import { Bounds } from "./bounds";
import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
import { getSelectedElements } from "../scene";
import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./";
import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
const CONTAINER_PADDING = 5;
const CONTAINER_HEIGHT = 42;
const AUTO_HIDE_TIMEOUT = 500;
export const EXTERNAL_LINK_IMG = document.createElement("img");
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
)}`;
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
export const Hyperlink = ({
element,
appState,
setAppState,
onLinkOpen,
}: {
element: NonDeletedExcalidrawElement;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"];
}) => {
const linkVal = element.link || "";
const [inputVal, setInputVal] = useState(linkVal);
const inputRef = useRef<HTMLInputElement>(null);
const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
const handleSubmit = useCallback(() => {
if (!inputRef.current) {
return;
}
const link = normalizeLink(inputRef.current.value);
if (!element.link && link) {
trackEvent("hyperlink", "create");
}
mutateElement(element, { link });
setAppState({ showHyperlinkPopup: "info" });
}, [element, setAppState]);
useLayoutEffect(() => {
return () => {
handleSubmit();
};
}, [handleSubmit]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => {
if (isEditing) {
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
}
const shouldHide = shouldHideLinkPopup(element, appState, [
event.clientX,
event.clientY,
]) as boolean;
if (shouldHide) {
timeoutId = window.setTimeout(() => {
setAppState({ showHyperlinkPopup: false });
}, AUTO_HIDE_TIMEOUT);
}
};
window.addEventListener(EVENT.POINTER_MOVE, handlePointerMove, false);
return () => {
window.removeEventListener(EVENT.POINTER_MOVE, handlePointerMove, false);
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [appState, element, isEditing, setAppState]);
const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete");
mutateElement(element, { link: null });
if (isEditing) {
inputRef.current!.value = "";
}
setAppState({ showHyperlinkPopup: false });
}, [setAppState, element, isEditing]);
const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui");
setAppState({ showHyperlinkPopup: "editor" });
};
const { x, y } = getCoordsForPopover(element, appState);
if (
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu
) {
return null;
}
return (
<div
className="excalidraw-hyperlinkContainer"
style={{
top: `${y}px`,
left: `${x}px`,
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
>
{isEditing ? (
<input
className={clsx("excalidraw-hyperlinkContainer-input")}
placeholder="Type or paste your link here"
ref={inputRef}
value={inputVal}
onChange={(event) => setInputVal(event.target.value)}
autoFocus
onKeyDown={(event) => {
event.stopPropagation();
// prevent cmd/ctrl+k shortcut when editing link
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K) {
event.preventDefault();
}
if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
handleSubmit();
}
}}
/>
) : (
<a
href={element.link || ""}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
target={isLocalLink(element.link) ? "_self" : "_blank"}
onClick={(event) => {
if (element.link && onLinkOpen) {
const customEvent = wrapEvent(
EVENT.EXCALIDRAW_LINK,
event.nativeEvent,
);
onLinkOpen(element, customEvent);
if (customEvent.defaultPrevented) {
event.preventDefault();
}
}
}}
rel="noopener noreferrer"
>
{element.link}
</a>
)}
<div className="excalidraw-hyperlinkContainer__buttons">
{!isEditing && (
<ToolButton
type="button"
title={t("buttons.edit")}
aria-label={t("buttons.edit")}
label={t("buttons.edit")}
onClick={onEdit}
className="excalidraw-hyperlinkContainer--edit"
icon={editIcon}
/>
)}
{linkVal && (
<ToolButton
type="button"
title={t("buttons.remove")}
aria-label={t("buttons.remove")}
label={t("buttons.remove")}
onClick={handleRemove}
className="excalidraw-hyperlinkContainer--remove"
icon={trash}
/>
)}
</div>
</div>
);
};
const getCoordsForPopover = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
const [x1, y1] = getElementAbsoluteCoords(element);
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: x1 + element.width / 2, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
return { x, y };
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
openMenu: null,
},
commitToHistory: true,
};
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1;
},
PanelComponent: ({ elements, appState, updateData }) => {
const selectedElements = getSelectedElements(elements, appState);
return (
<ToolButton
type="button"
icon={link}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});
export const getContextMenuLabel = (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]!.link
? "labels.link.edit"
: "labels.link.create";
return label;
};
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
appState: AppState,
): [x: number, y: number, width: number, height: number] => {
const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value;
const linkHeight = size / appState.zoom.value;
const linkMarginY = size / appState.zoom.value;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
const centeringOffset = (size - 8) / (2 * appState.zoom.value);
const dashedLineMargin = 4 / appState.zoom.value;
// Same as `ne` resize handle
const x = x2 + dashedLineMargin - centeringOffset;
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = rotate(
x + linkWidth / 2,
y + linkHeight / 2,
centerX,
centerY,
angle,
);
return [
rotatedX - linkWidth / 2,
rotatedY - linkHeight / 2,
linkWidth,
linkHeight,
];
};
export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[x, y]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
) {
return true;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const hitLink =
x > linkX - threshold &&
x < linkX + threshold + linkWidth &&
y > linkY - threshold &&
y < linkY + linkHeight + threshold;
return hitLink;
};
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
export const showHyperlinkTooltip = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
}
HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
() => renderTooltip(element, appState),
HYPERLINK_TOOLTIP_DELAY,
);
};
const renderTooltip = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
if (!element.link) {
return;
}
const tooltipDiv = getTooltipDiv();
tooltipDiv.classList.add("excalidraw-tooltip--visible");
tooltipDiv.style.maxWidth = "20rem";
tooltipDiv.textContent = element.link;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const linkViewportCoords = sceneCoordsToViewportCoords(
{ sceneX: linkX, sceneY: linkY },
appState,
);
updateTooltipPosition(
tooltipDiv,
{
left: linkViewportCoords.x,
top: linkViewportCoords.y,
width: linkWidth,
height: linkHeight,
},
"top",
);
trackEvent("hyperlink", "tooltip", "link-icon");
IS_HYPERLINK_TOOLTIP_VISIBLE = true;
};
export const hideHyperlinkToolip = () => {
if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
}
if (IS_HYPERLINK_TOOLTIP_VISIBLE) {
IS_HYPERLINK_TOOLTIP_VISIBLE = false;
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
}
};
export const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[clientX, clientY]: Point,
): Boolean => {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX, clientY },
appState,
);
const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
return false;
}
const [x1, y1, x2] = getElementAbsoluteCoords(element);
// hit box to prevent hiding when hovered in the vertical area between element and popover
if (
sceneX >= x1 &&
sceneX <= x2 &&
sceneY >= y1 - SPACE_BOTTOM &&
sceneY <= y1
) {
return false;
}
// hit box to prevent hiding when hovered around popover within threshold
const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState);
if (
clientX >= popoverX - threshold &&
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
clientY >= popoverY - threshold &&
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
) {
return false;
}
return true;
};
+17 -47
View File
@@ -255,8 +255,7 @@ export const getHoveredElementForBinding = (
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
scene.getElements(), scene.getElements(),
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element) && bindingBorderTest(element, pointerCoords),
bindingBorderTest(element, pointerCoords),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@@ -457,13 +456,13 @@ export const getEligibleElementsForBinding = (
): SuggestedBinding[] => { ): SuggestedBinding[] => {
const includedElementIds = new Set(elements.map(({ id }) => id)); const includedElementIds = new Set(elements.map(({ id }) => id));
return elements.flatMap((element) => return elements.flatMap((element) =>
isBindingElement(element, false) isBindingElement(element)
? (getElligibleElementsForBindingElement( ? (getElligibleElementsForBindingElement(
element as NonDeleted<ExcalidrawLinearElement>, element as NonDeleted<ExcalidrawLinearElement>,
).filter( ).filter(
(element) => !includedElementIds.has(element.id), (element) => !includedElementIds.has(element.id),
) as SuggestedBinding[]) ) as SuggestedBinding[])
: isBindableElement(element, false) : isBindableElement(element)
? getElligibleElementsForBindableElementAndWhere(element).filter( ? getElligibleElementsForBindableElementAndWhere(element).filter(
(binding) => !includedElementIds.has(binding[0].id), (binding) => !includedElementIds.has(binding[0].id),
) )
@@ -509,7 +508,7 @@ const getElligibleElementsForBindableElementAndWhere = (
return Scene.getScene(bindableElement)! return Scene.getScene(bindableElement)!
.getElements() .getElements()
.map((element) => { .map((element) => {
if (!isBindingElement(element, false)) { if (!isBindingElement(element)) {
return null; return null;
} }
const canBindStart = isLinearElementEligibleForNewBindingByBindable( const canBindStart = isLinearElementEligibleForNewBindingByBindable(
@@ -660,47 +659,28 @@ export const fixBindingsAfterDeletion = (
const deletedElementIds = new Set( const deletedElementIds = new Set(
deletedElements.map((element) => element.id), deletedElements.map((element) => element.id),
); );
// non-deleted which bindings need to be updated // Non deleted and need an update
const affectedElements: Set<ExcalidrawElement["id"]> = new Set(); const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => { deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) { if (isBindableElement(deletedElement)) {
deletedElement.boundElements?.forEach((element) => { deletedElement.boundElements?.forEach((element) => {
if (!deletedElementIds.has(element.id)) { if (!deletedElementIds.has(element.id)) {
affectedElements.add(element.id); boundElementIds.add(element.id);
} }
}); });
} else if (isBindingElement(deletedElement)) {
if (deletedElement.startBinding) {
affectedElements.add(deletedElement.startBinding.elementId);
}
if (deletedElement.endBinding) {
affectedElements.add(deletedElement.endBinding.elementId);
}
} }
}); });
sceneElements (
.filter(({ id }) => affectedElements.has(id)) sceneElements.filter(({ id }) =>
.forEach((element) => { boundElementIds.has(id),
if (isBindableElement(element)) { ) as ExcalidrawLinearElement[]
mutateElement(element, { ).forEach((element: ExcalidrawLinearElement) => {
boundElements: newBoundElementsAfterDeletion( const { startBinding, endBinding } = element;
element.boundElements, mutateElement(element, {
deletedElementIds, startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
), endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
});
} else if (isBindingElement(element)) {
mutateElement(element, {
startBinding: newBindingAfterDeletion(
element.startBinding,
deletedElementIds,
),
endBinding: newBindingAfterDeletion(
element.endBinding,
deletedElementIds,
),
});
}
}); });
});
}; };
const newBindingAfterDeletion = ( const newBindingAfterDeletion = (
@@ -712,13 +692,3 @@ const newBindingAfterDeletion = (
} }
return binding; return binding;
}; };
const newBoundElementsAfterDeletion = (
boundElements: ExcalidrawElement["boundElements"],
deletedElementIds: Set<ExcalidrawElement["id"]>,
) => {
if (!boundElements) {
return null;
}
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
};
+3 -16
View File
@@ -185,7 +185,7 @@ const getLinearElementAbsoluteCoords = (
maxY + element.y, maxY + element.y,
]; ];
} else { } else {
const shape = getShapeForElement(element)!; const shape = getShapeForElement(element) as Drawable[];
// first element is always the curve // first element is always the curve
const ops = getCurvePathOps(shape[0]); const ops = getCurvePathOps(shape[0]);
@@ -326,7 +326,7 @@ const getLinearElementRotatedBounds = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
} }
const shape = getShapeForElement(element)!; const shape = getShapeForElement(element) as Drawable[];
// first element is always the curve // first element is always the curve
const ops = getCurvePathOps(shape[0]); const ops = getCurvePathOps(shape[0]);
@@ -520,24 +520,11 @@ export interface Box {
minY: number; minY: number;
maxX: number; maxX: number;
maxY: number; maxY: number;
midX: number;
midY: number;
width: number;
height: number;
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): Box => { ): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return { minX, minY, maxX, maxY };
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
midX: (minX + maxX) / 2,
midY: (minY + maxY) / 2,
};
}; };
+13 -46
View File
@@ -24,8 +24,6 @@ import {
NonDeleted, NonDeleted,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawCustomElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -33,20 +31,13 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
import { import { hasBoundTextElement, isImageElement } from "./typeChecks";
hasBoundTextElement,
isCustomElement,
isImageElement,
} from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
): boolean => { ): boolean => {
if (isCustomElement(element)) {
return true;
}
if (element.type === "arrow") { if (element.type === "arrow") {
return false; return false;
} }
@@ -55,7 +46,8 @@ const isElementDraggableFromInside = (
return true; return true;
} }
const isDraggableFromInside = const isDraggableFromInside =
!isTransparent(element.backgroundColor) || hasBoundTextElement(element); !isTransparent(element.backgroundColor) ||
(isTransparent(element.backgroundColor) && hasBoundTextElement(element));
if (element.type === "line") { if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points); return isDraggableFromInside && isPathALoop(element.points);
} }
@@ -105,6 +97,7 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
? isInsideCheck ? isInsideCheck
: isNearCheck; : isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({ element, point, threshold, check });
}; };
@@ -113,7 +106,7 @@ const isElementSelected = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
) => appState.selectedElementIds[element.id]; ) => appState.selectedElementIds[element.id];
export const isPointHittingElementBoundingBox = ( const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
[x, y]: Point, [x, y]: Point,
threshold: number, threshold: number,
@@ -174,7 +167,6 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "text": case "text":
case "diamond": case "diamond":
case "ellipse": case "ellipse":
case "custom":
const distance = distanceToBindableElement(args.element, args.point); const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold); return args.check(distance, args.threshold);
case "freedraw": { case "freedraw": {
@@ -208,7 +200,6 @@ export const distanceToBindableElement = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "custom":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@@ -238,8 +229,7 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement,
| ExcalidrawCustomElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -373,14 +363,6 @@ const hitTestFreeDrawElement = (
B = element.points[i + 1]; B = element.points[i + 1];
} }
const shape = getShapeForElement(element);
// for filled freedraw shapes, support
// selecting from inside
if (shape && shape.sets.length) {
return hitTestRoughShape(shape, x, y, threshold);
}
return false; return false;
}; };
@@ -403,11 +385,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
} }
const [relX, relY] = GAPoint.toTuple(point); const [relX, relY] = GAPoint.toTuple(point);
const shape = getShapeForElement(element as ExcalidrawLinearElement); const shape = getShapeForElement(element) as Drawable[];
if (!shape) {
return false;
}
if (args.check === isInsideCheck) { if (args.check === isInsideCheck) {
const hit = shape.some((subshape) => const hit = shape.some((subshape) =>
@@ -515,7 +493,6 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "custom":
return c / (hwidth * (nabs + q * mabs)); return c / (hwidth * (nabs + q * mabs));
case "diamond": case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@@ -548,7 +525,6 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "custom":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@@ -599,7 +575,6 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "custom":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@@ -633,8 +608,7 @@ const getCorners = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement,
| ExcalidrawCustomElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@@ -643,7 +617,6 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "custom":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@@ -662,7 +635,7 @@ const getCorners = (
// Returns intersection of `line` with `segment`, with `segment` moved by // Returns intersection of `line` with `segment`, with `segment` moved by
// `gap` in its polar direction. // `gap` in its polar direction.
// If intersection coincides with second segment point returns empty array. // If intersection conincides with second segment point returns empty array.
const intersectSegment = ( const intersectSegment = (
line: GA.Line, line: GA.Line,
segment: [GA.Point, GA.Point], segment: [GA.Point, GA.Point],
@@ -786,8 +759,7 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement,
| ExcalidrawCustomElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,
@@ -851,7 +823,7 @@ const hitTestCurveInside = (
sharpness: ExcalidrawElement["strokeSharpness"], sharpness: ExcalidrawElement["strokeSharpness"],
) => { ) => {
const ops = getCurvePathOps(drawable); const ops = getCurvePathOps(drawable);
const points: Mutable<Point>[] = []; const points: Point[] = [];
let odd = false; // select one line out of double lines let odd = false; // select one line out of double lines
for (const operation of ops) { for (const operation of ops) {
if (operation.op === "move") { if (operation.op === "move") {
@@ -865,17 +837,13 @@ const hitTestCurveInside = (
points.push([operation.data[2], operation.data[3]]); points.push([operation.data[2], operation.data[3]]);
points.push([operation.data[4], operation.data[5]]); points.push([operation.data[4], operation.data[5]]);
} }
} else if (operation.op === "lineTo") {
if (odd) {
points.push([operation.data[0], operation.data[1]]);
}
} }
} }
if (points.length >= 4) { if (points.length >= 4) {
if (sharpness === "sharp") { if (sharpness === "sharp") {
return isPointInPolygon(points, x, y); return isPointInPolygon(points, x, y);
} }
const polygonPoints = pointsOnBezierCurves(points, 10, 5); const polygonPoints = pointsOnBezierCurves(points as any, 10, 5);
return isPointInPolygon(polygonPoints, x, y); return isPointInPolygon(polygonPoints, x, y);
} }
return false; return false;
@@ -930,10 +898,9 @@ const hitTestRoughShape = (
// position of the previous operation // position of the previous operation
return retVal; return retVal;
} else if (op === "lineTo") { } else if (op === "lineTo") {
return hitTestCurveInside(drawable, x, y, "sharp"); // TODO: Implement this
} else if (op === "qcurveTo") { } else if (op === "qcurveTo") {
// TODO: Implement this // TODO: Implement this
console.warn("qcurveTo is not implemented yet");
} }
return false; return false;
+11 -17
View File
@@ -1,11 +1,12 @@
import { SHAPES } from "../shapes";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import Scene from "../scene/Scene";
import { NonDeletedExcalidrawElement } from "./types"; import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types"; import { PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElementId } from "./textElement";
import { isSelectedViaGroup } from "../groups";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@@ -15,7 +16,6 @@ export const dragSelectedElements = (
lockDirection: boolean = false, lockDirection: boolean = false,
distanceX: number = 0, distanceX: number = 0,
distanceY: number = 0, distanceY: number = 0,
appState: AppState,
) => { ) => {
const [x1, y1] = getCommonBounds(selectedElements); const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 }; const offset = { x: pointerX - x1, y: pointerY - y1 };
@@ -28,23 +28,17 @@ export const dragSelectedElements = (
element, element,
offset, offset,
); );
// update coords of bound text only if we're dragging the container directly if (!element.groupIds.length) {
// (we don't drag the group that it's part of) const boundTextElementId = getBoundTextElementId(element);
if ( if (boundTextElementId) {
// container isn't part of any group const textElement =
// (perf optim so we don't check `isSelectedViaGroup()` in every case) Scene.getScene(element)!.getElement(boundTextElementId);
!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
) {
const textElement = getBoundTextElement(element);
if (textElement) {
updateElementCoords( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
distanceY, distanceY,
pointerDownState, pointerDownState,
textElement, textElement!,
offset, offset,
); );
} }
@@ -92,7 +86,7 @@ export const getDragOffsetXY = (
export const dragNewElement = ( export const dragNewElement = (
draggingElement: NonDeletedExcalidrawElement, draggingElement: NonDeletedExcalidrawElement,
elementType: AppState["activeTool"]["type"], elementType: typeof SHAPES[number]["value"],
originX: number, originX: number,
originY: number, originY: number,
x: number, x: number,

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