Compare commits

..

5 Commits

Author SHA1 Message Date
dwelle
7f4b72010e fix changing font when editing text 2021-10-28 18:52:31 +02:00
ad1992
eff5871147 Add more redacted fonts 2021-10-28 21:59:50 +05:30
ad1992
4eb5ec70be Add blokk 2021-10-28 14:17:35 +05:30
ad1992
ae2ab5f03a Add scribble 2021-10-28 13:46:56 +05:30
ad1992
468f20ae58 feat: Add wireframe font redacted 2021-10-28 13:23:10 +05:30
272 changed files with 9825 additions and 23137 deletions

5
.env Normal file
View File

@@ -0,0 +1,5 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'

View File

@@ -1,12 +0,0 @@
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=http://localhost:3002
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_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"}'

View File

@@ -1,15 +1 @@
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=https://portal.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"}'
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

View File

@@ -1,7 +1,6 @@
{ {
"extends": ["@excalidraw/eslint-config", "react-app"], "extends": ["@excalidraw/eslint-config", "react-app"],
"rules": { "rules": {
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off"
"no-restricted-globals": "off"
} }
} }

View File

@@ -10,7 +10,6 @@ updates:
- lipis - lipis
assignees: assignees:
- lipis - lipis
open-pull-requests-limit: 20
- package-ecosystem: npm - package-ecosystem: npm
directory: /src/packages/excalidraw/ directory: /src/packages/excalidraw/
@@ -22,7 +21,6 @@ updates:
- ad1992 - ad1992
assignees: assignees:
- ad1992 - ad1992
open-pull-requests-limit: 20
- package-ecosystem: npm - package-ecosystem: npm
directory: /src/packages/utils/ directory: /src/packages/utils/
@@ -34,4 +32,3 @@ updates:
- ad1992 - ad1992
assignees: assignees:
- ad1992 - ad1992
open-pull-requests-limit: 20

View File

@@ -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

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
.github/workflows/build-packages.yml vendored Normal file
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

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: docker/build-push-action@v2 - uses: docker/build-push-action@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}

4
.gitignore vendored
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

View File

@@ -1,2 +0,0 @@
#!/bin/sh
yarn lint-staged

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,10 +118,6 @@ 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
| Command | Description | | Command | Description |

View File

@@ -21,26 +21,26 @@
"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.11.10",
"@testing-library/react": "12.1.2", "@testing-library/react": "11.2.6",
"@tldraw/vec": "1.4.3", "@tldraw/vec": "0.0.106",
"@types/jest": "27.4.0", "@types/jest": "26.0.22",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "17.0.39", "@types/react": "17.0.3",
"@types/react-dom": "17.0.11", "@types/react-dom": "17.0.3",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.24.1", "browser-fs-access": "0.21.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.3",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.2", "i18next-browser-languagedetector": "6.1.0",
"idb-keyval": "6.0.3", "idb-keyval": "5.1.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.32", "nanoid": "3.1.22",
"open-color": "1.9.1", "open-color": "1.8.0",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.16", "perfect-freehand": "1.0.15",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
@@ -49,36 +49,38 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.4.1",
"sass": "1.49.7", "sass": "1.32.10",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.5.5" "typescript": "4.2.4"
}, },
"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.1",
"@types/resize-observer-browser": "0.1.6", "@types/resize-observer-browser": "0.1.5",
"chai": "4.3.6", "chai": "4.3.4",
"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",
"husky": "7.0.4", "firebase-tools": "9.9.0",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.1", "jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.3", "lint-staged": "10.5.4",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.5.1", "prettier": "2.2.1",
"rewire": "5.0.0" "rewire": "5.0.0"
}, },
"resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2"
},
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
}, },
"homepage": ".", "homepage": ".",
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
@@ -100,7 +102,6 @@
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start", "start": "react-scripts start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",

Binary file not shown.

View File

@@ -11,3 +11,33 @@
src: url("Cascadia.woff2"); src: url("Cascadia.woff2");
font-display: swap; font-display: swap;
} }
@font-face {
font-family: "REDACTED_REGULAR";
src: url("redacted-regular.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_SCRIPT_BOLD";
src: url("redacted-script-bold.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_SCRIPT_REGULAR";
src: url("redacted-script-regular.woff2");
font-display: swap;
}
@font-face {
font-family: "Scribble";
src: url("scribble-webfont.woff2");
font-display: swap;
}
@font-face {
font-family: "Blokk";
src: url("BLOKKNeue-Regular.woff2");
font-display: swap;
}

View File

@@ -13,6 +13,18 @@
<meta name="theme-color" content="#000" /> <meta name="theme-color" content="#000" />
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
<meta
http-equiv="origin-trial"
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
/>
<!-- File Handling (https://web.dev/file-handling/) -->
<meta
http-equiv="origin-trial"
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
/>
<!-- General tags --> <!-- General tags -->
<meta <meta
name="description" name="description"
@@ -72,6 +84,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"

View File

@@ -26,6 +26,7 @@
} }
} }
], ],
"capture_links": "new-client",
"share_target": { "share_target": {
"action": "/web-share-target", "action": "/web-share-target",
"method": "POST", "method": "POST",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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 🎉"); } catch (e) {
core.setOutput( console.error(e);
"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) {
core.setOutput("result", "package couldn't be published :warning:!");
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();
}); });

View File

@@ -1,16 +1,11 @@
const { readdirSync, writeFileSync } = require("fs"); const { readdirSync, writeFileSync } = require("fs");
const files = readdirSync(`${__dirname}/../src/locales`); const files = readdirSync(`${__dirname}/../src/locales`);
const flatten = (object = {}, result = {}, extraKey = "") => { const flatten = (object) =>
for (const key in object) { Object.keys(object).reduce(
if (typeof object[key] !== "object") { (initial, current) => ({ ...initial, ...object[current] }),
result[extraKey + key] = object[key]; {},
} else { );
flatten(object[key], result, `${extraKey}${key}.`);
}
}
return result;
};
const locales = files.filter( const locales = files.filter(
(file) => file !== "README.md" && file !== "percentages.json", (file) => file !== "README.md" && file !== "percentages.json",
@@ -24,8 +19,10 @@ for (let index = 0; index < locales.length; index++) {
const allKeys = Object.keys(data); const allKeys = Object.keys(data);
const translatedKeys = allKeys.filter((item) => data[item] !== ""); const translatedKeys = allKeys.filter((item) => data[item] !== "");
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
percentages[currentLocale.replace(".json", "")] = percentage; const percentage = (100 * translatedKeys.length) / allKeys.length;
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
} }
writeFileSync( writeFileSync(

View File

@@ -5,13 +5,10 @@ const THRESSHOLD = 85;
const crowdinMap = { const crowdinMap = {
"ar-SA": "en-ar", "ar-SA": "en-ar",
"bg-BG": "en-bg", "bg-BG": "en-bg",
"bn-BD": "en-bn",
"ca-ES": "en-ca", "ca-ES": "en-ca",
"da-DK": "en-da",
"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",
@@ -34,16 +31,12 @@ const crowdinMap = {
"pt-PT": "en-pt", "pt-PT": "en-pt",
"ro-RO": "en-ro", "ro-RO": "en-ro",
"ru-RU": "en-ru", "ru-RU": "en-ru",
"si-LK": "en-silk",
"sk-SK": "en-sk", "sk-SK": "en-sk",
"sv-SE": "en-sv", "sv-SE": "en-sv",
"ta-IN": "en-ta",
"tr-TR": "en-tr", "tr-TR": "en-tr",
"uk-UA": "en-uk", "uk-UA": "en-uk",
"zh-CN": "en-zhcn", "zh-CN": "en-zhcn",
"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",
@@ -52,10 +45,7 @@ const crowdinMap = {
const flags = { const flags = {
"ar-SA": "🇸🇦", "ar-SA": "🇸🇦",
"bg-BG": "🇧🇬", "bg-BG": "🇧🇬",
"bn-BD": "🇧🇩",
"ca-ES": "🏳", "ca-ES": "🏳",
"cs-CZ": "🇨🇿",
"da-DK": "🇩🇰",
"de-DE": "🇩🇪", "de-DE": "🇩🇪",
"el-GR": "🇬🇷", "el-GR": "🇬🇷",
"es-ES": "🇪🇸", "es-ES": "🇪🇸",
@@ -69,10 +59,7 @@ const flags = {
"it-IT": "🇮🇹", "it-IT": "🇮🇹",
"ja-JP": "🇯🇵", "ja-JP": "🇯🇵",
"kab-KAB": "🏳", "kab-KAB": "🏳",
"kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷", "ko-KR": "🇰🇷",
"lt-LT": "🇱🇹",
"lv-LV": "🇱🇻",
"my-MM": "🇲🇲", "my-MM": "🇲🇲",
"nb-NO": "🇳🇴", "nb-NO": "🇳🇴",
"nl-NL": "🇳🇱", "nl-NL": "🇳🇱",
@@ -84,28 +71,24 @@ const flags = {
"pt-PT": "🇵🇹", "pt-PT": "🇵🇹",
"ro-RO": "🇷🇴", "ro-RO": "🇷🇴",
"ru-RU": "🇷🇺", "ru-RU": "🇷🇺",
"si-LK": "🇱🇰",
"sk-SK": "🇸🇰", "sk-SK": "🇸🇰",
"sv-SE": "🇸🇪", "sv-SE": "🇸🇪",
"ta-IN": "🇮🇳",
"tr-TR": "🇹🇷", "tr-TR": "🇹🇷",
"uk-UA": "🇺🇦", "uk-UA": "🇺🇦",
"zh-CN": "🇨🇳", "zh-CN": "🇨🇳",
"zh-HK": "🇭🇰",
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿",
"kk-KZ": "🇰🇿",
}; };
const languages = { const languages = {
"ar-SA": "العربية", "ar-SA": "العربية",
"bg-BG": "Български", "bg-BG": "Български",
"bn-BD": "Bengali",
"ca-ES": "Català", "ca-ES": "Català",
"cs-CZ": "Česky",
"da-DK": "Dansk",
"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",
@@ -116,10 +99,7 @@ const languages = {
"it-IT": "Italiano", "it-IT": "Italiano",
"ja-JP": "日本語", "ja-JP": "日本語",
"kab-KAB": "Taqbaylit", "kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어", "ko-KR": "한국어",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu",
"my-MM": "Burmese", "my-MM": "Burmese",
"nb-NO": "Norsk bokmål", "nb-NO": "Norsk bokmål",
"nl-NL": "Nederlands", "nl-NL": "Nederlands",
@@ -131,15 +111,15 @@ const languages = {
"pt-PT": "Português", "pt-PT": "Português",
"ro-RO": "Română", "ro-RO": "Română",
"ru-RU": "Русский", "ru-RU": "Русский",
"si-LK": "සිංහල",
"sk-SK": "Slovenčina", "sk-SK": "Slovenčina",
"sv-SE": "Svenska", "sv-SE": "Svenska",
"ta-IN": "Tamil",
"tr-TR": "Türkçe", "tr-TR": "Türkçe",
"uk-UA": "Українська", "uk-UA": "Українська",
"zh-CN": "简体中文", "zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"lv-LV": "Latviešu",
"cs-CZ": "Česky",
"kk-KZ": "Қазақ тілі",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(

View File

@@ -25,8 +25,8 @@ const release = async (nextVersion) => {
); );
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log("Done!"); console.log("Done!");
} catch (error) { } catch (e) {
console.error(error); console.error(e);
process.exit(1); process.exit(1);
} }
}; };

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}"`;
@@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => {
`git log --format=format:"%H" --grep=${commitMessage}`, `git log --format=format:"%H" --grep=${commitMessage}`,
); );
return stdout; return stdout;
} catch (error) { } catch (e) {
console.error(error); console.error(e);
} }
}; };
@@ -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;
}; };

View File

@@ -2,8 +2,6 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
@@ -11,49 +9,15 @@ export const actionAddToLibrary = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
if (selectedElements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: "Support for adding images to the library coming soon!",
},
};
}
return app.library app.library.loadLibrary().then((items) => {
.loadLibrary() app.library.saveLibrary([
.then((items) => { ...items,
return app.library.saveLibrary([ selectedElements.map(deepCopyElement),
{ ]);
id: randomId(), });
status: "unpublished", return false;
elements: selectedElements.map(deepCopyElement),
created: Date.now(),
},
...items,
]);
})
.then(() => {
return {
commitToHistory: false,
appState: {
...appState,
toastMessage: t("toast.addedToLibrary"),
},
};
})
.catch((error) => {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
});
}, },
contextItemLabel: "labels.addToLibrary", contextItemLabel: "labels.addToLibrary",
}); });

