Compare commits

..

15 Commits

Author SHA1 Message Date
dwelle
2bf886c941 show autosave checkbox only if fileHandle exists 2021-04-06 21:34:18 +02:00
dwelle
6215256787 Merge branch 'master' into kb/auto-save-support
# Conflicts:
#	src/tests/__snapshots__/regressionTests.test.tsx.snap
2021-04-06 21:30:48 +02:00
dwelle
35d195e891 update snaps 2021-04-04 15:32:55 +02:00
dwelle
9d3d7f3500 update copy 2021-04-04 15:16:29 +02:00
dwelle
0b32757085 prevent autosave from prompting on failure & granularize error messages 2021-04-04 15:13:56 +02:00
dwelle
6442a45bd4 Merge branch 'master' into kb/auto-save-support 2021-04-04 14:47:38 +02:00
dwelle
d7a015cb3a autoSaveautosave 2021-03-26 21:49:43 +01:00
dwelle
f68404fbed handle error properly and display error message 2021-03-26 21:42:27 +01:00
kbariotis
01f5914a82 auto save only when its supported 2021-03-22 21:59:10 +02:00
kbariotis
5e1e16c150 Merge branch 'master' into kb/auto-save-support 2021-03-22 21:56:30 +02:00
kbariotis
14537cbaba update tests 2021-03-20 16:26:22 +02:00
kbariotis
92ac11c49d add eror handler, update snapshots 2021-03-20 16:22:57 +02:00
kbariotis
90d68b3e0b move to export dialog 2021-03-20 16:01:06 +02:00
kbariotis
006aad052d update tests 2021-02-14 23:55:17 +02:00
kbariotis
98a7707e26 initial commit 2021-02-14 23:42:54 +02:00
327 changed files with 11587 additions and 42280 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://portal.excalidraw.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'

View File

@@ -1,8 +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_SOCKET_SERVER_URL=http://localhost:3002
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,11 +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_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"}'
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

View File

@@ -5,4 +5,3 @@ package-lock.json
firebase/
dist/
public/workbox
src/packages/excalidraw/types

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
name: Auto release @excalidraw/excalidraw-next
on:
push:
branches:
- master
jobs:
Auto-release-excalidraw-next:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release
run: |
yarn add @actions/core
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 }}"

View File

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

6
.gitignore vendored
View File

@@ -5,11 +5,9 @@
.env.test.local
.envrc
.eslintcache
.history
.idea
.vercel
.vscode
.yarn
*.log
*.tgz
build
@@ -23,7 +21,3 @@ static
yarn-debug.log*
yarn-error.log*
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

@@ -10,7 +10,7 @@ ARG NODE_ENV=production
COPY . .
RUN yarn build:app:docker
FROM nginx:1.21-alpine
FROM nginx:1.17-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html

View File

@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server thats go
The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages.
> Note: Please ensure that the encryption key is 22 characters long.
## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
#### Requirements
- [Node.js](https://nodejs.org/en/)
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
- [Yarn](https://yarnpkg.com/getting-started/install)
- [Git](https://git-scm.com/downloads)
#### Clone the repo
@@ -104,24 +102,6 @@ These instructions will get you a copy of the project up and running on your loc
git clone https://github.com/excalidraw/excalidraw.git
```
#### Install the dependencies
```bash
yarn
```
#### Start the server
```bash
yarn start
```
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Collaboration
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
#### Commands
| Command | Description |

View File

@@ -2,8 +2,5 @@
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
}
}

View File

@@ -1,12 +0,0 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{migrations} {
match /{scenes}/{scene} {
allow get, write: if true;
// redundant, but let's be explicit'
allow list: if false;
}
}
}
}

View File

@@ -19,28 +19,22 @@
]
},
"dependencies": {
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.2",
"@tldraw/vec": "1.4.3",
"@types/jest": "27.4.0",
"@types/pica": "5.1.3",
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
"@sentry/browser": "6.2.2",
"@sentry/integrations": "6.2.1",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.5",
"@types/jest": "26.0.22",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.2",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.23.0",
"browser-fs-access": "0.16.2",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"firebase": "8.2.10",
"i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.32",
"open-color": "1.9.1",
"nanoid": "3.1.22",
"open-color": "1.8.0",
"pako": "1.0.11",
"perfect-freehand": "1.0.16",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
@@ -49,37 +43,36 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.49.7",
"roughjs": "4.3.1",
"sass": "1.32.8",
"socket.io-client": "2.3.1",
"typescript": "4.5.5"
"typescript": "4.2.3"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3",
"@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6",
"dotenv": "10.0.0",
"eslint-config-prettier": "8.3.0",
"@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.23.0",
"husky": "7.0.4",
"firebase-tools": "9.6.1",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.3",
"lint-staged": "10.5.4",
"pepjs": "0.5.3",
"prettier": "2.5.1",
"prettier": "2.2.1",
"rewire": "5.0.0"
},
"resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2"
},
"engines": {
"node": ">=14.0.0"
},
"homepage": ".",
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
@@ -101,7 +94,6 @@
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
@@ -111,7 +103,6 @@
"test:other": "yarn prettier --list-different",
"test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app",
"autorelease": "node scripts/autorelease.js"
"test": "yarn test:app"
}
}

Binary file not shown.

View File

@@ -107,17 +107,15 @@
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
body,
html {
body {
margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%;
width: 100%;
height: 100%;
overflow: hidden;
width: 100vw;
height: 100vh;
}
.visually-hidden {
@@ -127,7 +125,6 @@
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
user-select: none;
}
.LoadingMessage {
@@ -152,21 +149,6 @@
}
#root {
height: 100%;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@media screen and (min-width: 1200px) {
-webkit-touch-callout: default;
-webkit-user-select: auto;
-khtml-user-select: auto;
-moz-user-select: auto;
-ms-user-select: auto;
user-select: auto;
}
}
</style>
</head>

View File

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

View File

@@ -1,77 +0,0 @@
const fs = require("fs");
const { exec, execSync } = require("child_process");
const core = require("@actions/core");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const publish = () => {
try {
execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`);
console.info("Published 🎉");
core.setOutput(
"result",
`**Preview version has been shipped** :rocket:
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
);
} catch (error) {
core.setOutput("result", "package couldn't be published :warning:!");
console.error(error);
process.exit(1);
}
};
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
core.setOutput("result", ":warning: Package couldn't be published!");
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => {
return (
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
!filesToIgnoreRegex.test(file)
);
});
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);
}
// update package.json
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(`${excalidrawDir}/README.md`, data, "utf8");
console.info("Publish in progress...");
publish();
});

View File

@@ -1,16 +1,11 @@
const { readdirSync, writeFileSync } = require("fs");
const files = readdirSync(`${__dirname}/../src/locales`);
const flatten = (object = {}, result = {}, extraKey = "") => {
for (const key in object) {
if (typeof object[key] !== "object") {
result[extraKey + key] = object[key];
} else {
flatten(object[key], result, `${extraKey}${key}.`);
}
}
return result;
};
const flatten = (object) =>
Object.keys(object).reduce(
(initial, current) => ({ ...initial, ...object[current] }),
{},
);
const locales = files.filter(
(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 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(

View File

@@ -5,13 +5,10 @@ const THRESSHOLD = 85;
const crowdinMap = {
"ar-SA": "en-ar",
"bg-BG": "en-bg",
"bn-BD": "en-bn",
"ca-ES": "en-ca",
"da-DK": "en-da",
"de-DE": "en-de",
"el-GR": "en-el",
"es-ES": "en-es",
"eu-ES": "en-eu",
"fa-IR": "en-fa",
"fi-FI": "en-fi",
"fr-FR": "en-fr",
@@ -34,28 +31,18 @@ const crowdinMap = {
"pt-PT": "en-pt",
"ro-RO": "en-ro",
"ru-RU": "en-ru",
"si-LK": "en-silk",
"sk-SK": "en-sk",
"sv-SE": "en-sv",
"ta-IN": "en-ta",
"tr-TR": "en-tr",
"uk-UA": "en-uk",
"zh-CN": "en-zhcn",
"zh-HK": "en-zhhk",
"zh-TW": "en-zhtw",
"lt-LT": "en-lt",
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
};
const flags = {
"ar-SA": "🇸🇦",
"bg-BG": "🇧🇬",
"bn-BD": "🇧🇩",
"ca-ES": "🏳",
"cs-CZ": "🇨🇿",
"da-DK": "🇩🇰",
"de-DE": "🇩🇪",
"el-GR": "🇬🇷",
"es-ES": "🇪🇸",
@@ -69,10 +56,7 @@ const flags = {
"it-IT": "🇮🇹",
"ja-JP": "🇯🇵",
"kab-KAB": "🏳",
"kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷",
"lt-LT": "🇱🇹",
"lv-LV": "🇱🇻",
"my-MM": "🇲🇲",
"nb-NO": "🇳🇴",
"nl-NL": "🇳🇱",
@@ -84,28 +68,21 @@ const flags = {
"pt-PT": "🇵🇹",
"ro-RO": "🇷🇴",
"ru-RU": "🇷🇺",
"si-LK": "🇱🇰",
"sk-SK": "🇸🇰",
"sv-SE": "🇸🇪",
"ta-IN": "🇮🇳",
"tr-TR": "🇹🇷",
"uk-UA": "🇺🇦",
"zh-CN": "🇨🇳",
"zh-HK": "🇭🇰",
"zh-TW": "🇹🇼",
};
const languages = {
"ar-SA": "العربية",
"bg-BG": "Български",
"bn-BD": "Bengali",
"ca-ES": "Català",
"cs-CZ": "Česky",
"da-DK": "Dansk",
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"es-ES": "Español",
"eu-ES": "Euskara",
"fa-IR": "فارسی",
"fi-FI": "Suomi",
"fr-FR": "Français",
@@ -116,10 +93,7 @@ const languages = {
"it-IT": "Italiano",
"ja-JP": "日本語",
"kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu",
"my-MM": "Burmese",
"nb-NO": "Norsk bokmål",
"nl-NL": "Nederlands",
@@ -131,14 +105,11 @@ const languages = {
"pt-PT": "Português",
"ro-RO": "Română",
"ru-RU": "Русский",
"si-LK": "සිංහල",
"sk-SK": "Slovenčina",
"sv-SE": "Svenska",
"ta-IN": "Tamil",
"tr-TR": "Türkçe",
"uk-UA": "Українська",
"zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文",
};

View File

@@ -1,39 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateReadme = require("./updateReadme");
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const release = async (nextVersion) => {
try {
updateReadme();
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
release(nextVersion);

View File

@@ -1,104 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const lastVersion = pkg.version;
const existingChangeLog = fs.readFileSync(
`${excalidrawDir}/CHANGELOG.md`,
"utf8",
);
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
const headerForType = {
feat: "Features",
fix: "Fixes",
style: "Styles",
refactor: " Refactor",
perf: "Performance",
build: "Build",
};
const badCommits = [];
const getCommitHashForLastVersion = async () => {
try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
const { stdout } = await exec(
`git log --format=format:"%H" --grep=${commitMessage}`,
);
return stdout;
} catch (error) {
console.error(error);
}
};
const getLibraryCommitsSinceLastRelease = async () => {
const commitHash = await getCommitHashForLastVersion();
const { stdout } = await exec(
`git log --pretty=format:%s ${commitHash}...master`,
);
const commitsSinceLastRelease = stdout.split("\n");
const commitList = {};
supportedTypes.forEach((type) => {
commitList[type] = [];
});
commitsSinceLastRelease.forEach((commit) => {
const indexOfColon = commit.indexOf(":");
const type = commit.slice(0, indexOfColon);
if (!supportedTypes.includes(type)) {
return;
}
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
const messageWithCapitalizeFirst =
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
const prMatch = commit.match(/\(#([0-9]*)\)/);
if (prMatch) {
const prNumber = prMatch[1];
// return if the changelog already contains the pr number which would happen for package updates
if (existingChangeLog.includes(prNumber)) {
return;
}
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
} else {
badCommits.push(commit);
commitList[type].push(messageWithCapitalizeFirst);
}
});
console.info("Bad commits:", badCommits);
return commitList;
};
const updateChangelog = async (nextVersion) => {
const commitList = await getLibraryCommitsSinceLastRelease();
let changelogForLibrary =
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
supportedTypes.forEach((type) => {
if (commitList[type].length) {
changelogForLibrary += `### ${headerForType[type]}\n\n`;
const commits = commitList[type];
commits.forEach((commit) => {
changelogForLibrary += `- ${commit}\n\n`;
});
}
});
changelogForLibrary += "---\n";
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
let updatedContent =
existingChangeLog.slice(0, lastVersionIndex) +
changelogForLibrary +
existingChangeLog.slice(lastVersionIndex);
const currentDate = new Date().toISOString().slice(0, 10);
const newVersion = `## ${nextVersion} (${currentDate})`;
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
};
module.exports = updateChangelog;