View File

@@ -8,13 +8,13 @@ import {
CenterVerticallyIcon, CenterVerticallyIcon,
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } 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 { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
@@ -34,11 +34,9 @@ const alignSelectedElements = (
const updatedElements = alignElements(selectedElements, alignment); const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
export const actionAlignTop = register({ export const actionAlignTop = register({

View File

@@ -9,7 +9,7 @@ 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";
@@ -26,7 +26,7 @@ export const actionChangeViewBackgroundColor = register({
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
@@ -39,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>
); );
@@ -60,15 +58,11 @@ export const actionClearCanvas = register({
files: {}, files: {},
theme: appState.theme, theme: appState.theme,
elementLocked: appState.elementLocked, elementLocked: appState.elementLocked,
penMode: appState.penMode,
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,
elementType:
appState.elementType === "image" ? "selection" : appState.elementType,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -79,18 +73,17 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
perform: (_elements, appState, _, app) => { perform: (_elements, appState) => {
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,
}; };
@@ -114,18 +107,18 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
perform: (_elements, appState, _, app) => { perform: (_elements, appState) => {
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,
}; };
@@ -149,24 +142,25 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
perform: (_elements, appState, _, app) => { perform: (_elements, appState) => {
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,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> <Tooltip label={t("buttons.resetZoom")}>
<ToolButton <ToolButton
type="button" type="button"
className="reset-zoom-button" className="reset-zoom-button"
@@ -218,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;

View File

@@ -25,7 +25,7 @@ export const actionCut = register({
name: "cut", name: "cut",
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,
@@ -42,7 +42,6 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -57,7 +56,7 @@ export const actionCopyAsSvg = register({
return { return {
commitToHistory: false, commitToHistory: false,
}; };
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
@@ -82,7 +81,6 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -108,7 +106,7 @@ export const actionCopyAsPng = register({
}, },
commitToHistory: false, commitToHistory: false,
}; };
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return { return {
appState: { appState: {

View File

@@ -11,7 +11,6 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -22,12 +21,6 @@ const deleteSelectedElements = (
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
) {
return newElementWith(el, { isDeleted: true });
}
return el; return el;
}), }),
appState: { appState: {
@@ -62,7 +55,7 @@ export const actionDeleteSelected = register({
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
elementId, elementId,
selectedPointsIndices, activePointIndex,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
} = appState.editingLinearElement; } = appState.editingLinearElement;
@@ -72,7 +65,8 @@ export const actionDeleteSelected = register({
} }
if ( if (
// case: no point selected → delete whole element // case: no point selected → delete whole element
selectedPointsIndices == null || activePointIndex == null ||
activePointIndex === -1 ||
// case: deleting last remaining point // case: deleting last remaining point
element.points.length < 2 element.points.length < 2
) { ) {
@@ -92,17 +86,15 @@ export const actionDeleteSelected = register({
// We cannot do this inside `movePoint` because it is also called // We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding) // when deleting the uncommitted point (which hasn't caused any binding)
const binding = { const binding = {
startBindingElement: selectedPointsIndices?.includes(0) startBindingElement:
? null activePointIndex === 0 ? null : startBindingElement,
: startBindingElement, endBindingElement:
endBindingElement: selectedPointsIndices?.includes( activePointIndex === element.points.length - 1
element.points.length - 1, ? null
) : endBindingElement,
? null
: endBindingElement,
}; };
LinearElementEditor.deletePoints(element, selectedPointsIndices); LinearElementEditor.movePoint(element, activePointIndex, "delete");
return { return {
elements, elements,
@@ -111,17 +103,17 @@ export const actionDeleteSelected = register({
editingLinearElement: { editingLinearElement: {
...appState.editingLinearElement, ...appState.editingLinearElement,
...binding, ...binding,
selectedPointsIndices: activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]
: [0],
}, },
}, },
commitToHistory: true, commitToHistory: true,
}; };
} }
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState); let {
elements: nextElements,
appState: nextAppState,
} = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),

View File

@@ -4,13 +4,13 @@ import {
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../disitrubte"; import { distributeElements, Distribution } from "../disitrubte";
import { getNonDeletedElements } from "../element"; import { getElementMap, 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 { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
@@ -30,11 +30,9 @@ const distributeSelectedElements = (
const updatedElements = distributeElements(selectedElements, distribution); const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
export const distributeHorizontally = register({ export const distributeHorizontally = register({
@@ -49,8 +47,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)}
@@ -78,8 +75,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)}

View File

@@ -2,12 +2,13 @@ import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element"; import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons"; import { clone } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import { import {
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
getSelectedGroupForElement, getSelectedGroupForElement,
@@ -17,23 +18,41 @@ import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding"; import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types"; import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { bindTextToShapeAfterDuplication } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
perform: (elements, appState) => { perform: (elements, appState) => {
// duplicate selected point(s) if editing a line // duplicate point if selected while editing multi-point element
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(appState); const { activePointIndex, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!ret) { if (!element || activePointIndex === null) {
return false; return false;
} }
const { points } = element;
const selectedPoint = points[activePointIndex];
const nextPoint = points[activePointIndex + 1];
mutateElement(element, {
points: [
...points.slice(0, activePointIndex + 1),
nextPoint
? [
(selectedPoint[0] + nextPoint[0]) / 2,
(selectedPoint[1] + nextPoint[1]) / 2,
]
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
...points.slice(activePointIndex + 1),
],
});
return { return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex: activePointIndex + 1,
},
},
elements, elements,
appState: ret.appState,
commitToHistory: true, commitToHistory: true,
}; };
} }
@@ -87,12 +106,9 @@ const duplicateElements = (
const finalElements: ExcalidrawElement[] = []; const finalElements: ExcalidrawElement[] = [];
let index = 0; let index = 0;
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, true),
);
while (index < elements.length) { while (index < elements.length) {
const element = elements[index]; const element = elements[index];
if (selectedElementIds.get(element.id)) { if (appState.selectedElementIds[element.id]) {
if (element.groupIds.length) { if (element.groupIds.length) {
const groupId = getSelectedGroupForElement(appState, element); const groupId = getSelectedGroupForElement(appState, element);
// if group selected, duplicate it atomically // if group selected, duplicate it atomically
@@ -114,11 +130,7 @@ const duplicateElements = (
} }
index++; index++;
} }
bindTextToShapeAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId); fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return { return {
@@ -128,9 +140,7 @@ const duplicateElements = (
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: newElements.reduce((acc, element) => { selectedElementIds: newElements.reduce((acc, element) => {
if (!isBoundToContainer(element)) { acc[element.id] = true;
acc[element.id] = true;
}
return acc; return acc;
}, {} as any), }, {} as any),
}, },

View File

@@ -151,11 +151,9 @@ export const actionSaveToActiveFile = register({
: null, : null,
}, },
}; };
} catch (error: any) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -183,11 +181,9 @@ export const actionSaveFileToDisk = register({
app.files, app.files,
); );
return { commitToHistory: false, appState: { ...appState, fileHandle } }; return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error: any) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -223,9 +219,8 @@ export const actionLoadScene = register({
files, files,
commitToHistory: true, commitToHistory: true,
}; };
} catch (error: any) { } catch (error) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
console.warn(error);
return false; return false;
} }
return { return {

View File

@@ -19,8 +19,11 @@ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => { perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const {
appState.editingLinearElement; elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (element) { if (element) {

View File

@@ -1,6 +1,6 @@
import { register } from "./register"; import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getElementMap, getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
@@ -9,7 +9,6 @@ import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks"; import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding"; import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
const enableActionFlipHorizontal = ( const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -84,11 +83,9 @@ const flipSelectedElements = (
flipDirection, flipDirection,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
const flipElements = ( const flipElements = (
@@ -145,9 +142,10 @@ const flipElement = (
} }
if (isLinearElement(element)) { if (isLinearElement(element)) {
for (let index = 1; index < element.points.length; index++) { for (let i = 1; i < element.points.length; i++) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoint(element, i, [
{ index, point: [-element.points[index][0], element.points[index][1]] }, -element.points[i][0],
element.points[i][1],
]); ]);
} }
LinearElementEditor.normalizePoints(element); LinearElementEditor.normalizePoints(element);
@@ -155,7 +153,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",

View File

@@ -1,6 +1,6 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons"; import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
@@ -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) {
@@ -45,7 +44,6 @@ const enableActionGroup = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -58,7 +56,6 @@ export const actionGroup = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
@@ -86,9 +83,8 @@ export const actionGroup = register({
} }
} }
const newGroupId = randomId(); const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => { const updatedElements = elements.map((element) => {
if (!selectElementIds.get(element.id)) { if (!appState.selectedElementIds[element.id]) {
return element; return element;
} }
return newElementWith(element, { return newElementWith(element, {
@@ -103,8 +99,9 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack // to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId); const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = const lastGroupElementIndex = updatedElements.lastIndexOf(
updatedElements.lastIndexOf(lastElementInGroup); lastElementInGroup,
);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1); const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex) .slice(0, lastGroupElementIndex)
@@ -152,12 +149,7 @@ export const actionUngroup = register({
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,
@@ -169,19 +161,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,
}; };

View File

@@ -6,9 +6,9 @@ import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isWindows, KEYS } from "../keys"; import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], prevElements: readonly ExcalidrawElement[],
@@ -27,17 +27,17 @@ const writeData = (
return { commitToHistory }; return { commitToHistory };
} }
const prevElementMap = arrayToMap(prevElements); const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements; const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements); const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter( const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id), (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
); );
const elements = nextElements const elements = nextElements
.map((nextElement) => .map((nextElement) =>
newElementWith( newElementWith(
prevElementMap.get(nextElement.id) || nextElement, prevElementMap[nextElement.id] || nextElement,
nextElement, nextElement,
), ),
) )

View File

@@ -13,9 +13,9 @@ import {
FillCrossHatchIcon, FillCrossHatchIcon,
FillHachureIcon, FillHachureIcon,
FillSolidIcon, FillSolidIcon,
FontFamilyCodeIcon, // FontFamilyCodeIcon,
FontFamilyHandDrawnIcon, // FontFamilyHandDrawnIcon,
FontFamilyNormalIcon, // FontFamilyNormalIcon,
FontSizeExtraLargeIcon, FontSizeExtraLargeIcon,
FontSizeLargeIcon, FontSizeLargeIcon,
FontSizeMediumIcon, FontSizeMediumIcon,
@@ -30,69 +30,48 @@ 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,
ExcalidrawLinearElement, ExcalidrawLinearElement,
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";
import FontsList from "../components/FontList";
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,97 +101,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),
appState,
);
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",
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,
@@ -238,8 +138,6 @@ export const actionChangeStrokeColor = register({
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null }) updateData({ openPopup: active ? "strokeColorPicker" : null })
} }
elements={elements}
appState={appState}
/> />
</> </>
), ),
@@ -280,8 +178,6 @@ export const actionChangeBackgroundColor = register({
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null }) updateData({ openPopup: active ? "backgroundColorPicker" : null })
} }
elements={elements}
appState={appState}
/> />
</> </>
), ),
@@ -530,7 +426,24 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
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>
@@ -542,40 +455,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)}
@@ -584,71 +484,21 @@ export const actionChangeFontSize = register({
), ),
}); });
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
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",
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",
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),
appState,
);
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemFontFamily: value, currentItemFontFamily: value,
@@ -657,50 +507,43 @@ export const actionChangeFontFamily = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
const options: { // const options: {
value: FontFamilyValues; // value: FontFamilyValues;
text: string; // text: string;
icon: JSX.Element; // icon: JSX.Element;
}[] = [ // }[] = [
{ // {
value: FONT_FAMILY.Virgil, // value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"), // text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />, // icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
}, // },
{ // {
value: FONT_FAMILY.Helvetica, // value: FONT_FAMILY.Helvetica,
text: t("labels.normal"), // text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />, // icon: <FontFamilyNormalIcon theme={appState.theme} />,
}, // },
{ // {
value: FONT_FAMILY.Cascadia, // value: FONT_FAMILY.Cascadia,
text: t("labels.code"), // text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />, // icon: <FontFamilyCodeIcon theme={appState.theme} />,
}, // },
]; // ];
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false> <FontsList
group="font-family" onChange={(val) => {
options={options} updateData(val);
value={getFormValue( }}
elements, currentFontFamily={
appState, getFormValue(
(element) => { elements,
if (isTextElement(element)) { appState,
return element.fontFamily; (element) => isTextElement(element) && element.fontFamily,
} appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
const boundTextElement = getBoundTextElement(element); ) || DEFAULT_FONT_FAMILY
if (boundTextElement) { }
return boundTextElement.fontFamily;
}
return null;
},
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/> />
</fieldset> </fieldset>
); );
@@ -711,27 +554,17 @@ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
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),
appState,
);
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemTextAlign: value, currentItemTextAlign: value,
@@ -739,119 +572,38 @@ 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",
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{ verticalAlign: value },
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
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({

View File

@@ -1,7 +1,7 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements } from "../element";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@@ -15,10 +15,7 @@ export const actionSelectAll = register({
...appState, ...appState,
editingGroupId: null, editingGroupId: null,
selectedElementIds: elements.reduce((map, element) => { selectedElementIds: elements.reduce((map, element) => {
if ( if (!element.isDeleted) {
!element.isDeleted &&
!(isTextElement(element) && element.containerId)
) {
map[element.id] = true; map[element.id] = true;
} }
return map; return map;

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);
});
});

View File

@@ -12,7 +12,6 @@ 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 = "{}";
@@ -56,18 +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),
appState,
);
} }
return newElement; return newElement;
} }