View File

@@ -1,27 +0,0 @@
const fs = require("fs");
const updateReadme = () => {
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
// remove note for unstable release
data = data.replace(
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
"",
);
// replace "excalidraw-next" with "excalidraw"
data = data.replace(/excalidraw-next/g, "excalidraw");
data = data.trim();
const demoIndex = data.indexOf("### Demo");
const excalidrawNextNote =
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
// Add excalidraw next note to try out for unreleased changes
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
module.exports = updateReadme;

View File

@@ -2,58 +2,20 @@ import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { Library } from "../data/library";
export const actionAddToLibrary = register({
name: "addToLibrary",
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
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
.loadLibrary()
.then((items) => {
return app.library.saveLibrary([
{
id: randomId(),
status: "unpublished",
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,
},
};
});
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
return false;
},
contextItemLabel: "labels.addToLibrary",
});

View File

@@ -1,3 +1,4 @@
import React from "react";
import { alignElements, Alignment } from "../align";
import {
AlignBottomIcon,
@@ -8,13 +9,13 @@ import {
CenterVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
@@ -34,11 +35,9 @@ const alignSelectedElements = (
const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = arrayToMap(updatedElements);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
export const actionAlignTop = register({

View File

@@ -1,29 +1,28 @@
import React from "react";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { zoomIn, zoomOut } from "../components/icons";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../is-mobile";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
appState: { ...appState, viewBackgroundColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ appState, updateData }) => {
@@ -33,11 +32,7 @@ export const actionChangeViewBackgroundColor = register({
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
isActive={appState.openPopup === "canvasColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "canvasColorPicker" : null })
}
onChange={(color) => updateData(color)}
data-testid="canvas-background-picker"
/>
</div>
@@ -47,48 +42,55 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
perform: (elements, appState, _, app) => {
app.imageCache.clear();
perform: (elements, appState: AppState) => {
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
files: {},
theme: appState.theme,
elementLocked: appState.elementLocked,
penMode: appState.penMode,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
elementType:
appState.elementType === "image" ? "selection" : appState.elementType,
},
commitToHistory: true,
};
},
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={() => {
if (window.confirm(t("alerts.clearReset"))) {
updateData(null);
}
}}
data-testid="clear-canvas-button"
/>
),
});
export const actionZoomIn = register({
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 {
appState: {
...appState,
...getStateForZoom(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
zoom,
},
commitToHistory: false,
};
@@ -102,7 +104,6 @@ export const actionZoomIn = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@@ -112,18 +113,18 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
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 {
appState: {
...appState,
...getStateForZoom(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
zoom,
},
commitToHistory: false,
};
@@ -137,7 +138,6 @@ export const actionZoomOut = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@@ -147,37 +147,33 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
perform: (_elements, appState, _, app) => {
perform: (_elements, appState) => {
return {
appState: {
...appState,
...getStateForZoom(
zoom: getNewZoom(
1 as NormalizedZoomValue,
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(1),
x: appState.width / 2,
y: appState.height / 2,
},
appState,
),
},
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
<ToolButton
type="button"
className="reset-zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
size="small"
>
{(appState.zoom.value * 100).toFixed(0)}%
</ToolButton>
</Tooltip>
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={resetZoom}
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
/>
),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
@@ -216,12 +212,14 @@ const zoomToFitElements = (
? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const newZoom = getNewZoom(zoomValue, appState.zoom, {
left: appState.offsetLeft,
top: appState.offsetTop,
});
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
@@ -262,28 +260,3 @@ export const actionZoomToFit = register({
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionToggleTheme = register({
name: "toggleTheme",
perform: (_, appState, value) => {
return {
appState: {
...appState,
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
updateData(theme);
}}
/>
</div>
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});

View File

@@ -9,8 +9,8 @@ import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
perform: (elements, appState, _, app) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
return {
commitToHistory: false,
@@ -25,7 +25,7 @@ export const actionCut = register({
name: "cut",
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",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
@@ -42,7 +42,6 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
@@ -51,13 +50,13 @@ export const actionCopyAsSvg = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
app.canvas,
appState,
);
return {
commitToHistory: false,
};
} catch (error: any) {
} catch (error) {
console.error(error);
return {
appState: {
@@ -82,7 +81,6 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
@@ -91,7 +89,7 @@ export const actionCopyAsPng = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
app.canvas,
appState,
);
return {
@@ -108,7 +106,7 @@ export const actionCopyAsPng = register({
},
commitToHistory: false,
};
} catch (error: any) {
} catch (error) {
console.error(error);
return {
appState: {

View File

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

View File

@@ -1,16 +1,17 @@
import React from "react";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../disitrubte";
import { getNonDeletedElements } from "../element";
import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
@@ -30,11 +31,9 @@ const distributeSelectedElements = (
const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = arrayToMap(updatedElements);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
export const distributeHorizontally = register({

View File

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

View File

@@ -1,25 +1,17 @@
import React from "react";
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { load, questionCircle, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useIsMobile } from "../is-mobile";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import { supported } from "browser-fs-access";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -39,54 +31,6 @@ export const actionChangeProjectName = register({
),
});
export const actionChangeExportScale = register({
name: "changeExportScale",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
const elements = getNonDeletedElements(allElements);
const exportSelected = isSomeElementSelected(elements, appState);
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
: elements;
return (
<>
{EXPORT_SCALES.map((s) => {
const [width, height] = getExportSize(
exportedElements,
DEFAULT_EXPORT_PADDING,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="small"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === appState.exportScale}
onChange={() => updateData(s)}
/>
);
})}
</>
);
},
});
export const actionChangeExportBackground = register({
name: "changeExportBackground",
perform: (_elements, appState, value) => {
@@ -96,12 +40,14 @@ export const actionChangeExportBackground = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
<label>
<input
type="checkbox"
checked={appState.exportBackground}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.withBackground")}
</CheckboxItem>
</label>
),
});
@@ -114,35 +60,57 @@ export const actionChangeExportEmbedScene = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
<label style={{ display: "flex" }}>
<input
type="checkbox"
checked={appState.exportEmbedScene}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
<Tooltip
label={t("labels.exportEmbedScene_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
</label>
),
});
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
perform: async (elements, appState, value, app) => {
export const actionChangeShouldAddWatermark = register({
name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, shouldAddWatermark: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<label>
<input
type="checkbox"
checked={appState.shouldAddWatermark}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.addWatermark")}
</label>
),
});
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState, app.files)
: await saveAsJSON(elements, appState, app.files);
const { fileHandle } = await saveAsJSON(elements, appState);
return {
commitToHistory: false,
appState: {
...appState,
fileHandle,
toastMessage: fileHandleExists
? fileHandle?.name
? fileHandle.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
@@ -151,43 +119,40 @@ export const actionSaveToActiveFile = register({
: null,
},
};
} catch (error: any) {
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return { commitToHistory: false };
}
},
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => (
<ActiveFile
onSave={() => updateData(null)}
fileName={appState.fileHandle?.name}
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()}
onClick={() => updateData(null)}
data-testid="save-button"
/>
),
});
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
perform: async (elements, appState, value, app) => {
export const actionSaveAsScene = register({
name: "saveAsScene",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(
elements,
{
...appState,
fileHandle: null,
},
app.files,
);
const { fileHandle } = await saveAsJSON(elements, {
...appState,
fileHandle: null,
});
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error: any) {
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return { commitToHistory: false };
}
@@ -201,7 +166,7 @@ export const actionSaveFileToDisk = register({
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported}
hidden={!supported}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
@@ -210,28 +175,24 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
perform: async (elements, appState, _, app) => {
perform: async (elements, appState) => {
try {
const {
elements: loadedElements,
appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements);
} = await loadFromJSON(appState);
return {
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
};
} catch (error: any) {
} catch (error) {
if (error?.name === "AbortError") {
console.warn(error);
return false;
}
return {
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
};
}
@@ -268,12 +229,46 @@ export const actionExportWithDarkMode = register({
}}
>
<DarkModeToggle
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
value={appState.exportWithDarkMode ? "dark" : "light"}
onChange={(theme: Appearence) => {
updateData(theme === "dark");
}}
title={t("labels.toggleExportColorScheme")}
/>
</div>
),
});
export const actionToggleAutosave = register({
name: "toggleAutosave",
perform(elements, appState) {
trackEvent("toggle", "autosave");
return {
appState: {
...appState,
autosave: !appState.autosave,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) =>
supported && appState.fileHandle ? (
<label style={{ display: "flex" }}>
<input
type="checkbox"
checked={appState.autosave}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.toggleAutosave")}
<Tooltip
label={t("labels.toggleAutosave_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip>
</label>
) : (
<></>
),
});

View File

@@ -1,6 +1,7 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { resetCursor } from "../utils";
import React from "react";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -17,10 +18,13 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({
name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => {
perform: (elements, appState, _, { canvas }) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
const {
elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (element) {
@@ -46,25 +50,20 @@ export const actionFinalize = register({
}
let newElements = elements;
if (appState.pendingImageElement) {
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
window.document.activeElement.blur();
}
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.editingElement?.type === "freedraw"
: appState.editingElement?.type === "draw"
? appState.editingElement
: null;
if (multiPointElement) {
// pen and mouse have hover
if (
multiPointElement.type !== "freedraw" &&
multiPointElement.type !== "draw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = multiPointElement;
@@ -87,7 +86,7 @@ export const actionFinalize = register({
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw"
multiPointElement.type === "draw"
) {
if (isLoop) {
const linePoints = multiPointElement.points;
@@ -119,24 +118,22 @@ export const actionFinalize = register({
);
}
if (!appState.elementLocked && appState.elementType !== "freedraw") {
if (!appState.elementLocked && appState.elementType !== "draw") {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
(!appState.elementLocked && appState.elementType !== "freedraw") ||
(!appState.elementLocked && appState.elementType !== "draw") ||
!multiPointElement
) {
resetCursor(canvas);
}
return {
elements: newElements,
appState: {
...appState,
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
(appState.elementLocked || appState.elementType === "draw") &&
multiPointElement
? appState.elementType
: "selection",
@@ -148,15 +145,14 @@ export const actionFinalize = register({
selectedElementIds:
multiPointElement &&
!appState.elementLocked &&
appState.elementType !== "freedraw"
appState.elementType !== "draw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
pendingImageElement: null,
},
commitToHistory: appState.elementType === "freedraw",
commitToHistory: appState.elementType === "draw",
};
},
keyTest: (event, appState) =>

View File

@@ -1,15 +1,14 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { getElementMap, getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
@@ -84,11 +83,9 @@ const flipSelectedElements = (
flipDirection,
);
const updatedElementsMap = arrayToMap(updatedElements);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
const flipElements = (
@@ -96,13 +93,13 @@ const flipElements = (
appState: AppState,
flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
elements.forEach((element) => {
flipElement(element, appState);
for (let i = 0; i < elements.length; i++) {
flipElement(elements[i], appState);
// If vertical flip, rotate an extra 180
if (flipDirection === "vertical") {
rotateElement(element, Math.PI);
rotateElement(elements[i], Math.PI);
}
});
}
return elements;
};
@@ -117,7 +114,7 @@ const flipElement = (
const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) {
if (isLinearElement(element)) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
@@ -145,9 +142,10 @@ const flipElement = (
}
if (isLinearElement(element)) {
for (let index = 1; index < element.points.length; index++) {
LinearElementEditor.movePoints(element, [
{ index, point: [-element.points[index][0], element.points[index][1]] },
for (let i = 1; i < element.points.length; i++) {
LinearElementEditor.movePoint(element, i, [
-element.points[i][0],
element.points[i][1],
]);
}
LinearElementEditor.normalizePoints(element);

View File

@@ -1,6 +1,7 @@
import React from "react";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
@@ -17,9 +18,8 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@@ -45,7 +45,6 @@ const enableActionGroup = (
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -58,7 +57,6 @@ export const actionGroup = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.length < 2) {
// nothing to group
@@ -86,9 +84,8 @@ export const actionGroup = register({
}
}
const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => {
if (!selectElementIds.get(element.id)) {
if (!appState.selectedElementIds[element.id]) {
return element;
}
return newElementWith(element, {
@@ -103,8 +100,9 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex =
updatedElements.lastIndexOf(lastElementInGroup);
const lastGroupElementIndex = updatedElements.lastIndexOf(
lastElementInGroup,
);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex)
@@ -152,12 +150,7 @@ export const actionUngroup = register({
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
}
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
const nextGroupIds = removeFromSelectedGroups(
element.groupIds,
appState.selectedGroupIds,
@@ -169,19 +162,11 @@ export const actionUngroup = register({
groupIds: nextGroupIds,
});
});
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
);
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return {
appState: updateAppState,
appState: selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
elements: nextElements,
commitToHistory: true,
};

View File

@@ -1,14 +1,15 @@
import { Action, ActionResult } from "./types";
import React from "react";
import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
const writeData = (
prevElements: readonly ExcalidrawElement[],
@@ -27,17 +28,17 @@ const writeData = (
return { commitToHistory };
}
const prevElementMap = arrayToMap(prevElements);
const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
@@ -58,7 +59,7 @@ const writeData = (
return { commitToHistory };
};
type ActionCreator = (history: History) => Action;
type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={undo}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={redo}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,

View File

@@ -1,3 +1,4 @@
import React from "react";
import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
@@ -69,10 +70,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();
}
perform: (_elements, appState) => {
return {
appState: {
...appState,

View File

@@ -1,3 +1,4 @@
import React from "react";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, data }) => {
const clientId: string | undefined = data?.id;
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
if (!clientId) {
return null;
}

View File

@@ -1,3 +1,4 @@
import React from "react";
import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker";
@@ -6,20 +7,12 @@ import {
ArrowheadArrowIcon,
ArrowheadBarIcon,
ArrowheadDotIcon,
ArrowheadTriangleIcon,
ArrowheadNoneIcon,
EdgeRoundIcon,
EdgeSharpIcon,
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
@@ -27,67 +20,52 @@ import {
StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
TextAlignCenterIcon,
FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
} from "../constants";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import {
getNonDeletedElements,
isTextElement,
redrawTextBoundingBox,
} from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
isBoundToContainer,
isLinearElement,
isLinearElementType,
} from "../element/typeChecks";
import { newElementWith } from "../element/mutateElement";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
import {
Arrowhead,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
FontFamily,
TextAlign,
} from "../element/types";
import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random";
import {
canChangeSharpness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements,
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap } from "../utils";
import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const changeProperty = (
elements: readonly ExcalidrawElement[],
appState: AppState,
callback: (element: ExcalidrawElement) => ExcalidrawElement,
includeBoundText = false,
) => {
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText),
);
return elements.map((element) => {
if (
selectedElementIds.get(element.id) ||
appState.selectedElementIds[element.id] ||
element.id === appState.editingElement?.id
) {
return callback(element);
@@ -117,103 +95,17 @@ 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({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(
elements,
appState,
(el) => {
return hasStrokeColor(el.type)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
},
true,
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeColor: value,
}),
),
appState: { ...appState, currentItemStrokeColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -228,11 +120,7 @@ export const actionChangeStrokeColor = register({
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
onChange={updateData}
/>
</>
),
@@ -242,18 +130,13 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value,
}),
),
appState: { ...appState, currentItemBackgroundColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -268,11 +151,7 @@ export const actionChangeBackgroundColor = register({
(element) => element.backgroundColor,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
isActive={appState.openPopup === "backgroundColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
}
onChange={updateData}
/>
</>
),
@@ -521,7 +400,24 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({
name: "changeFontSize",
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 }) => (
<fieldset>
@@ -533,40 +429,27 @@ export const actionChangeFontSize = register({
value: 16,
text: t("labels.small"),
icon: <FontSizeSmallIcon theme={appState.theme} />,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: <FontSizeMediumIcon theme={appState.theme} />,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: <FontSizeLargeIcon theme={appState.theme} />,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) => isTextElement(element) && element.fontSize,
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
@@ -575,71 +458,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({
name: "changeFontFamily",
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontFamily: value,
});
redrawTextBoundingBox(element);
return element;
}
return oldElement;
},
true,
),
return el;
}),
appState: {
...appState,
currentItemFontFamily: value,
@@ -648,23 +481,19 @@ export const actionChangeFontFamily = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
}[] = [
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
{
value: FONT_FAMILY.Virgil,
value: 1,
text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Helvetica,
value: 2,
text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Cascadia,
value: 3,
text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />,
},
@@ -673,22 +502,13 @@ export const actionChangeFontFamily = register({
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false>
<ButtonIconSelect<FontFamily | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
(element) => isTextElement(element) && element.fontFamily,
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
@@ -702,29 +522,17 @@ export const actionChangeTextAlign = register({
name: "changeTextAlign",
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
textAlign: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
textAlign: value,
});
redrawTextBoundingBox(element);
return element;
}
return oldElement;
},
true,
),
return el;
}),
appState: {
...appState,
currentItemTextAlign: value,
@@ -757,16 +565,7 @@ export const actionChangeTextAlign = register({
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.textAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.textAlign;
}
return null;
},
(element) => isTextElement(element) && element.textAlign,
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
@@ -911,14 +710,6 @@ export const actionChangeArrowhead = register({
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r",
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "t",
},
]}
value={getFormValue<Arrowhead | null>(
elements,
@@ -961,14 +752,6 @@ export const actionChangeArrowhead = register({
keyBinding: "r",
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
),
keyBinding: "t",
},
]}
value={getFormValue<Arrowhead | null>(
elements,

View File

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

View File

@@ -12,7 +12,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getContainerElement } from "../element/textElement";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -56,18 +55,13 @@ export const actionPasteStyles = register({
opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness,
});
if (isTextElement(newElement) && isTextElement(element)) {
if (isTextElement(newElement)) {
mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
});
redrawTextBoundingBox(
element,
getContainerElement(element),
appState,
);
redrawTextBoundingBox(newElement);
}
return newElement;
}

View File

@@ -1,5 +1,4 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
@@ -14,6 +13,4 @@ export const actionToggleStats = register({
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});

View File

@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};

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

@@ -26,7 +26,6 @@ export {
actionZoomOut,
actionResetZoom,
actionZoomToFit,
actionToggleTheme,
} from "./actionCanvas";
export { actionFinalize } from "./actionFinalize";
@@ -34,8 +33,9 @@ export { actionFinalize } from "./actionFinalize";
export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveToActiveFile,
actionSaveFileToDisk,
actionToggleAutosave,
actionSaveScene,
actionSaveAsScene,
actionLoadScene,
} from "./actionExport";
@@ -80,5 +80,3 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText } from "./actionUnbindText";
export { actionLink } from "../element/Hyperlink";

View File

@@ -5,12 +5,15 @@ import {
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { AppProps, AppState } from "../types";
import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +21,13 @@ export class ActionManager implements ActionsManagerInterface {
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: AppClassProperties;
app: App;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: AppClassProperties,
app: App,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
@@ -48,7 +51,7 @@ export class ActionManager implements ActionsManagerInterface {
actions.forEach((action) => this.registerAction(action));
}
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
handleKeyDown(event: KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
@@ -98,10 +101,11 @@ export class ActionManager implements ActionsManagerInterface {
);
}
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@@ -129,8 +133,8 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
id={id}
appProps={this.app.props}
data={data}
/>
);
}

View File

@@ -2,9 +2,7 @@ import { Action } from "./types";
export let actions: readonly Action[] = [];
export const register = <T extends Action>(action: T) => {
export const register = (action: Action): Action => {
actions = actions.concat(action);
return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
};
return action;
};

View File

@@ -25,8 +25,7 @@ export type ShortcutName =
| "addToLibrary"
| "viewMode"
| "flipHorizontal"
| "flipVertical"
| "link";
| "flipVertical";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -58,12 +57,11 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [getShortcutKey("Alt+/")],
stats: [],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
link: [getShortcutKey("CtrlOrCmd+K")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@@ -1,12 +1,6 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
} from "../types";
import { ToolButtonSize } from "../components/ToolButton";
import { AppState, ExcalidrawProps } from "../types";
/** if false, the action should be prevented */
export type ActionResult =
@@ -16,10 +10,8 @@ export type ActionResult =
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
replaceFiles?: boolean;
}
| false;
@@ -27,7 +19,7 @@ type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppClassProperties,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
@@ -53,13 +45,13 @@ export type ActionName =
| "changeBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
| "toggleAutosave"
| "toggleEditMenu"
| "undo"
| "redo"
@@ -67,9 +59,9 @@ export type ActionName =
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
| "saveFileToDisk"
| "changeShouldAddWatermark"
| "saveScene"
| "saveAsScene"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
@@ -100,37 +92,25 @@ export type ActionName =
| "flipHorizontal"
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "link";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>;
};
| "exportWithDarkMode";
export interface Action {
name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>;
PanelComponent?: React.FC<{
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
id?: string;
}>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,
event: KeyboardEvent,
appState: AppState,
elements: readonly ExcalidrawElement[],
) => boolean;
contextItemLabel?:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
) => string);
contextItemLabel?: string;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
@@ -141,7 +121,6 @@ export interface Action {
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
handleKeyDown: (event: KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
}

View File

@@ -1,7 +1,13 @@
import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
import { getCommonBounds } from "./element";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment {
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 = (
group: ExcalidrawElement[],
selectionBoundingBox: Box,
@@ -60,3 +88,8 @@ const calculateTranslation = (
(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,23 +3,18 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
THEME,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
: 1;
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> => {
return {
theme: THEME.LIGHT,
autosave: false,
theme: "light",
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: "transparent",
@@ -43,11 +38,8 @@ export const getDefaultAppState = (): Omit<
editingLinearElement: null,
elementLocked: false,
elementType: "selection",
penMode: false,
penDetected: false,
errorMessage: null,
exportBackground: true,
exportScale: defaultExportScale,
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
@@ -61,7 +53,6 @@ export const getDefaultAppState = (): Omit<
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null,
openPopup: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
@@ -71,6 +62,7 @@ export const getDefaultAppState = (): Omit<
selectedElementIds: {},
selectedGroupIds: {},
selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false,
@@ -79,12 +71,8 @@ export const getDefaultAppState = (): Omit<
toastMessage: null,
viewBackgroundColor: oc.white,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,
},
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
pendingImageElement: null,
showHyperlinkPopup: false,
};
};
@@ -98,89 +86,78 @@ const APP_STATE_STORAGE_CONF = (<
browser: boolean;
/** whether to keep when exporting to file/database */
export: boolean;
/** server (shareLink/collab/...) */
server: boolean;
},
T extends Record<keyof AppState, Values>,
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({
theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false },
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemLinearStrokeSharpness: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { 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 },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: false, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
lastPointerDownWith: { browser: true, export: false, server: false },
multiElement: { browser: false, export: false, server: false },
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
toastMessage: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
T extends Record<keyof AppState, Values>
>(
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
autosave: { browser: true, export: false },
theme: { browser: true, export: false },
collaborators: { browser: false, export: false },
currentChartType: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
cursorButton: { browser: true, export: false },
draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false },
elementType: { browser: true, export: false },
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
exportWithDarkMode: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false },
isResizing: { browser: false, export: false },
isRotating: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false },
multiElement: { browser: false, export: false },
name: { browser: true, export: false },
offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false },
pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
scrolledOutside: { browser: true, export: false },
scrollX: { browser: true, export: false },
scrollY: { browser: true, export: false },
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
startBoundElement: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
toastMessage: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
});
const _clearAppStateForStorage = <
ExportType extends "export" | "browser" | "server",
>(
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
appState: Partial<AppState>,
exportType: ExportType,
) => {
@@ -193,10 +170,8 @@ const _clearAppStateForStorage = <
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key];
if (propConfig?.[exportType]) {
const nextValue = appState[key];
// https://github.com/microsoft/TypeScript/issues/31445
(stateForExport as any)[key] = nextValue;
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
}
}
return stateForExport;
@@ -209,7 +184,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export");
};
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};

View File

@@ -3,26 +3,17 @@ import {
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types";
import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { canvasToBlob } from "./data/blob";
import { EXPORT_DATA_TYPES } from "./constants";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
};
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
errorMessage?: string;
}
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
@@ -40,7 +31,7 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
): contents is { elements: ExcalidrawElement[] } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
@@ -56,26 +47,17 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
// select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
try {
PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json);
} catch (error: any) {
} catch (error) {
PREFER_APP_CLIPBOARD = true;
console.error(error);
}
@@ -88,7 +70,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
try {
return JSON.parse(CLIPBOARD);
} catch (error: any) {
} catch (error) {
console.error(error);
return {};
}
@@ -128,7 +110,12 @@ const getSystemClipboard = async (
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
): Promise<ClipboardData> => {
): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
@@ -150,10 +137,7 @@ export const parseClipboard = async (
try {
const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) {
return {
elements: systemClipboardData.elements,
files: systemClipboardData.files,
};
return { elements: systemClipboardData.elements };
}
return appClipboardData;
} catch {
@@ -166,9 +150,10 @@ export const parseClipboard = async (
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
new window.ClipboardItem({ "image/png": blob }),
]);
};
@@ -180,7 +165,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
// not focused
await navigator.clipboard.writeText(text || "");
copied = true;
} catch (error: any) {
} catch (error) {
console.error(error);
}
}
@@ -220,7 +205,7 @@ const copyTextViaExecCommand = (text: string) => {
textarea.setSelectionRange(0, textarea.value.length);
success = document.execCommand("copy");
} catch (error: any) {
} catch (error) {
console.error(error);
}

View File

@@ -1,16 +1,15 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useIsMobile } from "../is-mobile";
import {
canChangeSharpness,
canHaveArrowheads,
getTargetElements,
hasBackground,
hasStrokeStyle,
hasStrokeWidth,
hasStroke,
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
@@ -18,7 +17,6 @@ import { AppState, Zoom } from "../types";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
export const SelectedShapeActions = ({
appState,
@@ -49,36 +47,16 @@ export const SelectedShapeActions = ({
hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type));
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
return (
<div className="panelColumn">
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(elementType) ||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(elementType === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
{(hasStroke(elementType) ||
targetElements.some((element) => hasStroke(element.type))) && (
<>
{renderAction("changeStrokeWidth")}
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
@@ -150,15 +128,14 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
)}
{!isEditing && targetElements.length > 0 && (
{!isMobile && !isEditing && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!isMobile && renderAction("duplicateSelection")}
{!isMobile && renderAction("deleteSelectedElements")}
{renderAction("duplicateSelection")}
{renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{targetElements.length === 1 && renderAction("link")}
</div>
</fieldset>
)}
@@ -166,24 +143,31 @@ export const SelectedShapeActions = ({
);
};
const LIBRARY_ICON = (
// fa-th-large
<svg viewBox="0 0 512 512">
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
</svg>
);
export const ShapesSwitcher = ({
canvas,
elementType,
setAppState,
onImageAction,
isLibraryOpen,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
isLibraryOpen: boolean;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
: `${index + 1}`;
const letter = typeof key === "string" ? key : key[0];
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
index + 1
}`;
return (
<ToolButton
className="Shape"
@@ -197,20 +181,31 @@ export const ShapesSwitcher = ({
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
onChange={({ pointerType }) => {
onChange={() => {
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, value);
if (value === "image") {
onImageAction({ pointerType });
}
setAppState({});
}}
/>
);
})}
<ToolButton
className="Shape ToolIcon_type_button__library"
type="button"
icon={LIBRARY_ICON}
name="editor-library"
keyBindingLabel="9"
aria-keyshortcuts="9"
title={`${capitalizeString(t("toolBar.library"))} — 9`}
aria-label={capitalizeString(t("toolBar.library"))}
onClick={() => {
setAppState({ isLibraryOpen: !isLibraryOpen });
}}
/>
</>
);
@@ -223,9 +218,12 @@ export const ZoomActions = ({
}) => (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{renderAction("zoomOut")}
{renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);

View File

@@ -1,21 +0,0 @@
.excalidraw {
.ActiveFile {
.ActiveFile__fileName {
display: flex;
align-items: center;
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 9.3em;
}
svg {
width: 1.15em;
margin-inline-end: 0.3em;
transform: scaleY(0.9);
}
}
}
}

View File

@@ -1,28 +0,0 @@
import Stack from "../components/Stack";
import { ToolButton } from "../components/ToolButton";
import { save, file } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
type ActiveFileProps = {
fileName?: string;
onSave: () => void;
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<Stack.Row className="ActiveFile" gap={1} align="center">
<span className="ActiveFile__fileName">
{file}
<span>{fileName}</span>
</span>
<ToolButton
type="icon"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={onSave}
data-testid="save-button"
/>
</Stack.Row>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
import { DarkModeToggle } from "./DarkModeToggle";
export const BackgroundPickerAndDarkModeToggle = ({
appState,
@@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
}) => (
<div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
{showThemeBtn && actionManager.renderAction("toggleTheme")}
{showThemeBtn && (
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
setAppState({ theme });
}}
/>
</div>
)}
</div>
);

View File

@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
@@ -7,7 +8,7 @@ export const ButtonIconSelect = <T extends Object>({
onChange,
group,
}: {
options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
options: { value: T; text: string; icon: JSX.Element }[];
value: T | null;
onChange: (value: T) => void;
group: string;
@@ -24,7 +25,6 @@ export const ButtonIconSelect = <T extends Object>({
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>

View File

@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({

View File

@@ -1,57 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Card {
display: flex;
flex-direction: column;
align-items: center;
max-width: 290px;
margin: 1em;
text-align: center;
.Card-icon {
font-size: 2.6em;
display: flex;
flex: 0 0 auto;
padding: 1.4rem;
border-radius: 50%;
background: var(--card-color);
color: $oc-white;
svg {
width: 2.8rem;
height: 2.8rem;
}
}
.Card-details {
font-size: 0.96em;
min-height: 90px;
padding: 0 1em;
margin-bottom: auto;
}
& .Card-button.ToolIcon_type_button {
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
background-color: var(--card-color);
&:hover {
background-color: var(--card-color-darker);
}
&:active {
background-color: var(--card-color-darkest);
}
.ToolIcon__label {
color: $oc-white;
}
.Spinner {
--spinner-color: #fff;
}
}
}
}

View File

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

View File

@@ -1,89 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Checkbox {
margin: 4px 0.3em;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$oc-blue-4};
}
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
svg {
display: block;
opacity: 0.3;
}
}
&:active {
.Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
}
}
&:hover {
.Checkbox-box {
background-color: fade-out($oc-blue-1, 0.8);
}
}
&.is-checked {
.Checkbox-box {
background-color: #{$oc-blue-1};
svg {
display: block;
}
}
&:hover .Checkbox-box {
background-color: #{$oc-blue-2};
}
}
.Checkbox-box {
width: 22px;
height: 22px;
padding: 0;
flex: 0 0 auto;
margin: 0 1em;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px #{$oc-blue-7};
background-color: transparent;
border-radius: 4px;
color: #{$oc-blue-7};
&:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7};
}
svg {
display: none;
width: 16px;
height: 16px;
stroke-width: 3px;
}
}
.Checkbox-label {
display: flex;
align-items: center;
}
.excalidraw-tooltip-icon {
width: 1em;
height: 1em;
}
}
}

View File

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

View File

@@ -1,43 +0,0 @@
import { useState } from "react";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
import ConfirmDialog from "./ConfirmDialog";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false);
const toggleDialog = () => {
setShowDialog(!showDialog);
};
return (
<>
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={toggleDialog}
data-testid="clear-canvas-button"
/>
{showDialog && (
<ConfirmDialog
onConfirm={() => {
onConfirm();
toggleDialog();
}}
onCancel={toggleDialog}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
)}
</>
);
};
export default ClearCanvas;

View File

@@ -1,7 +1,8 @@
import React from "react";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useIsMobile } from "../is-mobile";
import { users } from "./icons";
import "./CollabButton.scss";

View File

@@ -160,7 +160,7 @@
}
.color-picker-input {
width: 11ch; /* length of `transparent` */
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
background-color: var(--input-bg-color);
@@ -218,7 +218,7 @@
left: 2px;
}
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@@ -1,6 +1,5 @@
import React from "react";
import { Popover } from "./Popover";
import { isTransparent } from "../utils";
import "./ColorPicker.scss";
import { isArrowKey, KEYS } from "../keys";
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
};
const getColor = (color: string): string | null => {
if (isTransparent(color)) {
if (color === "transparent") {
return color;
}
@@ -116,7 +115,6 @@ const Picker = ({
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
return (
@@ -138,41 +136,36 @@ const Picker = ({
}}
tabIndex={0}
>
{colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
return (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${t(`colors.${_colorWithoutHash}`)}${
!isTransparent(_color) ? ` (${_color})` : ""
}${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>
);
})}
{colors.map((_color, i) => (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${_color}${keyBindings[i].toUpperCase()}`}
aria-label={_color}
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);
}}
>
{_color === "transparent" ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBindings[i]}</span>
</button>
))}
{showInput && (
<ColorInput
color={color}
@@ -244,16 +237,13 @@ export const ColorPicker = ({
color,
onChange,
label,
isActive,
setActive,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null;
onChange: (color: string) => void;
label: string;
isActive: boolean;
setActive: (active: boolean) => void;
}) => {
const [isActive, setActive] = React.useState(false);
const pickerButton = React.useRef<HTMLButtonElement>(null);
return (

View File

@@ -1,37 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.confirm-dialog {
&-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: 0.8rem;
padding: 0 0.5rem;
}
&__content {
font-size: 1rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-8;
}
.ToolIcon__icon {
color: $oc-white;
}
}
}
}

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

@@ -76,7 +76,7 @@
z-index: 1;
}
@include isMobile {
@media #{$is-mobile-query} {
.context-menu-option {
display: block;

View File

@@ -1,3 +1,4 @@
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
@@ -11,7 +12,6 @@ import {
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
export type ContextMenuOption = "separator" | Action;
@@ -22,7 +22,6 @@ type ContextMenuProps = {
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
};
const ContextMenu = ({
@@ -32,7 +31,6 @@ const ContextMenu = ({
left,
actionManager,
appState,
elements,
}: ContextMenuProps) => {
return (
<Popover
@@ -40,10 +38,6 @@ const ContextMenu = ({
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
>
<ul
className="context-menu"
@@ -55,14 +49,9 @@ const ContextMenu = ({
}
const actionName = option.name;
let label = "";
if (option.contextItemLabel) {
if (typeof option.contextItemLabel === "function") {
label = t(option.contextItemLabel(elements, appState));
} else {
label = t(option.contextItemLabel);
}
}
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
@@ -109,7 +98,6 @@ type ContextMenuParams = {
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
};
const handleClose = (container: HTMLElement) => {
@@ -138,7 +126,6 @@ export default {
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
elements={params.elements}
/>,
getContextMenuNode(params.container),
);

View File

@@ -1,32 +1,42 @@
import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { THEME } from "../constants";
import { Theme } from "../element/types";
export type Appearence = "light" | "dark";
// We chose to use only explicit toggle and not a third option for system value,
// but this could be added in the future.
export const DarkModeToggle = (props: {
value: Theme;
onChange: (value: Theme) => void;
value: Appearence;
onChange: (value: Appearence) => void;
title?: string;
}) => {
const title =
props.title ||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
const title = props.title
? props.title
: props.value === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode");
return (
<ToolButton
type="icon"
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() =>
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
<label
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
data-testid="toggle-dark-mode"
/>
title={title}
>
<input
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
onChange={(event) =>
props.onChange(event.target.checked ? "dark" : "light")
}
checked={props.value === "dark"}
aria-label={title}
/>
<div className="ToolIcon__icon">
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
</div>
</label>
);
};

View File

@@ -31,7 +31,7 @@
padding: 0 16px 16px;
}
@include isMobile {
@media #{$is-mobile-query} {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};

View File

@@ -1,30 +1,23 @@
import clsx from "clsx";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useExcalidrawContainer, useIsMobile } from "../components/App";
import { useIsMobile } from "../is-mobile";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
export interface DialogProps {
export const Dialog = (props: {
children: React.ReactNode;
className?: string;
small?: boolean;
onCloseRequest(): void;
title: React.ReactNode;
autofocus?: boolean;
theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}
export const Dialog = (props: DialogProps) => {
}) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
useEffect(() => {
if (!islandNode) {
@@ -72,26 +65,19 @@ export const Dialog = (props: DialogProps) => {
return focusableElements ? Array.from(focusableElements) : [];
};
const onClose = () => {
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};
return (
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={props.small ? 550 : 800}
onCloseRequest={onClose}
theme={props.theme}
closeOnClickOutside={props.closeOnClickOutside}
onCloseRequest={props.onCloseRequest}
>
<Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
onClick={onClose}
onClick={props.onCloseRequest}
aria-label={t("buttons.close")}
>
{useIsMobile() ? back : close}

View File

@@ -2,7 +2,6 @@ import React, { useState } from "react";
import { t } from "../i18n";
import { Dialog } from "./Dialog";
import { useExcalidrawContainer } from "./App";
export const ErrorDialog = ({
message,
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
onClose?: () => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(!!message);
const { container: excalidrawContainer } = useExcalidrawContainer();
const handleClose = React.useCallback(() => {
setModalIsShown(false);
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
if (onClose) {
onClose();
}
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
excalidrawContainer?.focus();
}, [onClose, excalidrawContainer]);
}, [onClose]);
return (
<>

View File

@@ -28,7 +28,34 @@
justify-content: space-between;
}
@include isMobile {
.ExportDialog__name {
grid-column: project-name;
margin: auto;
display: flex;
align-items: center;
.TextInput {
height: calc(1rem - 3px);
width: 200px;
overflow: hidden;
text-align: center;
margin-left: 8px;
text-overflow: ellipsis;
&--readonly {
background: none;
border: none;
&:hover {
background: none;
}
width: auto;
max-width: 200px;
padding-left: 2px;
}
}
}
@media #{$is-mobile-query} {
.ExportDialog {
display: flex;
flex-direction: column;
@@ -57,63 +84,4 @@
overflow-y: auto;
}
}
.ExportDialog--json {
.ExportDialog-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
justify-items: center;
row-gap: 2em;
@media (max-width: 460px) {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
.Card-details {
min-height: 40px;
}
}
.ProjectName {
width: fit-content;
margin: 1em auto;
align-items: flex-start;
flex-direction: column;
.TextInput {
width: auto;
}
}
.ProjectName-label {
margin: 0.625em 0;
font-weight: bold;
}
}
}
button.ExportDialog-imageExportButton {
width: 5rem;
height: 5rem;
margin: 0 0.2em;
border-radius: 1rem;
background-color: var(--button-color);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
0 6px 10px 0 rgba(0, 0, 0, 0.14);
font-family: Cascadia;
font-size: 1.8em;
color: $oc-white;
&:hover {
background-color: var(--button-color-darker);
}
&:active {
background-color: var(--button-color-darkest);
box-shadow: none;
}
svg {
width: 0.9em;
}
}
}

View File

@@ -0,0 +1,286 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "../is-mobile";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import "./ExportDialog.scss";
import { clipboard, exportFile, link } from "./icons";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
export const ErrorCanvasPreview = () => {
return (
<div>
<h3>{t("canvasError.cannotShowPreview")}</h3>
<p>
<span>{t("canvasError.canvasTooBig")}</span>
</p>
<em>({t("canvasError.canvasTooBigTip")})</em>
</div>
);
};
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const ExportModal = ({
elements,
appState,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
onCloseRequest: () => void;
}) => {
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const {
exportBackground,
viewBackgroundColor,
shouldAddWatermark,
} = appState;
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
: elements;
useEffect(() => {
setExportSelected(someElementIsSelected);
}, [someElementIsSelected]);
useEffect(() => {
const previewNode = previewRef.current;
if (!previewNode) {
return;
}
try {
const canvas = exportToCanvas(exportedElements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
canvasToBlob(canvas)
.then(() => {
renderPreview(canvas, previewNode);
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
});
} catch (error) {
console.error(error);
renderPreview(new CanvasError(), previewNode);
}
}, [
appState,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
scale,
shouldAddWatermark,
]);
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<Stack.Col gap={2} align="center">
<div className="ExportDialog__actions">
<Stack.Row gap={2}>
<ToolButton
type="button"
label="PNG"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements, scale)}
/>
<ToolButton
type="button"
label="SVG"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements, scale)}
/>
{probablySupportsClipboardBlob && (
<ToolButton
type="button"
icon={clipboard}
title={t("buttons.copyPngToClipboard")}
aria-label={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements, scale)}
/>
)}
{onExportToBackend && (
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
)}
</Stack.Row>
<div className="ExportDialog__name">
{actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2}>
{scales.map((s) => {
const [width, height] = getExportSize(
exportedElements,
exportPadding,
shouldAddWatermark,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="s"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === scale}
onChange={() => setScale(s)}
/>
);
})}
</Stack.Row>
</div>
{actionManager.renderAction("toggleAutosave")}
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<div>
<label>
<input
type="checkbox"
checked={exportSelected}
onChange={(event) =>
setExportSelected(event.currentTarget.checked)
}
/>{" "}
{t("labels.onlySelected")}
</label>
</div>
)}
{actionManager.renderAction("changeExportEmbedScene")}
{actionManager.renderAction("changeShouldAddWatermark")}
</Stack.Col>
</div>
);
};
export const ExportDialog = ({
elements,
appState,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
triggerButton.current?.focus();
}, []);
return (
<>
<ToolButton
onClick={() => {
setModalIsShown(true);
}}
data-testid="export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
title={t("buttons.export")}
ref={triggerButton}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<ExportModal
elements={elements}
appState={appState}
exportPadding={exportPadding}
actionManager={actionManager}
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose}
/>
</Dialog>
)}
</>
);
};

View File

@@ -1,5 +1,6 @@
.excalidraw {
.FixedSideContainer {
--margin: 0.25rem;
position: absolute;
pointer-events: none;
}
@@ -9,9 +10,9 @@
}
.FixedSideContainer_side_top {
left: var(--space-factor);
top: var(--space-factor);
right: var(--space-factor);
left: var(--margin);
top: var(--margin);
right: var(--margin);
z-index: 2;
}
@@ -22,16 +23,16 @@
/* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left {
left: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
left: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 1;
}
.FixedSideContainer_side_right {
right: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
right: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 3;
}
*/

View File

@@ -1,22 +1,15 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../constants";
import { Theme } from "../../element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
({ theme }: { theme: "light" | "dark" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
className="github-corner rtl-mirror"
>
<a
href="https://github.com/excalidraw/excalidraw"
@@ -26,18 +19,18 @@ export const GitHubCorner = React.memo(
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : oc.black}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : oc.black}
/>
</a>
</svg>

View File

@@ -153,19 +153,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.freedraw")}
shortcuts={["Shift + P", "X", "7"]}
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("Enter"),
t("helpDialog.doubleClick"),
]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
shortcuts={[
@@ -205,10 +196,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
/>
<Shortcut
label={t("toolBar.link")}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/>
</ShortcutIsland>
<ShortcutIsland caption={t("helpDialog.view")}>
<Shortcut
@@ -244,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
<Shortcut
label={t("labels.toggleTheme")}
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
<Shortcut
label={t("stats.title")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
</ShortcutIsland>
</Column>
<Column>
@@ -264,18 +243,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.multiSelect")}
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
label={t("labels.moveCanvas")}
shortcuts={[
@@ -390,22 +357,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]}
/>
<Shortcut
label={t("labels.showStroke")}
shortcuts={[getShortcutKey("S")]}
/>
<Shortcut
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland>
</Column>
</Columns>

View File

@@ -1,3 +1,4 @@
import React from "react";
import { questionCircle } from "../components/icons";
type HelpIconProps = {

View File

@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
color: $oc-gray-6;
font-size: 0.8rem;
@include isMobile {
@media #{$is-mobile-query} {
position: static;
padding-right: 2em;
}

View File

@@ -1,27 +1,21 @@
import React from "react";
import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
import { AppState } from "../types";
import {
isImageElement,
isLinearElement,
isTextBindableContainer,
isTextElement,
} from "../element/typeChecks";
import { isLinearElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface HintViewerProps {
interface Hint {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
}
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const getHints = ({ appState, elements }: Hint) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (elementType === "arrow" || elementType === "line") {
if (!multiMode) {
return t("hints.linearElement");
@@ -29,7 +23,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.linearElementMulti");
}
if (elementType === "freedraw") {
if (elementType === "draw") {
return t("hints.freeDraw");
}
@@ -37,12 +31,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text");
}
if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage");
}
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
lastPointerDownWith === "mouse" &&
@@ -52,62 +41,29 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle");
}
return isImageElement(targetElement)
? t("hints.resizeImage")
: t("hints.resize");
return t("hints.resize");
}
if (isRotating && lastPointerDownWith === "mouse") {
return t("hints.rotate");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing");
}
if (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");
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");
}
return null;
};
export const HintViewer = ({
appState,
elements,
isMobile,
}: HintViewerProps) => {
export const HintViewer = ({ appState, elements }: Hint) => {
let hint = getHints({
appState,
elements,
isMobile,
});
if (!hint) {
return null;

View File

@@ -22,7 +22,7 @@
align-items: center;
justify-content: center;
&:focus-visible {
&:focus {
outline: transparent;
background-color: var(--button-gray-2);
& svg {
@@ -90,7 +90,7 @@
.picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(3, auto);
grid-auto-flow: column;
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
@@ -111,7 +111,7 @@
:root[dir="rtl"] & {
left: 2px;
}
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@@ -88,7 +88,6 @@ function Picker<T>({
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
return (

View File

@@ -1,273 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss";
import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
export const ErrorCanvasPreview = () => {
return (
<div>
<h3>{t("canvasError.cannotShowPreview")}</h3>
<p>
<span>{t("canvasError.canvasTooBig")}</span>
</p>
<em>({t("canvasError.canvasTooBigTip")})</em>
</div>
);
};
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const ExportButton: React.FC<{
color: keyof OpenColor;
onClick: () => void;
title: string;
shade?: number;
}> = ({ children, title, onClick, color, shade = 6 }) => {
return (
<button
className="ExportDialog-imageExportButton"
style={{
["--button-color" as any]: OpenColor[color][shade],
["--button-color-darker" as any]: OpenColor[color][shade + 1],
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
}}
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</button>
);
};
const ImageExportModal = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onCloseRequest: () => void;
}) => {
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
: elements;
useEffect(() => {
setExportSelected(someElementIsSelected);
}, [someElementIsSelected]);
useEffect(() => {
const previewNode = previewRef.current;
if (!previewNode) {
return;
}
exportToCanvas(exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
})
.then((canvas) => {
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => {
renderPreview(canvas, previewNode);
});
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
});
}, [
appState,
files,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
]);
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
// dunno why this is needed, but when the items wrap it creates
// an overflow
overflow: "hidden",
}}
>
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<CheckboxItem
checked={exportSelected}
onChange={(checked) => setExportSelected(checked)}
>
{t("labels.onlySelected")}
</CheckboxItem>
)}
{actionManager.renderAction("changeExportEmbedScene")}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
<Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")}
</Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: ".6em 0",
}}
>
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton
color="indigo"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements)}
>
PNG
</ExportButton>
<ExportButton
color="red"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements)}
>
SVG
</ExportButton>
{probablySupportsClipboardBlob && (
<ExportButton
title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)}
color="gray"
shade={7}
>
{clipboard}
</ExportButton>
)}
</Stack.Row>
</div>
);
};
export const ImageExportDialog = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
}, []);
return (
<>
<ToolButton
onClick={() => {
setModalIsShown(true);
}}
data-testid="image-export-button"
icon={exportImage}
type="button"
aria-label={t("buttons.exportImage")}
showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
<ImageExportModal
elements={elements}
appState={appState}
files={files}
exportPadding={exportPadding}
actionManager={actionManager}
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard}
onCloseRequest={handleClose}
/>
</Dialog>
)}
</>
);
};

View File

@@ -1,25 +1,30 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { LoadingMessage } from "./LoadingMessage";
import { defaultLang, Language, languages, setLanguage } from "../i18n";
interface Props {
langCode: Language["code"];
children: React.ReactElement;
}
interface State {
isLoading: boolean;
}
export class InitializeApp extends React.Component<Props, State> {
public state: { isLoading: boolean } = {
isLoading: true,
};
export const InitializeApp = (props: Props) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
};
async componentDidMount() {
const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang();
setLoading(false);
}, [props.langCode]);
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguage(currentLang);
this.setState({
isLoading: false,
});
}
return loading ? <LoadingMessage /> : props.children;
};
public render() {
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
}
}

View File

@@ -2,8 +2,9 @@
.Island {
--padding: 0;
background-color: var(--island-bg-color);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
border-radius: 4px;
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;

View File

@@ -1,135 +0,0 @@
import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const JSONExportModal = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const { onExportToBackend } = exportOpts;
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
{exportOpts.saveFileToDisk && (
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
}}
/>
</Card>
)}
{onExportToBackend && (
<Card color="pink">
<div className="Card-icon">{link}</div>
<h2>{t("exportDialog.link_title")}</h2>
<div className="Card-details">{t("exportDialog.link_details")}</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() =>
onExportToBackend(elements, appState, files, canvas)
}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, files, canvas)}
</div>
</div>
);
};
export const JSONExportDialog = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
}: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
}, []);
return (
<>
<ToolButton
onClick={() => {
setModalIsShown(true);
}}
data-testid="json-export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
title={t("buttons.export")}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onCloseRequest={handleClose}
exportOpts={exportOpts}
canvas={canvas}
/>
</Dialog>
)}
</>
);
};

View File

@@ -1,20 +1,89 @@
@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;
}
.layer-ui__library-items {
max-height: 50vh;
overflow: auto;
}
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
&__top-right {
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--space-factor);
color: $oc-green-9;
svg {
width: 1.2rem;
height: 1.2rem;
}
}
&__github-corner {
top: 0;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
position: absolute;
width: 40px;
}
&__footer {
width: 100%;
position: absolute;
z-index: 100;
bottom: 0;
&-right {
z-index: 100;
display: flex;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
width: 190px;
}
.zen-mode-transition {
@@ -36,15 +105,11 @@
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(-76px, 0);
:root[dir="ltr"] &.App-menu_bottom--transition-left {
transform: translate(-92px, 0);
}
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(76px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
transform: translate(0, 92px);
:root[dir="rtl"] &.App-menu_bottom--transition-left {
transform: translate(92px, 0);
}
}
@@ -72,27 +137,5 @@
transition-delay: 0.8s;
}
}
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
}
.layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
.disable-zen-mode--visible {
pointer-events: all;
}
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right {
margin-top: auto;
margin-bottom: auto;
margin-inline-end: 1em;
}
}
}