View File

@@ -1,44 +0,0 @@
import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getBoundTextElement, measureText } from "../element/textElement";
import { ExcalidrawTextElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});

View File

@@ -17,7 +17,6 @@ export {
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -81,5 +80,3 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText } from "./actionUnbindText";
export { actionLink } from "../element/Hyperlink";

View File

@@ -10,31 +10,6 @@ import {
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 = (
action: Action,
source: "ui" | "keyboard" | "api",
value: any,
) => {
if (action.trackEvent !== false) {
try {
if (action.trackEvent === true) {
trackEvent(
action.name,
source,
typeof value === "number" || typeof value === "string"
? String(value)
: undefined,
);
} else {
action.trackEvent?.(action, source, value);
}
} catch (error) {
console.error("error while logging action:", error);
}
}
};
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@@ -90,15 +65,9 @@ export class ActionManager implements ActionsManagerInterface {
), ),
); );
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)) {
@@ -106,8 +75,6 @@ export class ActionManager implements ActionsManagerInterface {
} }
} }
trackAction(action, "keyboard", null);
event.preventDefault(); event.preventDefault();
this.updater( this.updater(
data[0].perform( data[0].perform(
@@ -129,7 +96,6 @@ export class ActionManager implements ActionsManagerInterface {
this.app, this.app,
), ),
); );
trackAction(action, "api", null);
} }
/** /**
@@ -156,8 +122,6 @@ export class ActionManager implements ActionsManagerInterface {
this.app, this.app,
), ),
); );
trackAction(action, "ui", formState);
}; };
return ( return (

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"];
};
}; };

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,9 +25,7 @@ export type ShortcutName = SubtypeOf<
| "addToLibrary" | "addToLibrary"
| "viewMode" | "viewMode"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical";
| "hyperlink"
>;
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -66,11 +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")],
}; };
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] : "";
}; };

View File

@@ -82,7 +82,6 @@ export type ActionName =
| "zoomToSelection" | "zoomToSelection"
| "changeFontFamily" | "changeFontFamily"
| "changeTextAlign" | "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen" | "toggleFullScreen"
| "toggleShortcuts" | "toggleShortcuts"
| "group" | "group"
@@ -102,11 +101,7 @@ export type ActionName =
| "flipVertical" | "flipVertical"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme";
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -126,20 +121,12 @@ 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?:
| boolean
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
} }
export interface ActionsManagerInterface { export interface ActionsManagerInterface {

View File

@@ -1,7 +1,13 @@
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 { getCommonBounds } from "./element";
import { getMaximumGroups } from "./groups";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -31,6 +37,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,
@@ -60,3 +88,8 @@ const calculateTranslation = (
(groupBoundingBox[min] + groupBoundingBox[max]) / 2, (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
}; };
}; };
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { minX, minY, maxX, maxY };
};

View File

@@ -3,16 +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) => {
window.gtag("event", action, { window.gtag("event", name, {
event_category: category, event_category: category,
event_label: label, event_label: label,
value, value,
}); });
} }
: 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.info("Track Event", category, action, label, value); // console.info("Track Event", category, name, label, value);
}; };

View File

@@ -43,8 +43,6 @@ export const getDefaultAppState = (): Omit<
editingLinearElement: null, editingLinearElement: null,
elementLocked: false, elementLocked: false,
elementType: "selection", elementType: "selection",
penMode: false,
penDetected: false,
errorMessage: null, errorMessage: null,
exportBackground: true, exportBackground: true,
exportScale: defaultExportScale, exportScale: defaultExportScale,
@@ -79,12 +77,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,
}; };
}; };
@@ -101,9 +96,10 @@ const APP_STATE_STORAGE_CONF = (<
/** server (shareLink/collab/...) */ /** server (shareLink/collab/...) */
server: boolean; server: boolean;
}, },
T extends Record<keyof AppState, Values>, T extends Record<keyof AppState, Values>
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => >(
config)({ config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false, server: false },
@@ -132,8 +128,6 @@ const APP_STATE_STORAGE_CONF = (<
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false }, elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false }, elementType: { browser: true, export: false, server: false },
penMode: { browser: false, export: false, server: false },
penDetected: { browser: false, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false }, exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false, server: false },
@@ -175,11 +169,10 @@ 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 = <
ExportType extends "export" | "browser" | "server", ExportType extends "export" | "browser" | "server"
>( >(
appState: Partial<AppState>, appState: Partial<AppState>,
exportType: ExportType, exportType: ExportType,

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,7 +161,7 @@ const commonProps = {
strokeSharpness: "sharp", strokeSharpness: "sharp",
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: "middle",
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {

View File

@@ -58,8 +58,7 @@ export const copyToClipboard = async (
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
// select binded text elements when copying const selectedElements = getSelectedElements(elements, appState);
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements, elements: selectedElements,
@@ -75,7 +74,7 @@ export const copyToClipboard = async (
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
} catch (error: any) { } catch (error) {
PREFER_APP_CLIPBOARD = true; PREFER_APP_CLIPBOARD = true;
console.error(error); console.error(error);
} }
@@ -88,7 +87,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
try { try {
return JSON.parse(CLIPBOARD); return JSON.parse(CLIPBOARD);
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return {}; return {};
} }
@@ -124,7 +123,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,
@@ -180,7 +179,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
// not focused // not focused
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(text || "");
copied = true; copied = true;
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }
} }
@@ -220,7 +219,7 @@ const copyTextViaExecCommand = (text: string) => {
textarea.setSelectionRange(0, textarea.value.length); textarea.setSelectionRange(0, textarea.value.length);
success = document.execCommand("copy"); success = document.execCommand("copy");
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@@ -19,7 +19,6 @@ 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 { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@@ -36,15 +35,6 @@ export const SelectedShapeActions = ({
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 isMobile = useIsMobile(); const isMobile = useIsMobile();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@@ -110,10 +100,6 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{targetElements.some(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(elementType) || {(canHaveArrowheads(elementType) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
@@ -131,7 +117,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">
@@ -164,15 +150,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">
{!isMobile && renderAction("duplicateSelection")} {renderAction("duplicateSelection")}
{!isMobile && renderAction("deleteSelectedElements")} {renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{targetElements.length === 1 && renderAction("hyperlink")}
</div> </div>
</fieldset> </fieldset>
)} )}

File diff suppressed because it is too large Load Diff

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>

View File

@@ -3,22 +3,15 @@ import OpenColor from "open-color";
import "./Card.scss"; import "./Card.scss";
export const Card: React.FC<{ export const Card: React.FC<{
color: keyof OpenColor | "primary"; color: keyof OpenColor;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
className="Card" className="Card"
style={{ style={{
["--card-color" as any]: ["--card-color" as any]: OpenColor[color][7],
color === "primary" ? "var(--color-primary)" : OpenColor[color][7], ["--card-color-darker" as any]: OpenColor[color][8],
["--card-color-darker" as any]: ["--card-color-darkest" as any]: OpenColor[color][9],
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
}} }}
> >
{children} {children}

View File

@@ -6,19 +6,16 @@ import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{ export const CheckboxItem: React.FC<{
checked: boolean; checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void; onChange: (checked: boolean) => void;
className?: string; }> = ({ children, checked, onChange }) => {
}> = ({ children, checked, onChange, className }) => {
return ( return (
<div <div
className={clsx("Checkbox", className, { "is-checked": checked })} className={clsx("Checkbox", { "is-checked": checked })}
onClick={(event) => { onClick={(event) => {
onChange(!checked, event); onChange(!checked);
( ((event.currentTarget as HTMLDivElement).querySelector(
(event.currentTarget as HTMLDivElement).querySelector( ".Checkbox-box",
".Checkbox-box", ) as HTMLButtonElement).focus();
) as HTMLButtonElement
).focus();
}} }}
> >
<button className="Checkbox-box" role="checkbox" aria-checked={checked}> <button className="Checkbox-box" role="checkbox" aria-checked={checked}>

View File

@@ -1,21 +1,22 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.confirm-dialog { .clear-canvas {
&-buttons { &-buttons {
display: flex; display: flex;
padding: 0.2rem 0; padding: 0.2rem 0;
justify-content: flex-end; justify-content: flex-end;
}
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button { .ToolIcon__icon {
margin-left: 0.8rem; min-width: 2.5rem;
padding: 0 0.5rem; width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1.5rem;
padding: 0 0.5rem;
}
} }
&__content { &__content {
@@ -33,5 +34,9 @@
color: $oc-white; color: $oc-white;
} }
} }
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-2;
}
} }
} }

View File

@@ -1,10 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useIsMobile } from "./App";
import { Dialog } from "./Dialog";
import { trash } from "./icons"; import { trash } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import ConfirmDialog from "./ConfirmDialog"; import "./ClearCanvas.scss";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@@ -25,16 +26,39 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
/> />
{showDialog && ( {showDialog && (
<ConfirmDialog <Dialog
onConfirm={() => { onCloseRequest={toggleDialog}
onConfirm();
toggleDialog();
}}
onCancel={toggleDialog}
title={t("clearCanvasDialog.title")} title={t("clearCanvasDialog.title")}
className="clear-canvas"
small={true}
> >
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p> <>
</ConfirmDialog> <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
<div className="clear-canvas-buttons">
<ToolButton
type="button"
title={t("buttons.clear")}
aria-label={t("buttons.clear")}
label={t("buttons.clear")}
onClick={() => {
onConfirm();
toggleDialog();
}}
data-testid="confirm-clear-canvas-button"
className="clear-canvas--confirm"
/>
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={toggleDialog}
data-testid="cancel-clear-canvas-button"
className="clear-canvas--cancel"
/>
</div>
</>
</Dialog>
)} )}
</> </>
); );

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;
} }

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}

View File

@@ -1,52 +0,0 @@
import { t } from "../i18n";
import { Dialog, DialogProps } from "./Dialog";
import { ToolButton } from "./ToolButton";
import "./ConfirmDialog.scss";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
onCancel: () => void;
confirmText?: string;
cancelText?: string;
}
const ConfirmDialog = (props: Props) => {
const {
onConfirm,
onCancel,
children,
confirmText = t("buttons.confirm"),
cancelText = t("buttons.cancel"),
className = "",
...rest
} = props;
return (
<Dialog
onCloseRequest={onCancel}
small={true}
{...rest}
className={`confirm-dialog ${className}`}
>
{children}
<div className="confirm-dialog-buttons">
<ToolButton
type="button"
title={cancelText}
aria-label={cancelText}
label={cancelText}
onClick={onCancel}
className="confirm-dialog--cancel"
/>
<ToolButton
type="button"
title={confirmText}
aria-label={confirmText}
label={confirmText}
onClick={onConfirm}
className="confirm-dialog--confirm"
/>
</div>
</Dialog>
);
};
export default ConfirmDialog;

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
@@ -109,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) => {
@@ -138,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),
); );

View File

@@ -10,7 +10,7 @@ import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
export interface DialogProps { export const Dialog = (props: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
small?: boolean; small?: boolean;
@@ -18,10 +18,7 @@ export interface DialogProps {
title: React.ReactNode; title: React.ReactNode;
autofocus?: boolean; autofocus?: boolean;
theme?: AppState["theme"]; theme?: AppState["theme"];
closeOnClickOutside?: boolean; }) => {
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement); const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer(); const { id } = useExcalidrawContainer();
@@ -84,7 +81,6 @@ export const Dialog = (props: DialogProps) => {
maxWidth={props.small ? 550 : 800} maxWidth={props.small ? 550 : 800}
onCloseRequest={onClose} onCloseRequest={onClose}
theme={props.theme} theme={props.theme}
closeOnClickOutside={props.closeOnClickOutside}
> >
<Island ref={setIslandNode}> <Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title"> <h2 id={`${id}-dialog-title`} className="Dialog__title">

View File

@@ -0,0 +1,53 @@
import { FONT_FAMILY } from "../constants";
import { FontFamilyValues } from "../element/types";
const FontsList = ({
onChange,
currentFontFamily,
}: {
onChange: (val: FontFamilyValues) => void;
currentFontFamily: FontFamilyValues;
}) => {
return (
<select
className="dropdown-select"
onChange={(event) => {
onChange(Number(event.target.value));
}}
value={currentFontFamily}
>
<option key="virgil" value={FONT_FAMILY.Virgil}>
Hand-Drawn
</option>
<option key="helvetica" value={FONT_FAMILY.Helvetica}>
Normal
</option>
<option key="cascadia" value={FONT_FAMILY.Cascadia}>
code
</option>
<option key="redacted-regular" value={FONT_FAMILY.REDACTED_REGULAR}>
Redacted Regular
</option>
<option
key="redacted-script-regular"
value={FONT_FAMILY.REDACTED_SCRIPT_REGULAR}
>
Redacted Script
</option>
<option
key="redacted-script-bold"
value={FONT_FAMILY.REDACTED_SCRIPT_BOLD}
>
Redacted Script BOLD
</option>
<option key="Scribble" value={FONT_FAMILY.SCRIBBLE}>
Scribble
</option>
<option key="Blokk" value={FONT_FAMILY.BLOKK}>
Blokk
</option>
</select>
);
};
export default FontsList;

View File

@@ -154,7 +154,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<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"]} />
@@ -205,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
@@ -264,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={[
@@ -398,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>

View File

@@ -7,21 +7,18 @@ import { AppState } from "../types";
import { import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isTextBindableContainer,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
interface HintViewerProps { interface Hint {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
} }
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { const getHints = ({ appState, elements }: Hint) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState; const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (elementType === "arrow" || elementType === "line") { if (elementType === "arrow" || elementType === "line") {
if (!multiMode) { if (!multiMode) {
return t("hints.linearElement"); return t("hints.linearElement");
@@ -42,7 +39,6 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
} }
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if ( if (
isResizing && isResizing &&
lastPointerDownWith === "mouse" && lastPointerDownWith === "mouse" &&
@@ -61,6 +57,15 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.rotate"); return t("hints.rotate");
} }
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.activePointIndex
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected"); return t("hints.text_selected");
} }
@@ -69,45 +74,13 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (elementType === "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 (isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (isTextBindableContainer(selectedElements[0])) {
return t("hints.bindTextToElement");
}
}
return null; return null;
}; };
export const HintViewer = ({ export const HintViewer = ({ appState, elements }: Hint) => {
appState,
elements,
isMobile,
}: HintViewerProps) => {
let hint = getHints({ let hint = getHints({
appState, appState,
elements, elements,
isMobile,
}); });
if (!hint) { if (!hint) {
return null; return null;

View File

@@ -22,7 +22,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:focus-visible { &:focus {
outline: transparent; outline: transparent;
background-color: var(--button-gray-2); background-color: var(--button-gray-2);
& svg { & svg {

View File

@@ -102,7 +102,7 @@ const ImageExportModal = ({
const { exportBackground, viewBackgroundColor } = appState; const { exportBackground, viewBackgroundColor } = appState;
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState)
: elements; : elements;
useEffect(() => { useEffect(() => {

View File

@@ -3,7 +3,7 @@
--padding: 0; --padding: 0;
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: 4px;
padding: calc(var(--padding) * var(--space-factor)); padding: calc(var(--padding) * var(--space-factor));
position: relative; position: relative;
transition: box-shadow 0.5s ease-in-out; transition: box-shadow 0.5s ease-in-out;

View File

@@ -1,6 +1,42 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.layer-ui__library-items {
max-height: 50vh;
overflow: auto;
}
.layer-ui__wrapper { .layer-ui__wrapper {
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);

View File

@@ -1,15 +1,29 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback } from "react"; import React, {
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { 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 { 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,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -18,7 +32,10 @@ import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { exportFile, load, trash } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
@@ -26,17 +43,13 @@ import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section"; import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library"; import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -47,7 +60,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;
@@ -69,6 +81,302 @@ interface LayerUIProps {
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
} }
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
const LibraryMenuItems = ({
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
theme,
setAppState,
setLibraryItems,
libraryReturnUrl,
focusContainer,
library,
files,
id,
}: {
libraryItems: LibraryItems;
pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => {
const isMobile = useIsMobile();
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = isMobile ? 4 : 6;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = [];
let addedPendingElements = false;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
rows.push(
<div className="layer-ui__library-header" key="library-header">
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
{!!libraryItems.length && (
<>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON(library)
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<ToolButton
key="reset"
type="button"
title={t("buttons.resetLibrary")}
aria-label={t("buttons.resetLibrary")}
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}
}}
/>
</>
)}
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>,
);
for (let row = 0; row < numRows; row++) {
const y = CELLS_PER_ROW * row;
const children = [];
for (let x = 0; x < CELLS_PER_ROW; x++) {
const shouldAddPendingElements: boolean =
pendingElements.length > 0 &&
!addedPendingElements &&
y + x >= libraryItems.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={x}>
<LibraryUnit
elements={libraryItems[y + x]}
files={files}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, libraryItems[y + x])
}
/>
</Stack.Col>,
);
}
rows.push(
<Stack.Row align="center" gap={1} key={row}>
{children}
</Stack.Row>,
);
}
return (
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
{rows}
</Stack.Col>
);
};
const LibraryMenu = ({
onClickOutside,
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
id,
}: {
pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
return;
}
onClickOutside(event);
});
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(
async (indexToRemove) => {
const items = await library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setLibraryItems(nextItems);
},
[library, setAppState],
);
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={theme}
files={files}
id={id}
/>
)}
</Island>
);
};
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@@ -78,7 +386,6 @@ const LayerUI = ({
elements, elements,
onCollabButtonClick, onCollabButtonClick,
onLockToggle, onLockToggle,
onPenModeToggle,
onInsertElements, onInsertElements,
zenModeEnabled, zenModeEnabled,
showExitZenModeBtn, showExitZenModeBtn,
@@ -119,34 +426,34 @@ const LayerUI = ({
return null; return null;
} }
const createExporter = const createExporter = (type: ExportType): ExportCB => async (
(type: ExportType): ExportCB => exportedElements,
async (exportedElements) => { ) => {
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
appState, appState,
files, files,
{ {
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
name: appState.name, name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
}, },
) )
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
setAppState({ errorMessage: error.message }); setAppState({ errorMessage: error.message });
}); });
if ( if (
appState.exportEmbedScene && appState.exportEmbedScene &&
fileHandle && fileHandle &&
isImageFileHandle(fileHandle) isImageFileHandle(fileHandle)
) { ) {
setAppState({ fileHandle }); setAppState({ fileHandle });
} }
}; };
return ( return (
<ImageExportDialog <ImageExportDialog
@@ -238,7 +545,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`,
@@ -254,15 +561,12 @@ const LayerUI = ({
</Section> </Section>
); );
const closeLibrary = useCallback(() => { const closeLibrary = useCallback(
const isDialogOpen = !!document.querySelector(".Dialog"); (event) => {
setAppState({ isLibraryOpen: false });
// Prevent closing if any dialog is open },
if (isDialogOpen) { [setAppState],
return; );
}
setAppState({ isLibraryOpen: false });
}, [setAppState]);
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
setAppState({ setAppState({
@@ -273,8 +577,8 @@ const LayerUI = ({
const libraryMenu = appState.isLibraryOpen ? ( const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu <LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)} pendingElements={getSelectedElements(elements, appState)}
onClose={closeLibrary} onClickOutside={closeLibrary}
onInsertShape={onInsertElements} onInsertShape={onInsertElements}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
@@ -284,7 +588,6 @@ const LayerUI = ({
theme={appState.theme} theme={appState.theme}
files={files} files={files}
id={id} id={id}
appState={appState}
/> />
) : null; ) : null;
@@ -310,19 +613,7 @@ const LayerUI = ({
<Section heading="shapes"> <Section heading="shapes">
{(heading) => ( {(heading) => (
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row gap={1}>
gap={1}
className={clsx("App-toolbar-container", {
"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.elementLocked} checked={appState.elementLocked}
@@ -331,15 +622,9 @@ const LayerUI = ({
/> />
<Island <Island
padding={1} padding={1}
className={clsx("App-toolbar", { className={clsx({ "zen-mode": zenModeEnabled })}
"zen-mode": zenModeEnabled,
})}
> >
<HintViewer <HintViewer appState={appState} elements={elements} />
appState={appState}
elements={elements}
isMobile={isMobile}
/>
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
@@ -420,8 +705,7 @@ const LayerUI = ({
{!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" })}
@@ -435,13 +719,13 @@ const LayerUI = ({
className={clsx( className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition", "layer-ui__wrapper__footer-center zen-mode-transition",
{ {
"layer-ui__wrapper__footer-left--transition-bottom": "layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
zenModeEnabled,
}, },
)} )}
> >
{renderCustomFooter?.(false, appState)} {renderCustomFooter?.(false, appState)}
</div> </div>
<div <div
className={clsx( className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition", "layer-ui__wrapper__footer-right zen-mode-transition",
@@ -508,7 +792,6 @@ const LayerUI = ({
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}
@@ -563,7 +846,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
prev.renderCustomFooter === next.renderCustomFooter && prev.renderCustomFooter === next.renderCustomFooter &&
prev.langCode === next.langCode && prev.langCode === next.langCode &&
prev.elements === next.elements && prev.elements === next.elements &&
prev.files === next.files &&
keys.every((key) => prevAppState[key] === nextAppState[key]) keys.every((key) => prevAppState[key] === nextAppState[key])
); );
}; };

View File

@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
export const LibraryButton: React.FC<{ export const LibraryButton: React.FC<{
appState: AppState; appState: AppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean; }> = ({ appState, setAppState }) => {
}> = ({ appState, setAppState, isMobile }) => {
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library", "ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_medium`, `ToolIcon_size_medium`,
{ {
"is-mobile": isMobile, "zen-mode-visibility--hidden": appState.zenModeEnabled,
}, },
)} )}
title={`${capitalizeString(t("toolBar.library"))} — 0`} title={`${capitalizeString(t("toolBar.library"))} — 0`}
style={{ marginInlineStart: "var(--space-factor)" }}
> >
<input <input
className="ToolIcon_type_checkbox" className="ToolIcon_type_checkbox"

View File

@@ -1,55 +0,0 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.publish-library-success {
.Dialog__content {
display: flex;
flex-direction: column;
}
&-close.ToolIcon_type_button {
background-color: $oc-blue-6;
align-self: flex-end;
&:hover {
background-color: $oc-blue-8;
}
.ToolIcon__icon {
width: auto;
font-size: 1rem;
color: $oc-white;
padding: 0 0.5rem;
}
}
}
}

View File

@@ -1,326 +0,0 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
LibraryItems,
LibraryItem,
AppState,
BinaryFiles,
ExcalidrawProps,
} from "../types";
import { Dialog } from "./Dialog";
import { Island } from "./Island";
import PublishLibrary from "./PublishLibrary";
import { ToolButton } from "./ToolButton";
import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
const getSelectedItems = (
libraryItems: LibraryItems,
selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id));
export const LibraryMenu = ({
onClose,
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
id,
appState,
}: {
pendingElements: LibraryItem["elements"];
onClose: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
appState: AppState;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
onClose();
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
onClose();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false);
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(async () => {
const items = await library.loadLibrary();
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
setLibraryItems(nextItems);
}, [library, setAppState, selectedItems, setSelectedItems]);
const resetLibrary = useCallback(() => {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}, [library, focusContainer]);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems: LibraryItems = [
{
status: "unpublished",
elements,
id: randomId(),
created: Date.now(),
},
...items,
];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
const renderPublishSuccess = useCallback(() => {
return (
<Dialog
onCloseRequest={() => setPublishLibSuccess(null)}
title={t("publishSuccessDialog.title")}
className="publish-library-success"
small={true}
>
<p>
{t("publishSuccessDialog.content", {
authorName: publishLibSuccess!.authorName,
})}{" "}
<a
href={publishLibSuccess?.url}
target="_blank"
rel="noopener noreferrer"
>
{t("publishSuccessDialog.link")}
</a>
</p>
<ToolButton
type="button"
title={t("buttons.close")}
aria-label={t("buttons.close")}
label={t("buttons.close")}
onClick={() => setPublishLibSuccess(null)}
data-testid="publish-library-success-close"
className="publish-library-success-close"
/>
</Dialog>
);
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
},
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
appState={appState}
onSuccess={onPublishLibSuccess}
onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
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]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
);
};