View File

@@ -1,53 +1,61 @@
import clsx from "clsx";
import React, { useCallback } from "react";
import React, {
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants";
import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useIsMobile } from "../is-mobile";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import {
AppProps,
AppState,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner";
import { HintViewer } from "./HintViewer";
import { exportFile, load, shield, trash } from "./icons";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { LockIcon } from "./LockIcon";
import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
files: BinaryFiles;
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean;
@@ -55,116 +63,354 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: (
isMobile: boolean,
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
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 = ({
library,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
setAppState,
setLibraryItems,
libraryReturnUrl,
}: {
library: LibraryItems;
pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
}) => {
const isMobile = useIsMobile();
const numCells = library.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()
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
{!!library.length && (
<>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON()
.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([]);
}
}}
/>
</>
)}
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
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 >= library.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={x}>
<LibraryUnit
elements={library[y + x]}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, library[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,
setAppState,
libraryReturnUrl,
}: {
pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
}) => {
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<NodeJS.Timeout | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = setTimeout(() => {
resolve("loading");
}, 100);
}),
Library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, []);
const removeFromLibrary = useCallback(async (indexToRemove) => {
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
}, []);
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
[onAddToLibrary],
);
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
library={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl}
/>
)}
</Island>
);
};
const LayerUI = ({
actionManager,
appState,
files,
setAppState,
canvas,
elements,
onCollabButtonClick,
onLockToggle,
onPenModeToggle,
onInsertElements,
zenModeEnabled,
showExitZenModeBtn,
showThemeBtn,
toggleZenMode,
isCollaborating,
renderTopRightUI,
onExportToBackend,
renderCustomFooter,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
focusContainer,
library,
id,
onImageAction,
}: LayerUIProps) => {
const isMobile = useIsMobile();
const renderJSONExportDialog = () => {
const renderEncryptedIcon = () => (
<a
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
"zen-mode-visibility--hidden": zenModeEnabled,
})}
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield}
</Tooltip>
</a>
);
const renderExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
return (
<JSONExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
/>
);
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
return null;
}
const createExporter =
(type: ExportType): ExportCB =>
async (exportedElements) => {
const fileHandle = await exportCanvas(
type,
exportedElements,
appState,
files,
{
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
},
)
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
scale,
) => {
if (canvas) {
await exportCanvas(type, exportedElements, appState, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
scale,
shouldAddWatermark: appState.shouldAddWatermark,
})
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
setAppState({ errorMessage: error.message });
});
if (
appState.exportEmbedScene &&
fileHandle &&
isImageFileHandle(fileHandle)
) {
setAppState({ fileHandle });
}
};
}
};
return (
<ImageExportDialog
<ExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
onExportToClipboard={createExporter("clipboard")}
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
: undefined
}
/>
);
};
const Separator = () => {
return <div style={{ width: ".625em" }} />;
};
const renderViewModeCanvasActions = () => {
return (
<Section
@@ -178,15 +424,15 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
</Section>
);
};
const renderCanvasActions = () => (
<Section
heading="canvasActions"
@@ -199,12 +445,11 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("clearCanvas")}
<Separator />
{actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<Separator />
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
@@ -219,9 +464,6 @@ const LayerUI = ({
setAppState={setAppState}
showThemeBtn={showThemeBtn}
/>
{appState.fileHandle && (
<>{actionManager.renderAction("saveToActiveFile")}</>
)}
</Stack.Col>
</Island>
</Section>
@@ -240,8 +482,7 @@ const LayerUI = ({
style={{
// 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
// if active file name is displayed, subtracting 248 to account for its height
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
maxHeight: `${appState.height - 200}px`,
}}
>
<SelectedShapeActions
@@ -254,15 +495,12 @@ const LayerUI = ({
</Section>
);
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
}
setAppState({ isLibraryOpen: false });
}, [setAppState]);
const closeLibrary = useCallback(
(event) => {
setAppState({ isLibraryOpen: false });
},
[setAppState],
);
const deselectItems = useCallback(() => {
setAppState({
@@ -273,18 +511,12 @@ const LayerUI = ({
const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
onClose={closeLibrary}
pendingElements={getSelectedElements(elements, appState)}
onClickOutside={closeLibrary}
onInsertShape={onInsertElements}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={appState.theme}
files={files}
id={id}
appState={appState}
/>
) : null;
@@ -310,53 +542,27 @@ const LayerUI = ({
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row
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
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": zenModeEnabled,
})}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer
appState={appState}
elements={elements}
isMobile={isMobile}
/>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LibraryButton
appState={appState}
setAppState={setAppState}
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
@@ -364,32 +570,24 @@ const LayerUI = ({
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
})}
>
<UserList>
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(isMobile, appState)}
</div>
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
</div>
</FixedSideContainer>
);
@@ -397,73 +595,61 @@ const LayerUI = ({
const renderBottomAppMenu = () => {
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
<div
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
"App-menu_bottom--transition-left": zenModeEnabled,
})}
>
<div
className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
},
)}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{!viewModeEnabled && (
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
"transition-right disable-pointerEvents": zenModeEnabled,
},
)}
>
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
</footer>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{renderEncryptedIcon()}
</Section>
</Stack.Col>
</div>
);
};
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner theme={appState.theme} />
</aside>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
className={clsx("zen-mode-transition", {
"transition-right disable-pointerEvents": zenModeEnabled,
})}
>
{renderCustomFooter?.(false)}
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
</footer>
);
const dialogs = (
<>
{appState.isLoading && <LoadingMessage />}
@@ -474,11 +660,7 @@ const LayerUI = ({
/>
)}
{appState.showHelpDialog && (
<HelpDialog
onClose={() => {
setAppState({ showHelpDialog: false });
}}
/>
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
)}
{appState.pasteDialog.shown && (
<PasteChartDialog
@@ -503,19 +685,15 @@ const LayerUI = ({
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
exportButton={renderExportDialog()}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
/>
</>
) : (
@@ -530,6 +708,8 @@ const LayerUI = ({
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderGitHubCorner()}
{renderFooter()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
@@ -563,7 +743,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
prev.renderCustomFooter === next.renderCustomFooter &&
prev.langCode === next.langCode &&
prev.elements === next.elements &&
prev.files === next.files &&
keys.every((key) => prevAppState[key] === nextAppState[key])
);
};

View File

@@ -1,46 +0,0 @@
import React from "react";
import clsx from "clsx";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
const LIBRARY_ICON = (
<svg viewBox="0 0 576 512">
<path
fill="currentColor"
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
></path>
</svg>
);
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library",
`ToolIcon_size_medium`,
{
"is-mobile": isMobile,
},
)}
title={`${capitalizeString(t("toolBar.library"))} — 0`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name="editor-library"
onChange={(event) => {
setAppState({ isLibraryOpen: event.target.checked });
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
/>
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
</label>
);
};

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 {
.library-unit {
align-items: center;
@@ -9,26 +7,10 @@
position: relative;
width: 63px;
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 {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
@@ -40,9 +22,9 @@
max-width: 100%;
}
.library-unit__checkbox-container,
.library-unit__checkbox-container:hover,
.library-unit__checkbox-container:active {
.library-unit__removeFromLibrary,
.library-unit__removeFromLibrary:hover,
.library-unit__removeFromLibrary:active {
align-items: center;
background: none;
border: none;
@@ -50,35 +32,10 @@
display: flex;
justify-content: center;
margin: 0;
padding: 0.5rem;
padding: 0;
position: absolute;
left: 2rem;
bottom: 2rem;
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;
}
}
right: 5px;
top: 5px;
}
.library-unit__removeFromLibrary > svg {
@@ -86,37 +43,29 @@
width: 16px;
}
.library-unit__adder {
.library-unit__pulse {
transform: scale(1);
animation: library-unit__adder-animation 1s ease-in infinite;
animation: library-unit__pulse-animation 1s ease-in infinite;
}
.library-unit__adder {
position: absolute;
left: 40%;
top: 40%;
width: 2rem;
height: 2rem;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
margin-left: -10px;
margin-top: -10px;
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 {
cursor: pointer;
}
@keyframes library-unit__adder-animation {
@keyframes library-unit__pulse-animation {
0% {
transform: scale(0.85);
transform: scale(0.95);
}
50% {
@@ -124,7 +73,7 @@
}
100% {
transform: scale(0.85);
transform: scale(0.95);
}
}
}

View File

@@ -1,103 +1,82 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants";
import { useIsMobile } from "../components/App";
import { t } from "../i18n";
import { useIsMobile } from "../is-mobile";
import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
// fa-plus
const PLUS_ICON = (
<svg viewBox="0 0 1792 1792">
<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"
style={{
stroke: "#fff",
strokeWidth: 140,
}}
transform="translate(0 64)"
fill="currentColor"
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"
/>
</svg>
);
export const LibraryUnit = ({
id,
elements,
files,
isPending,
pendingElements,
onRemoveFromLibrary,
onClick,
selected,
onToggle,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
files: BinaryFiles;
isPending?: boolean;
elements?: LibraryItem;
pendingElements?: LibraryItem;
onRemoveFromLibrary: () => void;
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) {
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return;
}
(async () => {
if (!elements) {
return;
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
const svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
files,
);
node.innerHTML = svg.outerHTML;
})();
ref.current!.removeChild(child);
}
ref.current!.appendChild(svg);
const current = ref.current!;
return () => {
node.innerHTML = "";
current.removeChild(svg);
};
}, [elements, files]);
}, [elements, pendingElements]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
const adder = isPending && (
const adder = (isHovered || isMobile) && pendingElements && (
<div className="library-unit__adder">{PLUS_ICON}</div>
);
return (
<div
className={clsx("library-unit", {
"library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
"library-unit__active": elements || pendingElements,
})}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={clsx("library-unit__dragger", {
"library-unit__pulse": !!isPending,
"library-unit__pulse": !!pendingElements,
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
}
}
: undefined
}
onClick={!!elements || !!pendingElements ? onClick : undefined}
onDragStart={(event) => {
setIsHovered(false);
event.dataTransfer.setData(
@@ -107,12 +86,14 @@ export const LibraryUnit = ({
}}
/>
{adder}
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox"
/>
{elements && (isHovered || isMobile) && (
<button
className="library-unit__removeFromLibrary"
aria-label={t("labels.removeFromLibrary")}
onClick={onRemoveFromLibrary}
>
{close}
</button>
)}
</div>
);

View File

@@ -1,3 +1,4 @@
import React from "react";
import { t } from "../i18n";
export const LoadingMessage = () => {

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