View File

@@ -1,102 +0,0 @@
@import "open-color/open-color";
.excalidraw {
.library-menu-items-container {
.library-actions {
display: flex;
button .library-actions-counter {
position: absolute;
right: 2px;
bottom: 2px;
border-radius: 50%;
width: 1em;
height: 1em;
padding: 1px;
font-size: 0.7rem;
background: #fff;
}
&--remove {
background-color: $oc-red-7;
&:hover {
background-color: $oc-red-8;
}
&:active {
background-color: $oc-red-9;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-red-7;
}
}
&--export {
background-color: $oc-lime-5;
&:hover {
background-color: $oc-lime-7;
}
&:active {
background-color: $oc-lime-8;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-lime-5;
}
}
&--publish {
background-color: $oc-cyan-6;
&:hover {
background-color: $oc-cyan-7;
}
&:active {
background-color: $oc-cyan-9;
}
svg {
color: $oc-white;
}
label {
margin-left: -0.2em;
margin-right: 1.1em;
color: $oc-white;
font-size: 0.86em;
}
.library-actions-counter {
color: $oc-cyan-6;
}
}
&--load {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-7;
}
&:active {
background-color: $oc-blue-9;
}
svg {
color: $oc-white;
}
}
}
&__items {
max-height: 50vh;
overflow: auto;
margin-top: 0.5rem;
}
.separator {
font-weight: 500;
font-size: 0.9rem;
margin: 0.6em 0.2em;
color: var(--text-primary-color);
}
}
}

View File

@@ -1,323 +0,0 @@
import { chunk } from "lodash";
import { useCallback, useState } from "react";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import Library from "../data/library";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n";
import {
AppState,
BinaryFiles,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils";
import { useIsMobile } from "./App";
import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants";
const LibraryMenuItems = ({
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
theme,
setAppState,
libraryReturnUrl,
library,
files,
id,
selectedItems,
onToggle,
onPublish,
resetLibrary,
}: {
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
selectedItems: LibraryItem["id"][];
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onPublish: () => void;
resetLibrary: () => void;
}) => {
const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
: t("alerts.resetLibrary");
const title = selectedItems.length
? t("confirmDialog.removeItemsFromLib")
: t("confirmDialog.resetLibrary");
return (
<ConfirmDialog
onConfirm={() => {
if (selectedItems.length) {
onRemoveFromLibrary();
} else {
resetLibrary();
}
setShowRemoveLibAlert(false);
}}
onCancel={() => {
setShowRemoveLibAlert(false);
}}
title={title}
>
<p>{content}</p>
</ConfirmDialog>
);
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useIsMobile();
const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length;
const items = itemsSelected
? libraryItems.filter((item) => selectedItems.includes(item.id))
: libraryItems;
const resetLabel = itemsSelected
? t("buttons.remove")
: t("buttons.resetLibrary");
return (
<div className="library-actions">
{(!itemsSelected || !isMobile) && (
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
className="library-actions--load"
/>
)}
{!!items.length && (
<>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportToFileIcon}
onClick={async () => {
const libraryItems = itemsSelected
? items
: await library.loadLibrary();
saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
className="library-actions--export"
>
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
<ToolButton
key="reset"
type="button"
title={resetLabel}
aria-label={resetLabel}
icon={trash}
onClick={() => setShowRemoveLibAlert(true)}
className="library-actions--remove"
>
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
</>
)}
{itemsSelected && !isPublished && (
<Tooltip label={t("hints.publishLibrary")}>
<ToolButton
type="button"
aria-label={t("buttons.publishLibrary")}
label={t("buttons.publishLibrary")}
icon={publishIcon}
className="library-actions--publish"
onClick={onPublish}
>
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
</Tooltip>
)}
</div>
);
};
const CELLS_PER_ROW = isMobile ? 4 : 6;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const isPublished = selectedItems.some(
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
);
const createLibraryItemCompo = (params: {
item:
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
| null;
onClick?: () => void;
key: string;
}) => {
return (
<Stack.Col key={params.key}>
<LibraryUnit
elements={params.item?.elements}
files={files}
isPending={!params.item?.id && !!params.item?.elements}
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={(id, event) => {
onToggle(id, event);
}}
/>
</Stack.Col>
);
};
const renderLibrarySection = (
items: (
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
)[],
) => {
const _items = items.map((item) => {
if (item.id) {
return createLibraryItemCompo({
item,
onClick: () => onInsertShape(item.elements),
key: item.id,
});
}
return createLibraryItemCompo({
key: "__pending__item__",
item,
onClick: () => onAddToLibrary(pendingElements),
});
});
// ensure we render all empty cells if no items are present
let rows = chunk(_items, CELLS_PER_ROW);
if (!rows.length) {
rows = [[]];
}
return rows.map((rowItems, index, rows) => {
if (index === rows.length - 1) {
// pad row with empty cells
rowItems = rowItems.concat(
new Array(CELLS_PER_ROW - rowItems.length)
.fill(null)
.map((_, index) => {
return createLibraryItemCompo({
key: `empty_${index}`,
item: null,
});
}),
);
}
return (
<Stack.Row align="center" gap={1} key={index}>
{rowItems}
</Stack.Row>
);
});
};
const publishedItems = libraryItems.filter(
(item) => item.status === "published",
);
const unpublishedItems = [
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...libraryItems.filter((item) => item.status !== "published"),
];
return (
<div className="library-menu-items-container">
{showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
<Stack.Col
className="library-menu-items-container__items"
align="start"
gap={1}
>
<>
<div className="separator">{t("labels.personalLib")}</div>
{renderLibrarySection(unpublishedItems)}
</>
<>
<div className="separator">{t("labels.excalidrawLib")} </div>
{renderLibrarySection(publishedItems)}
</>
</Stack.Col>
</div>
);
};
export default LibraryMenuItems;

View File

@@ -1,5 +1,3 @@
@import "../css/variables.module";
.excalidraw { .excalidraw {
.library-unit { .library-unit {
align-items: center; align-items: center;
@@ -9,26 +7,10 @@
position: relative; position: relative;
width: 63px; width: 63px;
height: 63px; // match width height: 63px; // match width
&--hover {
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
border-color: $oc-blue-5;
}
&--selected {
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
border-color: $oc-blue-8;
}
}
&.theme--dark .library-unit {
border-color: rgb(48, 48, 48);
} }
.library-unit__dragger { .library-unit__dragger {
display: flex; display: flex;
align-items: center;
justify-content: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
@@ -40,9 +22,9 @@
max-width: 100%; max-width: 100%;
} }
.library-unit__checkbox-container, .library-unit__removeFromLibrary,
.library-unit__checkbox-container:hover, .library-unit__removeFromLibrary:hover,
.library-unit__checkbox-container:active { .library-unit__removeFromLibrary:active {
align-items: center; align-items: center;
background: none; background: none;
border: none; border: none;
@@ -50,35 +32,10 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 0; margin: 0;
padding: 0.5rem; padding: 0;
position: absolute; position: absolute;
left: 2rem; right: 5px;
bottom: 2rem; top: 5px;
cursor: pointer;
input {
cursor: pointer;
}
}
.library-unit__checkbox {
position: absolute;
left: 2.3rem;
bottom: 2.3rem;
.Checkbox-box {
width: 13px;
height: 13px;
border-radius: 2px;
margin: 0.5em 0.5em 0.2em 0.2em;
background-color: $oc-blue-1;
}
&.Checkbox:hover {
.Checkbox-box {
background-color: $oc-blue-2;
}
}
} }
.library-unit__removeFromLibrary > svg { .library-unit__removeFromLibrary > svg {
@@ -86,37 +43,29 @@
width: 16px; width: 16px;
} }
.library-unit__adder { .library-unit__pulse {
transform: scale(1); transform: scale(1);
animation: library-unit__adder-animation 1s ease-in infinite; animation: library-unit__pulse-animation 1s ease-in infinite;
} }
.library-unit__adder { .library-unit__adder {
position: absolute; position: absolute;
left: 40%; left: 50%;
top: 40%; top: 50%;
width: 2rem; width: 20px;
height: 2rem; height: 20px;
margin-left: -10px; margin-left: -10px;
margin-top: -10px; margin-top: -10px;
pointer-events: none; pointer-events: none;
} }
.library-unit:hover .library-unit__adder {
fill: $oc-blue-7;
}
.library-unit:active .library-unit__adder {
animation: none;
transform: scale(0.8);
fill: $oc-black;
}
.library-unit__active { .library-unit__active {
cursor: pointer; cursor: pointer;
} }
@keyframes library-unit__adder-animation { @keyframes library-unit__pulse-animation {
0% { 0% {
transform: scale(0.85); transform: scale(0.95);
} }
50% { 50% {
@@ -124,7 +73,7 @@
} }
100% { 100% {
transform: scale(0.85); transform: scale(0.95);
} }
} }
} }

View File

@@ -1,42 +1,36 @@
import clsx from "clsx"; import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
// fa-plus
const PLUS_ICON = ( const PLUS_ICON = (
<svg viewBox="0 0 1792 1792"> <svg viewBox="0 0 1792 1792">
<path <path
d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z" fill="currentColor"
style={{ d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
stroke: "#fff",
strokeWidth: 140,
}}
transform="translate(0 64)"
/> />
</svg> </svg>
); );
export const LibraryUnit = ({ export const LibraryUnit = ({
id,
elements, elements,
files, files,
isPending, pendingElements,
onRemoveFromLibrary,
onClick, onClick,
selected,
onToggle,
}: { }: {
id: LibraryItem["id"] | /** for pending item */ null; elements?: LibraryItem;
elements?: LibraryItem["elements"];
files: BinaryFiles; files: BinaryFiles;
isPending?: boolean; pendingElements?: LibraryItem;
onRemoveFromLibrary: () => void;
onClick: () => void; onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -46,11 +40,12 @@ export const LibraryUnit = ({
} }
(async () => { (async () => {
if (!elements) { const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return; return;
} }
const svg = await exportToSvg( const svg = await exportToSvg(
elements, elementsToRender,
{ {
exportBackground: false, exportBackground: false,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
@@ -63,41 +58,30 @@ export const LibraryUnit = ({
return () => { return () => {
node.innerHTML = ""; node.innerHTML = "";
}; };
}, [elements, files]); }, [elements, pendingElements, files]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const adder = isPending && (
const adder = (isHovered || isMobile) && pendingElements && (
<div className="library-unit__adder">{PLUS_ICON}</div> <div className="library-unit__adder">{PLUS_ICON}</div>
); );
return ( return (
<div <div
className={clsx("library-unit", { className={clsx("library-unit", {
"library-unit__active": elements, "library-unit__active": elements || pendingElements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
})} })}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div <div
className={clsx("library-unit__dragger", { className={clsx("library-unit__dragger", {
"library-unit__pulse": !!isPending, "library-unit__pulse": !!pendingElements,
})} })}
ref={ref} ref={ref}
draggable={!!elements} draggable={!!elements}
onClick={ onClick={!!elements || !!pendingElements ? onClick : undefined}
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
}
}
: undefined
}
onDragStart={(event) => { onDragStart={(event) => {
setIsHovered(false); setIsHovered(false);
event.dataTransfer.setData( event.dataTransfer.setData(
@@ -107,12 +91,14 @@ export const LibraryUnit = ({
}} }}
/> />
{adder} {adder}
{id && elements && (isHovered || isMobile || selected) && ( {elements && (isHovered || isMobile) && (
<CheckboxItem <button
checked={selected} className="library-unit__removeFromLibrary"
onChange={(checked, event) => onToggle(id, event)} aria-label={t("labels.removeFromLibrary")}
className="library-unit__checkbox" onClick={onRemoveFromLibrary}
/> >
{close}
</button>
)} )}
</div> </div>
); );

View File

@@ -10,7 +10,6 @@ type LockIconProps = {
checked: boolean; checked: boolean;
onChange?(): void; onChange?(): void;
zenModeEnabled?: boolean; zenModeEnabled?: boolean;
isMobile?: boolean;
}; };
const DEFAULT_SIZE: ToolButtonSize = "medium"; const DEFAULT_SIZE: ToolButtonSize = "medium";
@@ -43,10 +42,10 @@ export const LockButton = (props: LockIconProps) => {
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating", "ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`, `ToolIcon_size_${DEFAULT_SIZE}`,
{ {
"is-mobile": props.isMobile, "zen-mode-visibility--hidden": props.zenModeEnabled,
}, },
)} )}
title={`${props.title} — Q`} title={`${props.title} — Q`}

View File

@@ -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,
@@ -67,8 +64,8 @@ export const MobileMenu = ({
<Section heading="shapes"> <Section heading="shapes">
{(heading) => ( {(heading) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container"> <Stack.Row gap={1}>
<Island padding={1} className="App-toolbar"> <Island padding={1}>
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
@@ -88,26 +85,14 @@ export const MobileMenu = ({
checked={appState.elementLocked} checked={appState.elementLocked}
onChange={onLockToggle} onChange={onLockToggle}
title={t("toolBar.lock")} title={t("toolBar.lock")}
isMobile
/>
<LibraryButton
appState={appState}
setAppState={setAppState}
isMobile
/>
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/> />
<LibraryButton appState={appState} setAppState={setAppState} />
</Stack.Row> </Stack.Row>
{libraryMenu} {libraryMenu}
</Stack.Col> </Stack.Col>
)} )}
</Section> </Section>
<HintViewer appState={appState} elements={elements} isMobile={true} /> <HintViewer appState={appState} elements={elements} />
</FixedSideContainer> </FixedSideContainer>
); );
}; };

View File

@@ -15,9 +15,8 @@ export const Modal = (props: {
onCloseRequest(): void; onCloseRequest(): void;
labelledBy: string; labelledBy: string;
theme?: AppState["theme"]; theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}) => { }) => {
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props; const { theme = THEME.LIGHT } = props;
const modalRoot = useBodyRoot(theme); const modalRoot = useBodyRoot(theme);
if (!modalRoot) { if (!modalRoot) {
@@ -40,10 +39,7 @@ export const Modal = (props: {
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
> >
<div <div className="Modal__background" onClick={props.onCloseRequest}></div>
className="Modal__background"
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
></div>
<div <div
className="Modal__content" className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }} style={{ "--max-width": `${props.maxWidth}px` }}

View File

@@ -82,7 +82,7 @@ export const PasteChartDialog = ({
appState: AppState; appState: AppState;
onClose: () => void; onClose: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onInsertChart: (elements: LibraryItem["elements"]) => void; onInsertChart: (elements: LibraryItem) => void;
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {

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>
);
};

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) {

View File

@@ -42,7 +42,6 @@ export const ProjectName = (props: Props) => {
</label> </label>
{props.isNameEditable ? ( {props.isNameEditable ? (
<input <input
type="text"
className="TextInput" className="TextInput"
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@@ -1,92 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.publish-library {
&__fields {
display: flex;
flex-direction: column;
label {
padding: 1em;
display: flex;
justify-content: space-between;
align-items: center;
span {
font-weight: 500;
font-size: 1rem;
color: $oc-gray-6;
}
input,
textarea {
width: 70%;
padding: 0.6em;
font-family: var(--ui-font);
}
.required {
color: $oc-red-8;
margin: 0.2rem;
}
}
}
&__buttons {
display: flex;
padding: 0.2rem 0;
justify-content: flex-end;
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1rem;
padding: 0 0.5rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-8;
}
}
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-5;
&:hover {
background-color: $oc-gray-6;
}
}
.ToolIcon__icon {
color: $oc-white;
.Spinner {
--spinner-color: #fff;
svg {
padding: 0.5rem;
}
}
}
}
.selected-library-items {
display: flex;
padding: 0 0.8rem;
flex-wrap: wrap;
.single-library-item-wrapper {
width: 9rem;
}
}
&-note {
padding: 1em;
font-style: italic;
font-size: 14px;
display: block;
}
}
}

View File

@@ -1,455 +0,0 @@
import { ReactNode, useCallback, useEffect, useState } from "react";
import OpenColor from "open-color";
import { Dialog } from "./Dialog";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas } from "../packages/utils";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils";
interface PublishLibraryDataParams {
authorName: string;
githubHandle: string;
name: string;
description: string;
twitterHandle: string;
website: string;
}
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
JSON.stringify(data),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importPublishLibDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
if (data) {
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128;
const BOX_PADDING = Math.round(BOX_SIZE / 16);
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
const canvas = document.createElement("canvas");
canvas.width =
rows[0].length * BOX_SIZE +
(rows[0].length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
canvas.height =
rows.length * BOX_SIZE +
(rows.length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
// draw item
// -------------------------------------------------------------------------
const rowOffset =
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
const colOffset =
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
ctx.drawImage(
itemCanvas,
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
);
// draw item border
// -------------------------------------------------------------------------
ctx.lineWidth = BORDER_WIDTH;
ctx.strokeStyle = OpenColor.gray[4];
ctx.strokeRect(
colOffset + BOX_PADDING / 2,
rowOffset + BOX_PADDING / 2,
BOX_SIZE + BOX_PADDING,
BOX_SIZE + BOX_PADDING,
);
}
return await resizeImageFile(
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
{
outputType: MIME_TYPES.jpg,
maxWidthOrHeight: 5000,
},
);
};
const PublishLibrary = ({
onClose,
libraryItems,
appState,
onSuccess,
onError,
updateItemsInStorage,
onRemove,
}: {
onClose: () => void;
libraryItems: LibraryItems;
appState: AppState;
onSuccess: (data: {
url: string;
authorName: string;
items: LibraryItems;
}) => void;
onError: (error: Error) => void;
updateItemsInStorage: (items: LibraryItems) => void;
onRemove: (id: string) => void;
}) => {
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
authorName: "",
githubHandle: "",
name: "",
description: "",
twitterHandle: "",
website: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const data = importPublishLibDataFromStorage();
if (data) {
setLibraryData(data);
}
}, []);
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
libraryItems.slice(),
);
useEffect(() => {
setClonedLibItems(libraryItems.slice());
}, [libraryItems]);
const onInputChange = (event: any) => {
setLibraryData({
...libraryData,
[event.target.name]: event.target.value,
});
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
const erroredLibItems: LibraryItem[] = [];
let isError = false;
clonedLibItems.forEach((libItem) => {
let error = "";
if (!libItem.name) {
error = t("publishDialog.errors.required");
isError = true;
}
erroredLibItems.push({ ...libItem, error });
});
if (isError) {
setClonedLibItems(erroredLibItems);
setIsSubmitting(false);
return;
}
const previewImage = await generatePreviewImage(clonedLibItems);
const libContent: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
source: EXPORT_SOURCE,
libraryItems: clonedLibItems,
};
const content = JSON.stringify(libContent, null, 2);
const lib = new Blob([content], { type: "application/json" });
const formData = new FormData();
formData.append("excalidrawLib", lib);
formData.append("previewImage", previewImage);
formData.append("previewImageType", previewImage.type);
formData.append("title", libraryData.name);
formData.append("authorName", libraryData.authorName);
formData.append("githubHandle", libraryData.githubHandle);
formData.append("name", libraryData.name);
formData.append("description", libraryData.description);
formData.append("twitterHandle", libraryData.twitterHandle);
formData.append("website", libraryData.website);
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
method: "post",
body: formData,
})
.then(
(response) => {
if (response.ok) {
return response.json().then(({ url }) => {
// flush data from local storage
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
onSuccess({
url,
authorName: libraryData.authorName,
items: clonedLibItems,
});
});
}
return response
.json()
.catch(() => {
throw new Error(response.statusText || "something went wrong");
})
.then((error) => {
throw new Error(
error.message || response.statusText || "something went wrong",
);
});
},
(err) => {
console.error(err);
onError(err);
setIsSubmitting(false);
},
)
.catch((err) => {
console.error(err);
onError(err);
setIsSubmitting(false);
});
};
const renderLibraryItems = () => {
const items: ReactNode[] = [];
clonedLibItems.forEach((libItem, index) => {
items.push(
<div className="single-library-item-wrapper" key={index}>
<SingleLibraryItem
libItem={libItem}
appState={appState}
index={index}
onChange={(val, index) => {
const items = clonedLibItems.slice();
items[index].name = val;
setClonedLibItems(items);
}}
onRemove={onRemove}
/>
</div>,
);
});
return <div className="selected-library-items">{items}</div>;
};
const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems);
savePublishLibDataToStorage(libraryData);
onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
const shouldRenderForm = !!libraryItems.length;
return (
<Dialog
onCloseRequest={onDialogClose}
title={t("publishDialog.title")}
className="publish-library"
>
{shouldRenderForm ? (
<form onSubmit={onSubmit}>
<div className="publish-library-note">
{t("publishDialog.noteDescription.pre")}
<a
href="https://libraries.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteDescription.link")}
</a>{" "}
{t("publishDialog.noteDescription.post")}
</div>
<span className="publish-library-note">
{t("publishDialog.noteGuidelines.pre")}
<a
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteGuidelines.link")}
</a>
{t("publishDialog.noteGuidelines.post")}
</span>
<div className="publish-library-note">
{t("publishDialog.noteItems")}
</div>
{renderLibraryItems()}
<div className="publish-library__fields">
<label>
<div>
<span>{t("publishDialog.libraryName")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
name="name"
required
value={libraryData.name}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.libraryName")}
/>
</label>
<label style={{ alignItems: "flex-start" }}>
<div>
<span>{t("publishDialog.libraryDesc")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<textarea
name="description"
rows={4}
required
value={libraryData.description}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.libraryDesc")}
/>
</label>
<label>
<div>
<span>{t("publishDialog.authorName")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
name="authorName"
required
value={libraryData.authorName}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.authorName")}
/>
</label>
<label>
<span>{t("publishDialog.githubUsername")}</span>
<input
type="text"
name="githubHandle"
value={libraryData.githubHandle}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.githubHandle")}
/>
</label>
<label>
<span>{t("publishDialog.twitterUsername")}</span>
<input
type="text"
name="twitterHandle"
value={libraryData.twitterHandle}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.twitterHandle")}
/>
</label>
<label>
<span>{t("publishDialog.website")}</span>
<input
type="text"
name="website"
pattern="https?://.+"
title={t("publishDialog.errors.website")}
value={libraryData.website}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.website")}
/>
</label>
<span className="publish-library-note">
{t("publishDialog.noteLicense.pre")}
<a
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteLicense.link")}
</a>
{t("publishDialog.noteLicense.post")}
</span>
</div>
<div className="publish-library__buttons">
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={onDialogClose}
data-testid="cancel-clear-canvas-button"
className="publish-library__buttons--cancel"
/>
<ToolButton
type="submit"
title={t("buttons.submit")}
aria-label={t("buttons.submit")}
label={t("buttons.submit")}
className="publish-library__buttons--confirm"
isLoading={isSubmitting}
/>
</div>
</form>
) : (
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
{t("publishDialog.atleastOneLibItem")}
</p>
)}
</Dialog>
);
};
export default PublishLibrary;

View File

@@ -1,66 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.single-library-item {
position: relative;
&__svg {
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
margin: 0.3rem;
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1.3rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
}

View File

@@ -1,99 +0,0 @@
import oc from "open-color";
import { useEffect, useRef } from "react";
import { t } from "../i18n";
import { exportToSvg } from "../packages/utils";
import { AppState, LibraryItem } from "../types";
import { close } from "./icons";
import "./SingleLibraryItem.scss";
import { ToolButton } from "./ToolButton";
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: AppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={close}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0.3rem",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
export default SingleLibraryItem;

View File

@@ -2,6 +2,24 @@
.excalidraw { .excalidraw {
.TextInput { .TextInput {
color: var(--text-primary-color);
display: inline-block; display: inline-block;
border: 1.5px solid var(--button-gray-1);
line-height: 1;
padding: 0.75rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: var(--input-bg-color);
&:not(:focus) {
&:hover {
background-color: var(--input-hover-bg-color);
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
} }
} }

View File

@@ -25,7 +25,6 @@ type ToolButtonBaseProps = {
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
className?: string; className?: string;
isLoading?: boolean;
}; };
type ToolButtonProps = type ToolButtonProps =
@@ -34,11 +33,6 @@ type ToolButtonProps =
children?: React.ReactNode; children?: React.ReactNode;
onClick?(event: React.MouseEvent): void; onClick?(event: React.MouseEvent): void;
}) })
| (ToolButtonBaseProps & {
type: "submit";
children?: React.ReactNode;
onClick?(event: React.MouseEvent): void;
})
| (ToolButtonBaseProps & { | (ToolButtonBaseProps & {
type: "icon"; type: "icon";
children?: React.ReactNode; children?: React.ReactNode;
@@ -67,11 +61,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else {
console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
@@ -90,14 +82,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const lastPointerTypeRef = useRef<PointerType | null>(null); const lastPointerTypeRef = useRef<PointerType | null>(null);
if ( if (props.type === "button" || props.type === "icon") {
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return ( return (
<button <button
className={clsx( className={clsx(
@@ -117,10 +102,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
hidden={props.hidden} hidden={props.hidden}
title={props.title} title={props.title}
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
type={type} type="button"
onClick={onClick} onClick={onClick}
ref={innerRef} ref={innerRef}
disabled={isLoading || props.isLoading} disabled={isLoading}
> >
{(props.icon || props.label) && ( {(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true"> <div className="ToolIcon__icon" aria-hidden="true">
@@ -130,7 +115,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
{props.keyBindingLabel} {props.keyBindingLabel}
</span> </span>
)} )}
{props.isLoading && <Spinner />}
</div> </div>
)} )}
{props.showAriaLabel && ( {props.showAriaLabel && (

View File

@@ -6,9 +6,20 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
position: relative; position: relative;
font-family: Cascadia;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor);
user-select: none; user-select: none;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
} }
.ToolIcon--plain { .ToolIcon--plain {
@@ -19,20 +30,6 @@
} }
} }
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
}
.ToolIcon__icon { .ToolIcon__icon {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
@@ -42,11 +39,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: var(--border-radius-lg); border-radius: var(--space-factor);
& + .ToolIcon__label {
margin-inline-start: 0;
}
svg { svg {
position: relative; position: relative;
@@ -54,6 +47,10 @@
fill: var(--icon-fill-color); fill: var(--icon-fill-color);
color: var(--icon-fill-color); color: var(--icon-fill-color);
} }
& + .ToolIcon__label {
margin-inline-start: 0;
}
} }
.ToolIcon__label { .ToolIcon__label {
@@ -83,7 +80,7 @@
margin: 0; margin: 0;
font-size: inherit; font-size: inherit;
&:focus-visible { &:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 2px var(--focus-highlight-color);
} }
@@ -125,7 +122,7 @@
} }
} }
&:focus-visible + .ToolIcon__icon { &:focus + .ToolIcon__icon {
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 2px var(--focus-highlight-color);
} }
@@ -145,6 +142,10 @@
background-color: transparent; background-color: transparent;
} }
&:focus {
box-shadow: none;
}
.ToolIcon__icon { .ToolIcon__icon {
background-color: var(--button-gray-1); background-color: var(--button-gray-1);
&:hover { &:hover {
@@ -159,6 +160,13 @@
} }
} }
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
.ToolIcon__keybinding { .ToolIcon__keybinding {
position: absolute; position: absolute;
bottom: 2px; bottom: 2px;
@@ -219,10 +227,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 {

View File

@@ -1,124 +0,0 @@
@import "open-color/open-color.scss";
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
.excalidraw {
.App-toolbar-container {
.ToolIcon_type_floating {
@include toolbarButtonColorStates;
&:not(.is-mobile) {
.ToolIcon__icon {
padding: 1px;
background-color: var(--island-bg-color);
box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
border-radius: 50%;
transition: box-shadow 0.5s ease, transform 0.5s ease;
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:focus-within + .ToolIcon__icon {
// override for custom floating button shadow
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
}
.ToolIcon__hidden {
box-shadow: none !important;
background-color: transparent !important;
pointer-events: none !important;
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
.ToolIcon__library {
margin-inline-start: var(--space-factor);
}
&.zen-mode {
.ToolIcon_type_floating {
.ToolIcon__icon {
box-shadow: none;
transform: scale(0.9);
}
.ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
& + .ToolIcon__icon {
svg {
fill: $oc-gray-5;
color: $oc-gray-5;
}
}
}
}
}
}
.App-toolbar {
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
.ToolIcon {
&:hover {
--icon-fill-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
--keybinding-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
}
&:active {
--icon-fill-color: #{$oc-gray-9};
--keybinding-color: #{$oc-gray-9};
}
.ToolIcon__icon {
background: transparent;
border-radius: var(--border-radius-lg);
}
@include toolbarButtonColorStates;
}
&.zen-mode {
.ToolIcon__keybinding,
.HintViewer {
display: none;
}
}
}
&.theme--dark .App-toolbar .ToolIcon:active {
--icon-fill-color: #{$oc-gray-3};
--keybinding-color: #{$oc-gray-3};
}
}

View File

@@ -29,6 +29,7 @@
// wraps the element we want to apply the tooltip to // wraps the element we want to apply the tooltip to
.excalidraw-tooltip-wrapper { .excalidraw-tooltip-wrapper {
display: flex; display: flex;
height: 100%;
} }
.excalidraw-tooltip-icon { .excalidraw-tooltip-icon {

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,27 +27,51 @@ 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 = {
children: React.ReactNode; children: React.ReactNode;
label: string; label: string;
long?: boolean; long?: boolean;
style?: React.CSSProperties;
}; };
export const Tooltip = ({ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
children,
label,
long = false,
style,
}: TooltipProps) => {
useEffect(() => { useEffect(() => {
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"
@@ -106,7 +86,6 @@ export const Tooltip = ({
onPointerLeave={() => onPointerLeave={() =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible") getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
} }
style={style}
> >
{children} {children}
</div> </div>

View File

@@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component<
for (const [key, value] of Object.entries({ ...localStorage })) { for (const [key, value] of Object.entries({ ...localStorage })) {
try { try {
_localStorage[key] = JSON.parse(value); _localStorage[key] = JSON.parse(value);
} catch (error: any) { } catch (error) {
_localStorage[key] = value; _localStorage[key] = value;
} }
} }
@@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component<
) )
).default; ).default;
body = encodeURIComponent(templateStrFn(this.state.sentryEventId)); body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component<
try { try {
localStorage.clear(); localStorage.clear();
window.location.reload(); window.location.reload();
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }
}} }}

View File

@@ -7,10 +7,6 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
&:empty {
display: none;
}
} }
.UserList > * { .UserList > * {

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