Compare commits

..

3 Commits

Author SHA1 Message Date
dwelle 75e2d9e359 remove debug 2021-11-22 23:20:40 +01:00
dwelle 6592517122 import lazily 2021-11-22 22:11:10 +01:00
dwelle bd953a6287 feat: compress non-transparent PNGs as JPGs and allow larger dimensions 2021-11-22 17:39:59 +01:00
271 changed files with 9106 additions and 25673 deletions
+1 -6
View File
@@ -4,10 +4,5 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
REACT_APP_WS_SERVER_URL=http://localhost:3002
# set this only if using the collaboration workflow we use on excalidraw.com
REACT_APP_PORTAL_URL=
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
+1 -5
View File
@@ -4,11 +4,7 @@ REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL=
REACT_APP_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
@@ -23,5 +23,4 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release
run: |
yarn add @actions/core
yarn autorelease
-55
View File
@@ -1,55 +0,0 @@
name: Auto release preview @excalidraw/excalidraw-preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
+29
View File
@@ -0,0 +1,29 @@
name: Build packages
on:
push:
branches:
- master
pull_request:
jobs:
packages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Install dependencies
run: |
yarn --frozen-lockfile
yarn --cwd src/packages/excalidraw
yarn --cwd src/packages/utils
- name: Build @excalidraw/excalidraw
run: |
yarn --cwd src/packages/excalidraw run pack
- name: Build @excalidraw/utils
run: |
yarn --cwd src/packages/utils run pack
-4
View File
@@ -23,7 +23,3 @@ static
yarn-debug.log*
yarn-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
-1
View File
@@ -1,2 +1 @@
#!/bin/sh
yarn lint-staged
+8 -43
View File
@@ -32,10 +32,6 @@ Last but not least, we're thankful to these companies for offering their service
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
## Documentation
### Shortcuts
@@ -122,47 +118,16 @@ yarn start
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Collaboration
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
#### Commands
##### Install the dependencies
```
yarn
```
##### Run the project
```
yarn start
```
##### Reformat all files with Prettier
```
yarn fix
```
##### Run tests
```
yarn test
```
##### Update test snapshots
```
yarn test:update
```
##### Test for formatting with Prettier
```
yarn test:code
```
| Command | Description |
| ------------------ | --------------------------------- |
| `yarn` | Install the dependencies |
| `yarn start` | Run the project |
| `yarn fix` | Reformat all files with Prettier |
| `yarn test` | Run tests |
| `yarn test:update` | Update test snapshots |
| `yarn test:code` | Test for formatting with Prettier |
#### Docker Compose
+6 -5
View File
@@ -1,11 +1,12 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{files}/rooms/{room}/{file} {
allow get, write: if true;
}
match /{files}/shareLinks/{shareLink}/{file} {
allow get, write: if true;
match /{migrations} {
match /{scenes}/{scene} {
allow get, write: if true;
// redundant, but let's be explicit'
allow list: if false;
}
}
}
}
+16 -17
View File
@@ -21,24 +21,23 @@
"dependencies": {
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/jest-dom": "5.15.0",
"@testing-library/react": "12.1.2",
"@tldraw/vec": "1.4.3",
"@types/jest": "27.4.0",
"@tldraw/vec": "0.1.3",
"@types/jest": "27.0.2",
"@types/pica": "5.1.3",
"@types/react": "17.0.39",
"@types/react": "17.0.34",
"@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"browser-fs-access": "0.21.1",
"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",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"nanoid": "3.1.30",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.0.16",
@@ -50,31 +49,31 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.49.7",
"roughjs": "4.5.0",
"sass": "1.43.4",
"socket.io-client": "2.3.1",
"typescript": "4.5.5"
"typescript": "4.5.2"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/chai": "4.2.22",
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3",
"@types/pako": "1.0.2",
"@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6",
"dotenv": "10.0.0",
"chai": "4.3.4",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.22.0",
"husky": "7.0.4",
"jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.7",
"lint-staged": "12.0.1",
"pepjs": "0.5.3",
"prettier": "2.5.1",
"prettier": "2.4.1",
"rewire": "5.0.0"
},
"resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2"
"@typescript-eslint/typescript-estree": "5.3.0"
},
"engines": {
"node": ">=14.0.0"
+32 -4
View File
@@ -72,6 +72,12 @@
crossorigin="anonymous"
/>
<link
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
rel="preconnect"
crossorigin="anonymous"
/>
<link
rel="manifest"
href="manifest.json"
@@ -124,6 +130,26 @@
user-select: none;
}
.LoadingMessage {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
#root {
height: 100%;
-webkit-touch-callout: none;
@@ -132,10 +158,8 @@
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media screen and (min-width: 1200px) {
#root {
@media screen and (min-width: 1200px) {
-webkit-touch-callout: default;
-webkit-user-select: auto;
-khtml-user-select: auto;
@@ -152,6 +176,10 @@
<header>
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<div id="root">
<div class="LoadingMessage">
<span>Loading scene...</span>
</div>
</div>
</body>
</html>
+5 -30
View File
@@ -1,6 +1,5 @@
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`;
@@ -16,25 +15,18 @@ const publish = () => {
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/;
@@ -45,33 +37,16 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
);
});
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.version = `${pkg.version}-${getShortCommitHash()}`;
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");
// update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
console.info("Publish in progress...");
publish();
});
-5
View File
@@ -11,7 +11,6 @@ const crowdinMap = {
"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",
@@ -43,7 +42,6 @@ const crowdinMap = {
"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",
@@ -71,7 +69,6 @@ const flags = {
"kab-KAB": "🏳",
"kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷",
"lt-LT": "🇱🇹",
"lv-LV": "🇱🇻",
"my-MM": "🇲🇲",
"nb-NO": "🇳🇴",
@@ -105,7 +102,6 @@ const languages = {
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"es-ES": "Español",
"eu-ES": "Euskara",
"fa-IR": "فارسی",
"fi-FI": "Suomi",
"fr-FR": "Français",
@@ -118,7 +114,6 @@ const languages = {
"kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu",
"my-MM": "Burmese",
"nb-NO": "Norsk bokmål",
+11 -18
View File
@@ -20,7 +20,7 @@ const headerForType = {
perf: "Performance",
build: "Build",
};
const badCommits = [];
const getCommitHashForLastVersion = async () => {
try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
@@ -53,26 +53,19 @@ const getLibraryCommitsSinceLastRelease = async () => {
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];
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
// return if the changelog already contains the pr number which would happen for package updates
if (existingChangeLog.includes(prNumber)) {
return;
}
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
} else {
badCommits.push(commit);
commitList[type].push(messageWithCapitalizeFirst);
// 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);
});
console.info("Bad commits:", badCommits);
return commitList;
};
+7 -10
View File
@@ -7,14 +7,8 @@ import { t } from "../i18n";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.some((element) => element.type === "image")) {
if (elements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
appState: {
@@ -25,13 +19,16 @@ export const actionAddToLibrary = register({
}
return app.library
.getLatestLibrary()
.loadLibrary()
.then((items) => {
return app.library.setLibrary([
return app.library.saveLibrary([
{
id: randomId(),
status: "unpublished",
elements: selectedElements.map(deepCopyElement),
elements: getSelectedElements(
getNonDeletedElements(elements),
appState,
).map(deepCopyElement),
created: Date.now(),
},
...items,
+4 -14
View File
@@ -8,13 +8,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,16 +34,13 @@ 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({
name: "alignTop",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -73,7 +70,6 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -103,7 +99,6 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -133,8 +128,6 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -164,8 +157,6 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -191,7 +182,6 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
-136
View File
@@ -1,136 +0,0 @@
import { VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
getBoundTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
hasBoundTextElement,
isTextBindableContainer,
} from "../element/typeChecks";
import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {
const textElement =
isTextElement(selectedElements[0]) ||
isTextElement(selectedElements[1]);
let bindingContainer;
if (isTextBindableContainer(selectedElements[0])) {
bindingContainer = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[1])) {
bindingContainer = selectedElements[1];
}
if (
textElement &&
bindingContainer &&
getBoundTextElement(bindingContainer) === null
) {
return true;
}
}
return false;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
if (
isTextElement(selectedElements[0]) &&
isTextBindableContainer(selectedElements[1])
) {
textElement = selectedElements[0];
container = selectedElements[1];
} else {
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
});
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return {
elements: updatedElements,
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
};
},
});
+38 -112
View File
@@ -1,5 +1,5 @@
import { ColorPicker } from "../components/ColorPicker";
import { eraser, zoomIn, zoomOut } from "../components/icons";
import { zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
@@ -9,26 +9,24 @@ import { t } from "../i18n";
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, isEraserActive } from "../appState";
import { getDefaultAppState } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
PanelComponent: ({ appState, updateData }) => {
return (
<div style={{ position: "relative" }}>
<ColorPicker
@@ -41,8 +39,6 @@ export const actionChangeViewBackgroundColor = register({
updateData({ openPopup: active ? "canvasColorPicker" : null })
}
data-testid="canvas-background-picker"
elements={elements}
appState={appState}
/>
</div>
);
@@ -51,7 +47,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
@@ -62,17 +57,12 @@ export const actionClearCanvas = register({
...getDefaultAppState(),
files: {},
theme: appState.theme,
penMode: appState.penMode,
penDetected: appState.penDetected,
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
};
@@ -83,19 +73,17 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
trackEvent: { category: "canvas" },
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,
};
@@ -119,19 +107,18 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
trackEvent: { category: "canvas" },
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,
};
@@ -155,25 +142,25 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
trackEvent: { category: "canvas" },
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%" }}>
<Tooltip label={t("buttons.resetZoom")}>
<ToolButton
type="button"
className="reset-zoom-button"
@@ -225,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;
@@ -254,7 +243,6 @@ const zoomToFitElements = (
export const actionZoomToSelected = register({
name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
@@ -265,7 +253,6 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({
name: "zoomToFit",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
event.code === CODES.ONE &&
@@ -276,7 +263,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
appState: {
@@ -299,63 +285,3 @@ export const actionToggleTheme = register({
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});
export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.type !== "eraser") {
if (appState.activeTool.type === "custom") {
activeTool.lastActiveToolBeforeEraser = {
type: "custom",
customType: appState.activeTool.customType,
};
} else {
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
}
}
if (isEraserActive(appState)) {
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
) {
activeTool.type = "custom";
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
} else {
activeTool.type = "eraser";
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
},
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
type="button"
icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })}
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
}}
size={data?.size || "medium"}
></ToolButton>
),
});
+4 -48
View File
@@ -1,23 +1,16 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import {
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { copyToClipboard } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { getNonDeletedElements } from "../element";
import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files);
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
return {
commitToHistory: false,
@@ -30,10 +23,9 @@ export const actionCopy = register({
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
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,
@@ -41,7 +33,6 @@ export const actionCut = register({
export const actionCopyAsSvg = register({
name: "copyAsSvg",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
@@ -51,7 +42,6 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
@@ -82,7 +72,6 @@ export const actionCopyAsSvg = register({
export const actionCopyAsPng = register({
name: "copyAsPng",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
@@ -92,7 +81,6 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
@@ -132,35 +120,3 @@ export const actionCopyAsPng = register({
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
});
+13 -24
View File
@@ -11,7 +11,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 +21,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: {
@@ -58,12 +51,11 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
elementId,
selectedPointsIndices,
activePointIndex,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
@@ -73,7 +65,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
) {
@@ -93,17 +86,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,
@@ -112,15 +103,13 @@ 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);
fixBindingsAfterDeletion(
@@ -134,7 +123,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
activeTool: { ...appState.activeTool, type: "selection" },
elementType: "selection",
multiElement: null,
},
commitToHistory: isSomeElementSelected(
+7 -13
View File
@@ -4,13 +4,13 @@ import {
} 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, KEYS } from "../keys";
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,16 +30,13 @@ 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({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -50,8 +47,7 @@ export const distributeHorizontally = register({
commitToHistory: true,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
keyTest: (event) => event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
@@ -69,7 +65,6 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -80,8 +75,7 @@ export const distributeVertically = register({
commitToHistory: true,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
keyTest: (event) => event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
+32 -23
View File
@@ -2,12 +2,13 @@ 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,24 +18,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",
trackEvent: { category: "element" },
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,
};
}
@@ -88,12 +106,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
@@ -115,11 +130,7 @@ const duplicateElements = (
}
index++;
}
bindTextToShapeAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return {
@@ -129,9 +140,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),
},
+5 -11
View File
@@ -1,3 +1,4 @@
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
@@ -7,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { useDeviceType } from "../components/App";
import { useIsMobile } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
@@ -22,8 +23,8 @@ import { Theme } from "../element/types";
export const actionChangeProjectName = register({
name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData, appProps }) => (
@@ -40,7 +41,6 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({
name: "changeExportScale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
@@ -89,7 +89,6 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({
name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
@@ -108,7 +107,6 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
@@ -130,7 +128,6 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
@@ -175,7 +172,6 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
try {
const { fileHandle } = await saveAsJSON(
@@ -204,7 +200,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useDeviceType().isMobile}
showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"
@@ -214,7 +210,6 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
trackEvent: { category: "export" },
perform: async (elements, appState, _, app) => {
try {
const {
@@ -248,7 +243,7 @@ export const actionLoadScene = register({
icon={load}
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useDeviceType().isMobile}
showAriaLabel={useIsMobile()}
onClick={updateData}
data-testid="load-button"
/>
@@ -257,7 +252,6 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
+11 -34
View File
@@ -17,7 +17,6 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({
name: "finalize",
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
@@ -39,7 +38,6 @@ export const actionFinalize = register({
: undefined,
appState: {
...appState,
cursorButton: "up",
editingLinearElement: null,
},
commitToHistory: true,
@@ -121,47 +119,27 @@ export const actionFinalize = register({
);
}
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
if (!appState.elementLocked && appState.elementType !== "freedraw") {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
(!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") ||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
!multiPointElement
) {
resetCursor(canvas);
}
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
) {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
return {
elements: newElements,
appState: {
...appState,
cursorButton: "up",
activeTool:
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
multiPointElement
? appState.activeTool
: activeTool,
? appState.elementType
: "selection",
draggingElement: null,
multiElement: null,
editingElement: null,
@@ -169,8 +147,8 @@ export const actionFinalize = register({
suggestedBindings: [],
selectedElementIds:
multiPointElement &&
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
!appState.elementLocked &&
appState.elementType !== "freedraw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
@@ -178,7 +156,7 @@ export const actionFinalize = register({
: appState.selectedElementIds,
pendingImageElement: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
commitToHistory: appState.elementType === "freedraw",
};
},
keyTest: (event, appState) =>
@@ -187,7 +165,7 @@ export const actionFinalize = register({
(!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
PanelComponent: ({ appState, updateData }) => (
<ToolButton
type="button"
icon={done}
@@ -195,7 +173,6 @@ export const actionFinalize = register({
aria-label={t("buttons.done")}
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
/>
),
});
+8 -12
View File
@@ -1,6 +1,6 @@
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";
@@ -9,7 +9,6 @@ import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
@@ -35,7 +34,6 @@ const enableActionFlipVertical = (
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -51,7 +49,6 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "vertical"),
@@ -86,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 = (
@@ -147,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);
@@ -157,7 +153,7 @@ const flipElement = (
// calculate new x-coord for transformation
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
resizeSingleElement(
new Map().set(element.id, element),
element,
true,
element,
usingNWHandle ? "nw" : "ne",
+7 -26
View File
@@ -1,6 +1,6 @@
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 +17,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 +44,6 @@ const enableActionGroup = (
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -54,12 +52,10 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.length < 2) {
// nothing to group
@@ -87,9 +83,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, {
@@ -148,18 +143,12 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState);
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,
@@ -171,19 +160,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,
};
+5 -7
View File
@@ -6,9 +6,9 @@ import History, { 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 +27,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,
),
)
@@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) =>
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) =>
-4
View File
@@ -9,7 +9,6 @@ import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
@@ -30,7 +29,6 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
@@ -55,7 +53,6 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({
name: "toggleFullScreen",
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
allowFullScreen();
@@ -72,7 +69,6 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();
+5 -5
View File
@@ -1,4 +1,4 @@
import { getClientColors } from "../clients";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
@@ -6,7 +6,6 @@ import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {
@@ -43,15 +42,16 @@ export const actionGoToCollaborator = register({
}
const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""}
src={collaborator.src}
/>
>
{shortName}
</Avatar>
);
},
});
+100 -336
View File
@@ -30,31 +30,19 @@ import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
VERTICAL_ALIGN,
} 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,
@@ -62,37 +50,27 @@ import {
ExcalidrawTextElement,
FontFamilyValues,
TextAlign,
VerticalAlign,
} 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);
@@ -122,94 +100,18 @@ const getFormValue = function <T>(
);
};
const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
) => {
if (isBoundToContainer(nextElement)) {
return nextElement;
}
return mutateElement(
nextElement,
{
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
};
const changeFontSize = (
elements: readonly ExcalidrawElement[],
appState: AppState,
getNewFontSize: (element: ExcalidrawTextElement) => number,
fallbackValue?: ExcalidrawTextElement["fontSize"],
) => {
const newFontSizes = new Set<number>();
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
// update state only if we've set all select text elements to
// the same font size
currentItemFontSize:
newFontSizes.size === 1
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
};
};
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(
elements,
appState,
(el) => {
return hasStrokeColor(el.type)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
},
true,
),
elements: changeProperty(elements, appState, (el) => {
return hasStrokeColor(el.type)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
}),
}),
appState: {
...appState,
@@ -235,8 +137,6 @@ export const actionChangeStrokeColor = register({
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
),
@@ -244,7 +144,6 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
@@ -278,8 +177,6 @@ export const actionChangeBackgroundColor = register({
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
),
@@ -287,7 +184,6 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -337,7 +233,6 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -385,7 +280,6 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -434,7 +328,6 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -482,7 +375,6 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({
name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -503,6 +395,20 @@ export const actionChangeOpacity = register({
max="100"
step="10"
onChange={(event) => updateData(+event.target.value)}
onWheel={(event) => {
event.stopPropagation();
const target = event.target as HTMLInputElement;
const STEP = 10;
const MAX = 100;
const MIN = 0;
const value = +target.value;
if (event.deltaY < 0 && value < MAX) {
updateData(value + STEP);
} else if (event.deltaY > 0 && value > MIN) {
updateData(value - STEP);
}
}}
value={
getFormValue(
elements,
@@ -518,9 +424,25 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({
name: "changeFontSize",
trackEvent: false,
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>
@@ -532,40 +454,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)}
@@ -574,70 +483,21 @@ export const actionChangeFontSize = register({
),
});
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
// get previous value before relative increase (doesn't work fully
// due to rounding and float precision issues)
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.COMMA needed for MacOS
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
);
},
});
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.PERIOD needed for MacOS
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
);
},
});
export const actionChangeFontFamily = register({
name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value,
},
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
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,
@@ -677,16 +537,7 @@ export const actionChangeFontFamily = register({
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)}
@@ -698,26 +549,19 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{ textAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
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,
@@ -725,121 +569,42 @@ export const actionChangeTextAlign = register({
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: <TextAlignLeftIcon theme={appState.theme} />,
},
{
value: "center",
text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />,
},
{
value: "right",
text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.textAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.textAlign;
}
return null;
},
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{ verticalAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<ButtonIconSelect<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
},
]}
value={getFormValue(elements, appState, (element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
})}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: <TextAlignLeftIcon theme={appState.theme} />,
},
{
value: "center",
text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />,
},
{
value: "right",
text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => isTextElement(element) && element.textAlign,
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -847,10 +612,10 @@ export const actionChangeSharpness = register({
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.activeTool.type);
: !isLinearElementType(appState.elementType);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.activeTool.type);
: isLinearElementType(appState.elementType);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -890,8 +655,8 @@ export const actionChangeSharpness = register({
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.activeTool.type)
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
@@ -904,7 +669,6 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({
name: "changeArrowhead",
trackEvent: false,
perform: (
elements,
appState,
+2 -7
View File
@@ -1,11 +1,10 @@
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",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
if (appState.editingLinearElement) {
return false;
@@ -16,11 +15,7 @@ export const actionSelectAll = register({
...appState,
editingGroupId: null,
selectedElementIds: elements.reduce((map, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
element.locked === false
) {
if (!element.isDeleted) {
map[element.id] = true;
}
return map;
-71
View File
@@ -1,71 +0,0 @@
import ExcalidrawApp from "../excalidraw-app";
import { t } from "../i18n";
import { CODES } from "../keys";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { fireEvent, render, screen } from "../tests/test-utils";
import { copiedStyles } from "./actionStyles";
const { h } = window;
const mouse = new Pointer("mouse");
describe("actionStyles", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("should copy & paste styles via keyboard", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
// Change some styles of second rectangle
UI.clickLabeledElement("Stroke");
UI.clickLabeledElement(t("colors.c92a2a"));
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.e64980"));
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
fireEvent.click(screen.getByTitle("Bold"));
// Stroke style
fireEvent.click(screen.getByTitle("Dotted"));
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" },
});
mouse.reset();
API.setSelectedElements([h.elements[1]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C);
});
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
API.setSelectedElements([h.elements[0]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.V);
});
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
});
+2 -6
View File
@@ -12,14 +12,12 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getContainerElement } from "../element/textElement";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const element = elements.find((el) => appState.selectedElementIds[el.id]);
if (element) {
@@ -40,7 +38,6 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({
name: "pasteStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles);
if (!isExcalidrawElement(pastedElement)) {
@@ -58,14 +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(newElement, getContainerElement(newElement));
redrawTextBoundingBox(newElement);
}
return newElement;
}
+2 -4
View File
@@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,
-63
View File
@@ -1,63 +0,0 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
export const actionToggleLock = register({
name: "toggleLock",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
if (!selectedElements.length) {
return false;
}
const operation = getOperation(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: operation === "lock" });
}),
appState,
commitToHistory: true,
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false);
if (selected.length === 1) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
if (selected.length > 1) {
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
},
keyTest: (event, appState, elements) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0
);
},
});
const getOperation = (
elements: readonly ExcalidrawElement[],
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
-1
View File
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
trackEvent: { category: "menu" },
perform(elements, appState) {
return {
appState: {
+2 -4
View File
@@ -1,13 +1,11 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,
+3 -4
View File
@@ -1,13 +1,12 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
appState: {
...appState,
-5
View File
@@ -18,7 +18,6 @@ import {
export const actionSendBackward = register({
name: "sendBackward",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneLeft(elements, appState),
@@ -46,7 +45,6 @@ export const actionSendBackward = register({
export const actionBringForward = register({
name: "bringForward",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneRight(elements, appState),
@@ -74,7 +72,6 @@ export const actionBringForward = register({
export const actionSendToBack = register({
name: "sendToBack",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
@@ -109,8 +106,6 @@ export const actionSendToBack = register({
export const actionBringToFront = register({
name: "bringToFront",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),
-5
View File
@@ -17,7 +17,6 @@ export {
actionChangeFontSize,
actionChangeFontFamily,
actionChangeTextAlign,
actionChangeVerticalAlign,
} from "./actionProperties";
export {
@@ -75,13 +74,9 @@ export {
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
copyText,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";
+21 -59
View File
@@ -1,47 +1,18 @@
import React from "react";
import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import { trackEvent } from "../analytics";
const trackAction = (
action: Action,
source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any,
) => {
if (action.trackEvent) {
try {
if (typeof action.trackEvent === "object") {
const shouldTrack = action.trackEvent.predicate
? action.trackEvent.predicate(appState, elements, value)
: true;
if (shouldTrack) {
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
);
}
}
} catch (error) {
console.error("error while logging action:", error);
}
}
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -94,15 +65,9 @@ export class ActionManager {
),
);
if (data.length !== 1) {
if (data.length > 1) {
console.warn("Canceling as multiple actions match this shortcut", data);
}
if (data.length === 0) {
return false;
}
const action = data[0];
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
@@ -110,26 +75,27 @@ export class ActionManager {
}
}
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, "keyboard", appState, elements, this.app, null);
event.preventDefault();
event.stopPropagation();
this.updater(data[0].perform(elements, appState, value, this.app));
this.updater(
data[0].perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true;
}
executeAction(action: Action, source: ActionSource = "api") {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, source, appState, elements, this.app, value);
this.updater(action.perform(elements, appState, value, this.app));
executeAction(action: Action) {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
}
/**
@@ -147,11 +113,7 @@ export class ActionManager {
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
+2 -4
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;
};
+3 -10
View File
@@ -1,10 +1,8 @@
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
export type ShortcutName = SubtypeOf<
ActionName,
export type ShortcutName =
| "cut"
| "copy"
| "paste"
@@ -27,10 +25,7 @@ export type ShortcutName = SubtypeOf<
| "addToLibrary"
| "viewMode"
| "flipHorizontal"
| "flipVertical"
| "hyperlink"
| "toggleLock"
>;
| "flipVertical";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -67,12 +62,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts available, take the first one
// if multiple shortcuts availiable, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};
+10 -37
View File
@@ -8,8 +8,6 @@ import {
} from "../types";
import { ToolButtonSize } from "../components/ToolButton";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */
export type ActionResult =
| {
@@ -41,7 +39,6 @@ export type ActionName =
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "copyText"
| "sendBackward"
| "bringForward"
| "sendToBack"
@@ -85,7 +82,6 @@ export type ActionName =
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
@@ -105,14 +101,7 @@ export type ActionName =
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock";
| "toggleTheme";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -132,34 +121,18 @@ export interface Action {
appState: AppState,
elements: readonly ExcalidrawElement[],
) => boolean;
contextItemLabel?:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
) => string);
contextItemLabel?: string;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:
| false
| {
category:
| "toolbar"
| "element"
| "canvas"
| "export"
| "history"
| "menu"
| "collab"
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
}
+22 -1
View File
@@ -1,7 +1,6 @@
import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {
position: "start" | "center" | "end";
@@ -31,6 +30,28 @@ export const alignElements = (
});
};
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const calculateTranslation = (
group: ExcalidrawElement[],
selectionBoundingBox: Box,
+9 -13
View File
@@ -3,20 +3,16 @@ export const trackEvent =
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" &&
window.gtag
? (category: string, action: string, label?: string, value?: number) => {
try {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
} catch (error) {
console.error("error logging to ga", error);
}
? (category: string, name: string, label?: string, value?: number) => {
window.gtag("event", name, {
event_category: category,
event_label: label,
value,
});
}
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => {
? (category: string, name: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
// console.info("Track Event", category, name, label, value);
};
+5 -21
View File
@@ -41,13 +41,8 @@ export const getDefaultAppState = (): Omit<
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
type: "selection",
locked: false,
lastActiveToolBeforeEraser: null,
},
penMode: false,
penDetected: false,
elementLocked: false,
elementType: "selection",
errorMessage: null,
exportBackground: true,
exportScale: defaultExportScale,
@@ -82,12 +77,9 @@ 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,
};
};
@@ -133,9 +125,8 @@ const APP_STATE_STORAGE_CONF = (<
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, 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 },
@@ -177,7 +168,6 @@ const APP_STATE_STORAGE_CONF = (<
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 },
});
const _clearAppStateForStorage = <
@@ -215,9 +205,3 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
export const isEraserActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";
+3 -9
View File
@@ -1,10 +1,5 @@
import colors from "./colors";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
@@ -108,7 +103,7 @@ const transposeCells = (cells: string[][]) => {
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadsheets, tsv, csv.
// Copy/paste from excel, spreadhseets, tsv, csv.
// For now we only accept 2 columns with an optional header
// Check for tab separated values
@@ -166,8 +161,7 @@ const commonProps = {
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
verticalAlign: "middle",
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
+16 -43
View File
@@ -2,16 +2,16 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } 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 { isPromiseLike } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: readonly NonDeletedExcalidrawElement[];
elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
};
@@ -56,20 +56,18 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null,
files: BinaryFiles,
) => {
// select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements,
files: files
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
@@ -125,7 +123,7 @@ const getSystemClipboard = async (
};
/**
* Attempts to parse clipboard. Prefers system clipboard.
* Attemps to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
@@ -167,35 +165,10 @@ export const parseClipboard = async (
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
let promise;
try {
// in Safari so far we need to construct the ClipboardItem synchronously
// (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// not await so that we can detect whether the thrown error likely relates
// to a lack of support for the Promise ClipboardItem constructor
promise = navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: blob,
}),
]);
} catch (error: any) {
// if we're using a Promise ClipboardItem, let's try constructing
// with resolution value instead
if (isPromiseLike(blob)) {
await navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: await blob,
}),
]);
} else {
throw error;
}
}
await promise;
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
await navigator.clipboard.write([
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
]);
};
export const copyTextToSystemClipboard = async (text: string | null) => {
+23 -59
View File
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useDeviceType } from "../components/App";
import { useIsMobile } from "../components/App";
import {
canChangeSharpness,
canHaveArrowheads,
@@ -19,50 +19,36 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
export const SelectedShapeActions = ({
appState,
elements,
renderAction,
activeTool,
elementType,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
activeTool: AppState["activeTool"]["type"];
elementType: ExcalidrawElement["type"];
}) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement);
const deviceType = useDeviceType();
const isMobile = useIsMobile();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
hasBackground(activeTool) ||
hasBackground(elementType) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showChangeBackgroundIcons =
hasBackground(activeTool) ||
hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
@@ -74,23 +60,23 @@ export const SelectedShapeActions = ({
return (
<div className="panelColumn">
{((hasStrokeColor(activeTool) &&
activeTool !== "image" &&
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(activeTool) ||
{(hasStrokeWidth(elementType) ||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(activeTool === "freedraw" ||
{(elementType === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(activeTool) ||
{(hasStrokeStyle(elementType) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
<>
{renderAction("changeStrokeStyle")}
@@ -98,12 +84,12 @@ export const SelectedShapeActions = ({
</>
)}
{(canChangeSharpness(activeTool) ||
{(canChangeSharpness(elementType) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(activeTool) ||
{(hasText(elementType) ||
targetElements.some((element) => hasText(element.type))) && (
<>
{renderAction("changeFontSize")}
@@ -114,11 +100,7 @@ export const SelectedShapeActions = ({
</>
)}
{targetElements.some(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(activeTool) ||
{(canHaveArrowheads(elementType) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
)}
@@ -135,7 +117,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
{targetElements.length > 1 && !isSingleElementBoundContainer && (
{targetElements.length > 1 && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
@@ -168,15 +150,14 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
)}
{!isEditing && targetElements.length > 0 && (
{!isMobile && !isEditing && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!deviceType.isMobile && renderAction("duplicateSelection")}
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
{renderAction("duplicateSelection")}
{renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
</div>
</fieldset>
)}
@@ -186,16 +167,14 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({
canvas,
activeTool,
elementType,
setAppState,
onImageAction,
appState,
}: {
canvas: HTMLCanvasElement | null;
activeTool: AppState["activeTool"];
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
@@ -210,35 +189,20 @@ export const ShapesSwitcher = ({
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
checked={elementType === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = { ...activeTool, type: value };
setAppState({
activeTool: nextActiveTool,
elementType: value,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
setCursorForShape(canvas, value);
if (value === "image") {
onImageAction({ pointerType });
}
+371 -1319
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -12,11 +12,5 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
}
}
+10 -18
View File
@@ -1,28 +1,20 @@
import "./Avatar.scss";
import React from "react";
import { getClientInitials } from "../clients";
type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
name: string;
src?: string;
};
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name);
const style = src
? undefined
: { background: color, border: `1px solid ${border}` };
return (
<div className="Avatar" style={style} onClick={onClick}>
{src ? (
<img className="Avatar-img" src={src} alt={shortName} />
) : (
shortName
)}
</div>
);
};
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
<div
className="Avatar"
style={{ background: color, border: `1px solid ${border}` }}
onClick={onClick}
>
{children}
</div>
);
+1 -2
View File
@@ -7,7 +7,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 +24,6 @@ export const ButtonIconSelect = <T extends Object>({
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
+4 -11
View File
@@ -3,22 +3,15 @@ import OpenColor from "open-color";
import "./Card.scss";
export const Card: React.FC<{
color: keyof OpenColor | "primary";
color: keyof OpenColor;
}> = ({ 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],
["--card-color" as any]: OpenColor[color][7],
["--card-color-darker" as any]: OpenColor[color][8],
["--card-color-darkest" as any]: OpenColor[color][9],
}}
>
{children}
+2 -2
View File
@@ -6,14 +6,14 @@ import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{
checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void;
onChange: (checked: boolean) => void;
className?: string;
}> = ({ children, checked, onChange, className }) => {
return (
<div
className={clsx("Checkbox", className, { "is-checked": checked })}
onClick={(event) => {
onChange(!checked, event);
onChange(!checked);
(
(event.currentTarget as HTMLDivElement).querySelector(
".Checkbox-box",
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { t } from "../i18n";
import { useDeviceType } from "./App";
import { useIsMobile } from "./App";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useDeviceType().isMobile}
showAriaLabel={useIsMobile()}
onClick={toggleDialog}
data-testid="clear-canvas-button"
/>
+2 -2
View File
@@ -1,7 +1,7 @@
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import { useDeviceType } from "../components/App";
import { useIsMobile } from "../components/App";
import { users } from "./icons";
import "./CollabButton.scss";
@@ -26,7 +26,7 @@ const CollabButton = ({
type="button"
title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")}
showAriaLabel={useDeviceType().isMobile}
showAriaLabel={useIsMobile()}
>
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div>
+1 -21
View File
@@ -46,7 +46,7 @@
top: -11px;
}
.color-picker-content--default {
.color-picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(5, auto);
@@ -59,26 +59,6 @@
}
}
.color-picker-content--canvas {
display: flex;
flex-direction: column;
padding: 0.25rem;
&-title {
color: $oc-gray-6;
font-size: 12px;
padding: 0 0.25rem;
}
&-colors {
padding: 0.5rem 0;
.color-picker-swatch {
margin: 0 0.25rem;
}
}
}
.color-picker-content .color-input-container {
grid-column: 1 / span 5;
}
+42 -154
View File
@@ -7,53 +7,6 @@ import { isArrowKey, KEYS } from "../keys";
import { t, getLanguage } from "../i18n";
import { isWritableElement } from "../utils";
import colors from "../colors";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
const MAX_CUSTOM_COLORS = 5;
const MAX_DEFAULT_COLORS = 15;
export const getCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
) => {
const customColors: string[] = [];
const updatedElements = elements
.filter((element) => !element.isDeleted)
.sort((ele1, ele2) => ele2.updated - ele1.updated);
let index = 0;
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colorType = elementColorTypeMap[type] as
| "backgroundColor"
| "strokeColor";
while (
index < updatedElements.length &&
customColors.length < MAX_CUSTOM_COLORS
) {
const element = updatedElements[index];
if (
customColors.length < MAX_CUSTOM_COLORS &&
isCustomColor(element[colorType], type) &&
!customColors.includes(element[colorType])
) {
customColors.push(element[colorType]);
}
index++;
}
return customColors;
};
const isCustomColor = (
color: string,
type: "elementBackground" | "elementStroke",
) => {
return !colors[type].includes(color);
};
const isValidColor = (color: string) => {
const style = new Option().style;
@@ -82,7 +35,6 @@ const keyBindings = [
["1", "2", "3", "4", "5"],
["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat();
const Picker = ({
@@ -93,7 +45,6 @@ const Picker = ({
label,
showInput = true,
type,
elements,
}: {
colors: string[];
color: string | null;
@@ -102,20 +53,12 @@ const Picker = ({
label: string;
showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke";
elements: readonly ExcalidrawElement[];
}) => {
const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>();
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getCustomColors(elements, type);
});
React.useEffect(() => {
// After the component is first mounted focus on first input
if (activeItem.current) {
@@ -142,42 +85,23 @@ const Picker = ({
} else if (isArrowKey(event.key)) {
const { activeElement } = document;
const isRTL = getLanguage().rtl;
let isCustom = false;
let index = Array.prototype.indexOf.call(
gallery!.current!.querySelector(".color-picker-content--default")!
.children,
const index = Array.prototype.indexOf.call(
gallery!.current!.children,
activeElement,
);
if (index === -1) {
index = Array.prototype.indexOf.call(
gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!.children,
activeElement,
);
if (index !== -1) {
isCustom = true;
}
}
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
if (index !== -1) {
const length = parentSelector!.children.length - (showInput ? 1 : 0);
const length = gallery!.current!.children.length - (showInput ? 1 : 0);
const nextIndex =
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
? (index + 1) % length
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
? (length + index - 1) % length
: !isCustom && event.key === KEYS.ARROW_DOWN
: event.key === KEYS.ARROW_DOWN
? (index + 5) % length
: !isCustom && event.key === KEYS.ARROW_UP
: event.key === KEYS.ARROW_UP
? (length + index - 5) % length
: index;
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
(gallery!.current!.children![nextIndex] as any).focus();
}
event.preventDefault();
} else if (
@@ -185,15 +109,7 @@ const Picker = ({
!isWritableElement(event.target)
) {
const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS;
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
(gallery!.current!.children![index] as any).focus();
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
event.preventDefault();
@@ -203,50 +119,6 @@ const Picker = ({
event.stopPropagation();
};
const renderColors = (colors: Array<string>, custom: boolean = false) => {
return colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
const keyBinding = custom
? keyBindings[i + MAX_DEFAULT_COLORS]
: keyBindings[i];
const label = custom
? _colorWithoutHash
: t(`colors.${_colorWithoutHash}`);
return (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${label}${
!isTransparent(_color) ? ` (${_color})` : ""
}${keyBinding.toUpperCase()}`}
aria-label={label}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
ref={(el) => {
if (!custom && el && i === 0) {
firstItem.current = el;
}
if (el && _color === color) {
activeItem.current = el;
}
}}
onFocus={() => {
onChange(_color);
}}
>
{isTransparent(_color) ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBinding}</span>
</button>
);
});
};
return (
<div
className={`color-picker color-picker-type-${type}`}
@@ -266,20 +138,41 @@ const Picker = ({
}}
tabIndex={0}
>
<div className="color-picker-content--default">
{renderColors(colors)}
</div>
{!!customColors.length && (
<div className="color-picker-content--canvas">
<span className="color-picker-content--canvas-title">
{t("labels.canvasColors")}
</span>
<div className="color-picker-content--canvas-colors">
{renderColors(customColors, true)}
</div>
</div>
)}
{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>
);
})}
{showInput && (
<ColorInput
color={color}
@@ -353,8 +246,6 @@ export const ColorPicker = ({
label,
isActive,
setActive,
elements,
appState,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null;
@@ -362,8 +253,6 @@ export const ColorPicker = ({
label: string;
isActive: boolean;
setActive: (active: boolean) => void;
elements: readonly ExcalidrawElement[];
appState: AppState;
}) => {
const pickerButton = React.useRef<HTMLButtonElement>(null);
@@ -405,7 +294,6 @@ export const ColorPicker = ({
label={label}
showInput={false}
type={type}
elements={elements}
/>
</Popover>
) : null}
+4 -20
View File
@@ -11,7 +11,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 +21,6 @@ type ContextMenuProps = {
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
};
const ContextMenu = ({
@@ -32,7 +30,6 @@ const ContextMenu = ({
left,
actionManager,
appState,
elements,
}: ContextMenuProps) => {
return (
<Popover
@@ -40,10 +37,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 +48,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
@@ -70,9 +58,7 @@ const ContextMenu = ({
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
@@ -111,7 +97,6 @@ type ContextMenuParams = {
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
};
const handleClose = (container: HTMLElement) => {
@@ -140,7 +125,6 @@ export default {
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
elements={params.elements}
/>,
getContextMenuNode(params.container),
);
+2 -2
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useExcalidrawContainer, useDeviceType } from "../components/App";
import { useExcalidrawContainer, useIsMobile } from "../components/App";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose}
aria-label={t("buttons.close")}
>
{useDeviceType().isMobile ? back : close}
{useIsMobile() ? back : close}
</button>
</h2>
<div className="Dialog__content">{props.children}</div>
+3 -35
View File
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Section title={t("helpDialog.shortcuts")}>
<Columns>
<Column>
<ShortcutIsland caption={t("helpDialog.tools")}>
<ShortcutIsland caption={t("helpDialog.shapes")}>
<Shortcut
label={t("toolBar.selection")}
shortcuts={["V", "1"]}
@@ -149,20 +149,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["R", "2"]}
/>
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.freedraw")}
shortcuts={["Shift + P", "X", "7"]}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("toolBar.eraser")}
shortcuts={[getShortcutKey("E")]}
/>
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
@@ -209,10 +205,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
@@ -268,18 +260,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={[
@@ -363,10 +343,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]}
/>
<Shortcut
label={t("helpDialog.toggleElementLock")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
@@ -406,14 +382,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
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>
+16 -39
View File
@@ -7,11 +7,9 @@ import { AppState } from "../types";
import {
isImageElement,
isLinearElement,
isTextBindableContainer,
isTextElement,
} from "../element/typeChecks";
import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
interface HintViewerProps {
appState: AppState;
@@ -20,32 +18,25 @@ interface HintViewerProps {
}
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (elementType === "arrow" || elementType === "line") {
if (!multiMode) {
return t("hints.linearElement");
}
return t("hints.linearElementMulti");
}
if (activeTool.type === "freedraw") {
if (elementType === "freedraw") {
return t("hints.freeDraw");
}
if (activeTool.type === "text") {
if (elementType === "text") {
return t("hints.text");
}
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage");
}
@@ -69,6 +60,15 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.rotate");
}
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.activePointIndex
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
@@ -77,31 +77,8 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing");
}
if (activeTool.type === "selection") {
if (
appState.draggingElement?.type === "selection" &&
!appState.editingElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
}
if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
}
}
if (selectedElements.length === 1) {
if (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 (elementType === "selection" && !selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
}
return null;
+1 -1
View File
@@ -22,7 +22,7 @@
align-items: center;
justify-content: center;
&:focus-visible {
&:focus {
outline: transparent;
background-color: var(--button-gray-2);
& svg {
+6 -6
View File
@@ -1,11 +1,12 @@
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 { useDeviceType } from "./App";
import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types";
@@ -18,7 +19,6 @@ import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -90,7 +90,7 @@ const ImageExportModal = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
@@ -102,7 +102,7 @@ const ImageExportModal = ({
const { exportBackground, viewBackgroundColor } = appState;
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
? getSelectedElements(elements, appState)
: elements;
useEffect(() => {
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
icon={exportImage}
type="button"
aria-label={t("buttons.exportImage")}
showAriaLabel={useDeviceType().isMobile}
showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")}
/>
{modalIsShown && (
+1 -1
View File
@@ -3,7 +3,7 @@
--padding: 0;
background-color: var(--island-bg-color);
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;
+9 -12
View File
@@ -1,7 +1,8 @@
import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useDeviceType } from "./App";
import { useIsMobile } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
@@ -11,9 +12,6 @@ import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -31,7 +29,7 @@ const JSONExportModal = ({
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager;
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
@@ -56,7 +54,7 @@ const JSONExportModal = ({
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
actionManager.executeAction(actionSaveFileToDisk);
}}
/>
</Card>
@@ -72,10 +70,9 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => {
onExportToBackend(elements, appState, files, canvas);
trackEvent("export", "link", `ui (${getFrame()})`);
}}
onClick={() =>
onExportToBackend(elements, appState, files, canvas)
}
/>
</Card>
)}
@@ -97,7 +94,7 @@ export const JSONExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
actionManager: ActionManager;
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
@@ -117,7 +114,7 @@ export const JSONExportDialog = ({
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useDeviceType().isMobile}
showAriaLabel={useIsMobile()}
title={t("buttons.export")}
/>
{modalIsShown && (
+25 -75
View File
@@ -6,6 +6,7 @@ import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
@@ -18,6 +19,7 @@ import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { HintViewer } from "./HintViewer";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu";
@@ -33,12 +35,6 @@ import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDeviceType } from "../components/App";
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
@@ -48,7 +44,6 @@ interface LayerUIProps {
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean;
@@ -68,7 +63,6 @@ interface LayerUIProps {
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderCustomElementWidget?: (appState: AppState) => void;
}
const LayerUI = ({
@@ -80,7 +74,6 @@ const LayerUI = ({
elements,
onCollabButtonClick,
onLockToggle,
onPenModeToggle,
onInsertElements,
zenModeEnabled,
showExitZenModeBtn,
@@ -96,9 +89,8 @@ const LayerUI = ({
library,
id,
onImageAction,
renderCustomElementWidget,
}: LayerUIProps) => {
const deviceType = useDeviceType();
const isMobile = useIsMobile();
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -125,7 +117,6 @@ const LayerUI = ({
const createExporter =
(type: ExportType): ExportCB =>
async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
type,
exportedElements,
@@ -242,7 +233,7 @@ const LayerUI = ({
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting 200
// we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer
// if active file name is displayed, subtracting 248 to account for its height
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
@@ -252,7 +243,7 @@ const LayerUI = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
elementType={appState.elementType}
/>
</Island>
</Section>
@@ -277,7 +268,7 @@ const LayerUI = ({
const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
pendingElements={getSelectedElements(elements, appState)}
onClose={closeLibrary}
onInsertShape={onInsertElements}
onAddToLibrary={deselectItems}
@@ -314,42 +305,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}
/>
<Stack.Row gap={1}>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": zenModeEnabled,
})}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer
appState={appState}
elements={elements}
isMobile={deviceType.isMobile}
isMobile={isMobile}
/>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
canvas={canvas}
activeTool={appState.activeTool}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
@@ -393,7 +369,7 @@ const LayerUI = ({
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(deviceType.isMobile, appState)}
{renderTopRightUI?.(isMobile, appState)}
</div>
</div>
</FixedSideContainer>
@@ -423,41 +399,16 @@ const LayerUI = ({
/>
</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>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
{renderCustomElementWidget &&
renderCustomElementWidget(appState)}
</div>
</>
<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>
)}
{!viewModeEnabled &&
appState.multiElement &&
deviceType.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>
@@ -496,7 +447,7 @@ const LayerUI = ({
const dialogs = (
<>
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.isLoading && <LoadingMessage />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
@@ -525,7 +476,7 @@ const LayerUI = ({
</>
);
return deviceType.isMobile ? (
return isMobile ? (
<>
{dialogs}
<MobileMenu
@@ -537,8 +488,7 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
onLockToggle={onLockToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
+4 -4
View File
@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
}> = ({ appState, setAppState }) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library",
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_medium`,
{
"is-mobile": isMobile,
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},
)}
title={`${capitalizeString(t("toolBar.library"))} — 0`}
style={{ marginInlineStart: "var(--space-factor)" }}
>
<input
className="ToolIcon_type_checkbox"
+2 -15
View File
@@ -13,10 +13,6 @@
width: 100%;
margin: 2px 0;
.Spinner {
margin-right: 1rem;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
@@ -32,17 +28,8 @@
}
.layer-ui__library-message {
padding: 2em 4em;
min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
span {
font-size: 0.8em;
}
padding: 10px 20px;
max-width: 200px;
}
.publish-library-success {
+89 -135
View File
@@ -1,12 +1,5 @@
import {
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { libraryItemsAtom } from "../data/library";
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
@@ -25,11 +18,6 @@ import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
@@ -64,17 +52,6 @@ const getSelectedItems = (
selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id));
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<Island padding={1} ref={ref} className="layer-ui__library">
{children}
</Island>
);
});
export const LibraryMenu = ({
onClose,
onInsertShape,
@@ -124,6 +101,11 @@ export const LibraryMenu = ({
};
}, [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);
@@ -131,35 +113,55 @@ export const LibraryMenu = ({
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
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"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
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",
@@ -167,12 +169,14 @@ export const LibraryMenu = ({
id: randomId(),
created: Date.now(),
},
...libraryItems,
...items,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
@@ -211,7 +215,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data, libraryItems: LibraryItems) => {
(data) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
@@ -220,114 +224,64 @@ export const LibraryMenu = ({
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
},
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message">
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</LibraryMenuWrapper>
);
}
return (
<LibraryMenuWrapper ref={ref}>
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
appState={appState}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onSuccess={onPublishLibSuccess}
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItemsData.libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItemsData.libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
{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) => {
if (!selectedItems.includes(id)) {
setSelectedItems([...selectedItems, id]);
} else {
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
</LibraryMenuWrapper>
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
);
};
+22 -26
View File
@@ -12,7 +12,7 @@ import {
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils";
import { useDeviceType } from "./App";
import { useIsMobile } from "./App";
import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit";
@@ -21,11 +21,8 @@ import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants";
import Spinner from "./Spinner";
const LibraryMenuItems = ({
isLoading,
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
@@ -42,7 +39,6 @@ const LibraryMenuItems = ({
onPublish,
resetLibrary,
}: {
isLoading: boolean;
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void;
@@ -55,7 +51,7 @@ const LibraryMenuItems = ({
library: Library;
id: string;
selectedItems: LibraryItem["id"][];
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onToggle: (id: LibraryItem["id"]) => void;
onPublish: () => void;
resetLibrary: () => void;
}) => {
@@ -88,7 +84,7 @@ const LibraryMenuItems = ({
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useDeviceType().isMobile;
const isMobile = useIsMobile();
const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length;
@@ -109,10 +105,14 @@ const LibraryMenuItems = ({
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) => {
console.error(error);
setAppState({ errorMessage: t("errors.importLibraryError") });
setAppState({ errorMessage: error.message });
});
}}
className="library-actions--load"
@@ -129,7 +129,7 @@ const LibraryMenuItems = ({
onClick={async () => {
const libraryItems = itemsSelected
? items
: await library.getLatestLibrary();
: await library.loadLibrary();
saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError)
.catch((error) => {
@@ -212,8 +212,10 @@ const LibraryMenuItems = ({
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={(id, event) => {
onToggle(id, event);
onToggle={() => {
if (params.item?.id) {
onToggle(params.item.id);
}
}}
/>
</Stack.Col>
@@ -288,20 +290,14 @@ const LibraryMenuItems = ({
{showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
{isLoading ? (
<Spinner />
) : (
<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>
)}
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
<Stack.Col
className="library-menu-items-container__items"
+2 -9
View File
@@ -27,8 +27,6 @@
.library-unit__dragger {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
@@ -101,13 +99,8 @@
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--hover .library-unit__adder {
color: $oc-blue-7;
}
.library-unit__active {
+8 -21
View File
@@ -2,21 +2,18 @@ import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import { MIME_TYPES } from "../constants";
import { useDeviceType } from "../components/App";
import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export";
import { BinaryFiles, 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>
);
@@ -36,7 +33,7 @@ export const LibraryUnit = ({
isPending?: boolean;
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onToggle: (id: string) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -66,7 +63,7 @@ export const LibraryUnit = ({
}, [elements, files]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDeviceType().isMobile;
const isMobile = useIsMobile();
const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div>
);
@@ -87,17 +84,7 @@ export const LibraryUnit = ({
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
}
}
: undefined
}
onClick={!!elements || !!isPending ? onClick : undefined}
onDragStart={(event) => {
setIsHovered(false);
event.dataTransfer.setData(
@@ -110,7 +97,7 @@ export const LibraryUnit = ({
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
onChange={() => onToggle(id)}
className="library-unit__checkbox"
/>
)}
+3 -23
View File
@@ -1,30 +1,10 @@
import { t } from "../i18n";
import { useState, useEffect } from "react";
import Spinner from "./Spinner";
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
const [isWaiting, setIsWaiting] = useState(!!delay);
useEffect(() => {
if (!delay) {
return;
}
const timer = setTimeout(() => {
setIsWaiting(false);
}, delay);
return () => clearTimeout(timer);
}, [delay]);
if (isWaiting) {
return null;
}
export const LoadingMessage = () => {
// !! KEEP THIS IN SYNC WITH index.html !!
return (
<div className="LoadingMessage">
<div>
<Spinner />
</div>
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
<span>{t("labels.loadingScene")}</span>
</div>
);
};
+2 -3
View File
@@ -10,7 +10,6 @@ type LockIconProps = {
checked: boolean;
onChange?(): void;
zenModeEnabled?: boolean;
isMobile?: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
@@ -43,10 +42,10 @@ export const LockButton = (props: LockIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
"zen-mode-visibility--hidden": props.zenModeEnabled,
},
)}
title={`${props.title} — Q`}
+7 -33
View File
@@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer";
import { Island } from "./Island";
import { HintViewer } from "./HintViewer";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
@@ -17,7 +17,6 @@ import { LockButton } from "./LockButton";
import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton";
type MobileMenuProps = {
appState: AppState;
@@ -29,7 +28,6 @@ type MobileMenuProps = {
libraryMenu: JSX.Element | null;
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
@@ -52,7 +50,6 @@ export const MobileMenu = ({
setAppState,
onCollabButtonClick,
onLockToggle,
onPenModeToggle,
canvas,
isCollaborating,
renderCustomFooter,
@@ -67,14 +64,13 @@ export const MobileMenu = ({
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container">
<Island padding={1} className="App-toolbar">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
canvas={canvas}
activeTool={appState.activeTool}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
@@ -86,23 +82,11 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton
checked={appState.activeTool.locked}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<LibraryButton
appState={appState}
setAppState={setAppState}
isMobile
/>
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LibraryButton appState={appState} setAppState={setAppState} />
</Stack.Row>
{libraryMenu}
</Stack.Col>
@@ -114,12 +98,6 @@ export const MobileMenu = ({
};
const renderAppToolbar = () => {
// Render eraser conditionally in mobile
const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
@@ -127,16 +105,12 @@ export const MobileMenu = ({
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{showEraser && actionManager.renderAction("eraser")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
@@ -226,7 +200,7 @@ export const MobileMenu = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
elementType={appState.elementType}
/>
</Section>
) : null}
+6 -6
View File
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useDeviceType } from "./App";
import { useExcalidrawContainer, useIsMobile } from "./App";
import { AppState } from "../types";
import { THEME } from "../constants";
@@ -59,17 +59,17 @@ export const Modal = (props: {
const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null);
const deviceType = useDeviceType();
const isMobileRef = useRef(deviceType.isMobile);
isMobileRef.current = deviceType.isMobile;
const isMobile = useIsMobile();
const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
div.classList.toggle("excalidraw--mobile", isMobile);
}
}, [div, deviceType.isMobile]);
}, [div, isMobile]);
useLayoutEffect(() => {
const isDarkTheme =
-91
View File
@@ -1,91 +0,0 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
type PenModeIconProps = {
title?: string;
name?: string;
checked: boolean;
onChange?(): void;
zenModeEnabled?: boolean;
isMobile?: boolean;
penDetected: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const ICONS = {
CHECKED: (
<svg
width="205"
height="205"
viewBox="0 0 205 205"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m35 195-25-29.17V50h50v115l-25 30" />
<path d="M10 40V10h50v30H10" />
<path d="M125 145h70v50h-70" />
<path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" />
</svg>
),
UNCHECKED: (
<svg
width="205"
height="205"
viewBox="0 0 205 205"
xmlns="http://www.w3.org/2000/svg"
className="unlocked-icon rtl-mirror"
>
<path d="m35 195-25-29.17V50h50v115l-25 30" />
<path d="M10 40V10h50v30H10" />
<path d="M125 145h70v50h-70" />
<path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" />
</svg>
),
};
export const PenModeButton = (props: PenModeIconProps) => {
if (!props.penDetected) {
if (props.isMobile) {
return null;
}
return (
<label
className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
>
<div className="ToolIcon__icon ToolIcon__hidden" />
</label>
);
}
return (
<label
className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
/>
<div className="ToolIcon__icon">
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
</div>
</label>
);
};
+6 -11
View File
@@ -8,10 +8,6 @@ type Props = {
children?: React.ReactNode;
onCloseRequest?(event: PointerEvent): void;
fitInViewport?: boolean;
offsetLeft?: number;
offsetTop?: number;
viewportWidth?: number;
viewportHeight?: number;
};
export const Popover = ({
@@ -20,10 +16,6 @@ export const Popover = ({
top,
onCloseRequest,
fitInViewport = false,
offsetLeft = 0,
offsetTop = 0,
viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight,
}: Props) => {
const popoverRef = useRef<HTMLDivElement>(null);
@@ -32,14 +24,17 @@ export const Popover = ({
if (fitInViewport && popoverRef.current) {
const element = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect();
if (x + width - offsetLeft > viewportWidth) {
const viewportWidth = window.innerWidth;
if (x + width > viewportWidth) {
element.style.left = `${viewportWidth - width}px`;
}
if (y + height - offsetTop > viewportHeight) {
const viewportHeight = window.innerHeight;
if (y + height > viewportHeight) {
element.style.top = `${viewportHeight - height}px`;
}
}
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
}, [fitInViewport]);
useEffect(() => {
if (onCloseRequest) {
+57 -83
View File
@@ -1,5 +1,5 @@
import { ReactNode, useCallback, useEffect, useState } from "react";
import OpenColor from "open-color";
import oc from "open-color";
import { Dialog } from "./Dialog";
import { t } from "../i18n";
@@ -7,19 +7,16 @@ import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas } from "../packages/utils";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { exportToBlob } from "../packages/utils";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import { ExcalidrawElement } from "../element/types";
import { newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils";
interface PublishLibraryDataParams {
authorName: string;
@@ -58,75 +55,6 @@ const importPublishLibDataFromStorage = () => {
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128;
const BOX_PADDING = Math.round(BOX_SIZE / 16);
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
const canvas = document.createElement("canvas");
canvas.width =
rows[0].length * BOX_SIZE +
(rows[0].length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
canvas.height =
rows.length * BOX_SIZE +
(rows.length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
// draw item
// -------------------------------------------------------------------------
const rowOffset =
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
const colOffset =
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
ctx.drawImage(
itemCanvas,
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
);
// draw item border
// -------------------------------------------------------------------------
ctx.lineWidth = BORDER_WIDTH;
ctx.strokeStyle = OpenColor.gray[4];
ctx.strokeRect(
colOffset + BOX_PADDING / 2,
rowOffset + BOX_PADDING / 2,
BOX_SIZE + BOX_PADDING,
BOX_SIZE + BOX_PADDING,
);
}
return await resizeImageFile(
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
{
outputType: MIME_TYPES.jpg,
maxWidthOrHeight: 5000,
},
);
};
const PublishLibrary = ({
onClose,
libraryItems,
@@ -201,12 +129,59 @@ const PublishLibrary = ({
setIsSubmitting(false);
return;
}
const elements: ExcalidrawElement[] = [];
const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
clonedLibItems.forEach((libItem) => {
const boundingBox = getCommonBoundingBox(libItem.elements);
const width = boundingBox.maxX - boundingBox.minX + 30;
const height = boundingBox.maxY - boundingBox.minY + 30;
const offset = {
x: prevBoundingBox.maxX - boundingBox.minX,
y: prevBoundingBox.maxY - boundingBox.minY,
};
const previewImage = await generatePreviewImage(clonedLibItems);
const itemsWithUpdatedCoords = libItem.elements.map((element) => {
element = mutateElement(element, {
x: element.x + offset.x + 15,
y: element.y + offset.y + 15,
});
return element;
});
const items = [
...itemsWithUpdatedCoords,
newElement({
type: "rectangle",
width,
height,
x: prevBoundingBox.maxX,
y: prevBoundingBox.maxY,
strokeColor: "#ced4da",
backgroundColor: "transparent",
strokeStyle: "solid",
opacity: 100,
roughness: 0,
strokeSharpness: "sharp",
fillStyle: "solid",
strokeWidth: 1,
}),
];
elements.push(...items);
prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
});
const png = await exportToBlob({
elements,
mimeType: "image/png",
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
const libContent: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
version: 2,
source: EXPORT_SOURCE,
libraryItems: clonedLibItems,
};
@@ -215,8 +190,7 @@ const PublishLibrary = ({
const formData = new FormData();
formData.append("excalidrawLib", lib);
formData.append("previewImage", previewImage);
formData.append("previewImageType", previewImage.type);
formData.append("excalidrawPng", png!);
formData.append("title", libraryData.name);
formData.append("authorName", libraryData.authorName);
formData.append("githubHandle", libraryData.githubHandle);
+3 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useDeviceType } from "../components/App";
import { useIsMobile } from "../components/App";
import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons";
@@ -16,13 +16,13 @@ export const Stats = (props: {
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
const deviceType = useDeviceType();
const isMobile = useIsMobile();
const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
if (deviceType.isMobile && props.appState.openMenu) {
if (isMobile && props.appState.openMenu) {
return null;
}
-2
View File
@@ -48,7 +48,6 @@ type ToolButtonProps =
type: "radio";
checked: boolean;
onChange?(data: { pointerType: PointerType | null }): void;
onPointerDown?(data: { pointerType: PointerType }): void;
});
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -150,7 +149,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
+29 -26
View File
@@ -8,7 +8,17 @@
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor);
user-select: none;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
.ToolIcon--plain {
@@ -19,20 +29,6 @@
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
}
.ToolIcon__icon {
width: 2.5rem;
height: 2.5rem;
@@ -42,11 +38,7 @@
justify-content: center;
align-items: center;
border-radius: var(--border-radius-lg);
& + .ToolIcon__label {
margin-inline-start: 0;
}
border-radius: var(--space-factor);
svg {
position: relative;
@@ -54,6 +46,10 @@
fill: var(--icon-fill-color);
color: var(--icon-fill-color);
}
& + .ToolIcon__label {
margin-inline-start: 0;
}
}
.ToolIcon__label {
@@ -83,7 +79,7 @@
margin: 0;
font-size: inherit;
&:focus-visible {
&:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -125,7 +121,7 @@
}
}
&:focus-visible + .ToolIcon__icon {
&:focus + .ToolIcon__icon {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -145,6 +141,10 @@
background-color: transparent;
}
&:focus {
box-shadow: none;
}
.ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
@@ -155,7 +155,14 @@
}
width: 2rem;
height: 2rem;
height: 2em;
}
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
@@ -219,10 +226,6 @@
margin-inline-end: 0;
top: 60px;
}
.ToolIcon.ToolIcon__penMode {
margin-inline-end: 0;
top: 140px;
}
}
.unlocked-icon {
-124
View File
@@ -1,124 +0,0 @@
@import "open-color/open-color.scss";
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
.excalidraw {
.App-toolbar-container {
.ToolIcon_type_floating {
@include toolbarButtonColorStates;
&:not(.is-mobile) {
.ToolIcon__icon {
padding: 1px;
background-color: var(--island-bg-color);
box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
border-radius: 50%;
transition: box-shadow 0.5s ease, transform 0.5s ease;
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:focus-within + .ToolIcon__icon {
// override for custom floating button shadow
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
}
.ToolIcon__hidden {
box-shadow: none !important;
background-color: transparent !important;
pointer-events: none !important;
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
.ToolIcon__library {
margin-inline-start: var(--space-factor);
}
&.zen-mode {
.ToolIcon_type_floating {
.ToolIcon__icon {
box-shadow: none;
transform: scale(0.9);
}
.ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
& + .ToolIcon__icon {
svg {
fill: $oc-gray-5;
color: $oc-gray-5;
}
}
}
}
}
}
.App-toolbar {
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
.ToolIcon {
&:hover {
--icon-fill-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
--keybinding-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
}
&:active {
--icon-fill-color: #{$oc-gray-9};
--keybinding-color: #{$oc-gray-9};
}
.ToolIcon__icon {
background: transparent;
border-radius: var(--border-radius-lg);
}
@include toolbarButtonColorStates;
}
&.zen-mode {
.ToolIcon__keybinding,
.HintViewer {
display: none;
}
}
}
&.theme--dark .App-toolbar .ToolIcon:active {
--icon-fill-color: #{$oc-gray-3};
--keybinding-color: #{$oc-gray-3};
}
}
+2 -1
View File
@@ -2,7 +2,7 @@
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: fixed;
position: absolute;
z-index: 1000;
padding: 8px;
@@ -29,6 +29,7 @@
// wraps the element we want to apply the tooltip to
.excalidraw-tooltip-wrapper {
display: flex;
height: 100%;
}
.excalidraw-tooltip-icon {
+32 -55
View File
@@ -2,7 +2,7 @@ import "./Tooltip.scss";
import React, { useEffect } from "react";
export const getTooltipDiv = () => {
const getTooltipDiv = () => {
const existingDiv = document.querySelector<HTMLDivElement>(
".excalidraw-tooltip",
);
@@ -15,50 +15,6 @@ export const getTooltipDiv = () => {
return div;
};
export const updateTooltipPosition = (
tooltip: HTMLDivElement,
item: {
left: number;
top: number;
width: number;
height: number;
},
position: "bottom" | "top" = "bottom",
) => {
const tooltipRect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
let left = item.left + item.width / 2 - tooltipRect.width / 2;
if (left < 0) {
left = margin;
} else if (left + tooltipRect.width >= viewportWidth) {
left = viewportWidth - tooltipRect.width - margin;
}
let top: number;
if (position === "bottom") {
top = item.top + item.height + margin;
if (top + tooltipRect.height >= viewportHeight) {
top = item.top - tooltipRect.height - margin;
}
} else {
top = item.top - tooltipRect.height - margin;
if (top < 0) {
top = item.top + item.height + margin;
}
}
Object.assign(tooltip.style, {
top: `${top}px`,
left: `${left}px`,
});
};
const updateTooltip = (
item: HTMLDivElement,
tooltip: HTMLDivElement,
@@ -71,27 +27,49 @@ const updateTooltip = (
tooltip.textContent = label;
const itemRect = item.getBoundingClientRect();
updateTooltipPosition(tooltip, itemRect);
const {
x: itemX,
bottom: itemBottom,
top: itemTop,
width: itemWidth,
} = item.getBoundingClientRect();
const { width: labelWidth, height: labelHeight } =
tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
const left = itemX + itemWidth / 2 - labelWidth / 2;
const offsetLeft =
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
const top = itemBottom + margin;
const offsetTop =
top + labelHeight >= viewportHeight
? itemBottom - itemTop + labelHeight + margin * 2
: 0;
Object.assign(tooltip.style, {
top: `${top - offsetTop}px`,
left: `${left - offsetLeft}px`,
});
};
type TooltipProps = {
children: React.ReactNode;
label: string;
long?: boolean;
style?: React.CSSProperties;
};
export const Tooltip = ({
children,
label,
long = false,
style,
}: TooltipProps) => {
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
useEffect(() => {
return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
}, []);
return (
<div
className="excalidraw-tooltip-wrapper"
@@ -106,7 +84,6 @@ export const Tooltip = ({
onPointerLeave={() =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
}
style={style}
>
{children}
</div>
-4
View File
@@ -7,10 +7,6 @@
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
&:empty {
display: none;
}
}
.UserList > * {
+2 -49
View File
@@ -15,9 +15,8 @@ import { THEME } from "../constants";
const activeElementColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const iconFillColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.black : oc.gray[4];
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@@ -885,40 +884,6 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
transform="matrix(1,0,0,1,0,80)"
d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const publishIcon = createIcon(
<path
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
@@ -926,15 +891,3 @@ export const publishIcon = createIcon(
/>,
{ width: 640, height: 512 },
);
export const editIcon = createIcon(
<path
fill="currentColor"
d="M402.3 344.9l32-32c5-5 13.7-1.5 13.7 5.7V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h273.5c7.1 0 10.7 8.6 5.7 13.7l-32 32c-1.5 1.5-3.5 2.3-5.7 2.3H48v352h352V350.5c0-2.1.8-4.1 2.3-5.6zm156.6-201.8L296.3 405.7l-90.4 10c-26.2 2.9-48.5-19.2-45.6-45.6l10-90.4L432.9 17.1c22.9-22.9 59.9-22.9 82.7 0l43.2 43.2c22.9 22.9 22.9 60 .1 82.8zM460.1 174L402 115.9 216.2 301.8l-7.3 65.3 65.3-7.3L460.1 174zm64.8-79.7l-43.2-43.2c-4.1-4.1-10.8-4.1-14.8 0L436 82l58.1 58.1 30.9-30.9c4-4.2 4-10.8-.1-14.9z"
></path>,
{ width: 640, height: 512 },
);
export const eraser = createIcon(
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
);
+9 -36
View File
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
import { AppProps, CustomElementConfig } from "./types";
import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw";
@@ -24,7 +24,7 @@ export const POINTER_BUTTON = {
WHEEL: 1,
SECONDARY: 2,
TOUCH: -1,
} as const;
};
export enum EVENT {
COPY = "copy",
@@ -52,8 +52,6 @@ export enum EVENT {
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
}
export const ENV = {
@@ -94,9 +92,7 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg",
gif: "image/gif",
binary: "application/octet-stream",
@@ -108,8 +104,11 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib",
} as const;
export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
export const EXPORT_SOURCE = window.location.origin;
export const STORAGE_KEYS = {
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
@@ -120,7 +119,6 @@ export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
@@ -155,17 +153,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
},
};
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
type: "custom",
customType: "custom",
transformHandles: true,
displayData: { content: "", type: "svg" },
width: 40,
height: 40,
stackedOnTop: false,
onCreate: () => {},
disableContextMenu: false,
};
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
@@ -175,7 +162,8 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG = 10000;
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
@@ -189,18 +177,3 @@ export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
export const ENCRYPTION_KEY_BITS = 128;
export const VERSIONS = {
excalidraw: 2,
excalidrawLibrary: 2,
} as const;
export const BOUND_TEXT_PADDING = 5;
export const VERTICAL_ALIGN = {
TOP: "top",
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
+8 -10
View File
@@ -16,17 +16,15 @@
left: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
.Spinner {
font-size: 2.8em;
}
.LoadingMessage-text {
margin-top: 1em;
font-size: 0.8em;
}
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
+9 -31
View File
@@ -180,7 +180,7 @@
}
.buttonList label:focus-within,
input:focus-visible {
input:focus {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -190,14 +190,14 @@
user-select: none;
background-color: var(--button-gray-1);
border: 0;
border-radius: var(--border-radius-md);
border-radius: 4px;
margin: 0.125rem 0;
padding: 0.25rem;
white-space: nowrap;
cursor: pointer;
&:focus-visible {
&:focus {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -217,16 +217,14 @@
.active,
.buttonList label.active {
background-color: var(--color-primary);
--icon-fill-color: #{$oc-white};
background-color: var(--button-gray-2);
&:hover {
background-color: var(--color-primary-darker);
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--color-primary-darkest);
background-color: var(--button-gray-3);
}
}
@@ -236,7 +234,7 @@
justify-content: center;
align-items: center;
svg {
width: 35px;
width: 36px;
height: 14px;
padding: 2px;
opacity: 0.6;
@@ -290,16 +288,6 @@
width: 100%;
box-sizing: border-box;
.eraser {
&.ToolIcon:hover {
--icon-fill-color: #fff;
--keybinding-color: #fff;
}
&.active {
background-color: var(--color-primary);
}
}
}
.App-toolbar-content {
@@ -323,7 +311,7 @@
}
.App-menu_top {
grid-template-columns: auto max-content auto;
grid-template-columns: 1fr auto 1fr;
grid-gap: 4px;
align-items: flex-start;
cursor: default;
@@ -477,17 +465,7 @@
font-family: var(--ui-font);
}
.finalize-button {
display: grid;
grid-auto-flow: column;
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
}
.undo-redo-buttons,
.eraser-buttons {
.undo-redo-buttons {
display: grid;
grid-auto-flow: column;
gap: 0.4em;
+3 -17
View File
@@ -12,7 +12,7 @@
--dialog-border-color: #{$oc-gray-6};
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: #{$oc-gray-9};
--icon-fill-color: #{$oc-black};
--icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white};
@@ -32,19 +32,10 @@
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--space-factor: 0.25rem;
--text-primary-color: #{$oc-gray-8};
--color-primary: #6965db;
--color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1;
--color-primary-light: #e2e1fc;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
&.theme--dark {
background: $oc-black;
@@ -80,12 +71,7 @@
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--shadow-island: 1px 1px 5px #{transparentize($oc-black, 0.7)};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--text-primary-color: #{$oc-gray-4};
--color-primary: #5650f0;
--color-primary-darker: #4b46d8;
--color-primary-darkest: #3e39be;
--color-primary-light: #3f3d64;
}
}
+16 -14
View File
@@ -1,16 +1,20 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import {
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
import { AppState, DataURL } from "../types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
@@ -159,17 +163,13 @@ export const loadFromBlob = async (
}
};
export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
export const loadLibraryFromBlob = async (blob: Blob) => {
const contents = await parseFileContents(blob);
const data: ImportedLibraryData | undefined = JSON.parse(contents);
if (!isValidLibrary(data)) {
throw new Error("Invalid library");
const data: ImportedLibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus);
return data;
};
export const canvasToBlob = async (
@@ -271,6 +271,8 @@ export const resizeImageFile = async (
};
}
const fileType = file.type;
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
}
@@ -279,7 +281,7 @@ export const resizeImageFile = async (
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
file.name,
{
type: opts.outputType || file.type,
type: fileType,
},
);
};
+1 -13
View File
@@ -234,19 +234,7 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
let cursor = 0;
// first chunk is the version
const version = dataView(
concatenatedBuffer,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
);
// If version is outside of the supported versions, throw an error.
// This usually means the buffer wasn't encoded using this API, so we'd only
// waste compute.
if (version > CONCAT_BUFFERS_VERSION) {
throw new Error(`invalid version ${version}`);
}
// first chunk is the version (ignored for now)
cursor += VERSION_DATAVIEW_BYTES;
while (true) {
-2
View File
@@ -13,9 +13,7 @@ type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
+33 -6
View File
@@ -1,8 +1,11 @@
import decodePng from "png-chunks-extract";
import extractPngChunks from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { PngChunk } from "../types";
export { extractPngChunks };
// -----------------------------------------------------------------------------
// PNG
@@ -28,7 +31,9 @@ const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
export const getTEXtChunk = async (
blob: Blob,
): Promise<{ keyword: string; text: string } | null> => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
const chunks = extractPngChunks(
new Uint8Array(await blobToArrayBuffer(blob)),
);
const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt");
if (metadataChunk) {
return tEXt.decode(metadataChunk.data);
@@ -36,6 +41,28 @@ export const getTEXtChunk = async (
return null;
};
export const findPngChunk = (
chunks: PngChunk[],
name: PngChunk["name"],
/** this makes the search stop before IDAT chunk (before which most
* metadata chunks reside). This is a perf optim. */
breakBeforeIDAT = true,
) => {
let i = 0;
const len = chunks.length;
while (i <= len) {
const chunk = chunks[i];
if (chunk.name === name) {
return chunk;
}
if (breakBeforeIDAT && chunk.name === "IDAT") {
return null;
}
i++;
}
return null;
};
export const encodePngMetadata = async ({
blob,
metadata,
@@ -43,7 +70,9 @@ export const encodePngMetadata = async ({
blob: Blob;
metadata: string;
}) => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
const chunks = extractPngChunks(
new Uint8Array(await blobToArrayBuffer(blob)),
);
const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw,
@@ -105,9 +134,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
if (!match) {
throw new Error("INVALID");
}
+5 -12
View File
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export const exportCanvas = async (
type: Omit<ExportType, "backend">,
type: ExportType,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
@@ -56,7 +56,7 @@ export const exportCanvas = async (
{
description: "Export to SVG",
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
extension: "svg",
fileHandle,
},
);
@@ -73,10 +73,10 @@ export const exportCanvas = async (
});
tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas);
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (appState.exportEmbedScene) {
blob = await (
await import(/* webpackChunkName: "image" */ "./image")
@@ -89,24 +89,17 @@ export const exportCanvas = async (
return await fileSave(blob, {
description: "Export to PNG",
name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
extension: "png",
fileHandle,
});
} else if (type === "clipboard") {
try {
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;
}
throw new Error(t("alerts.couldNotCopyToClipboard"));
} finally {
tempCanvas.remove();
}
} else {
tempCanvas.remove();
// shouldn't happen
throw new Error("Unsupported export type");
}
};
+6 -16
View File
@@ -1,11 +1,6 @@
import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
@@ -15,7 +10,6 @@ import {
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
ImportedLibraryData,
} from "./types";
import Library from "./library";
@@ -48,7 +42,7 @@ export const serializeAsJSON = (
): string => {
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw,
version: 2,
source: EXPORT_SOURCE,
elements:
type === "local"
@@ -115,7 +109,7 @@ export const isValidExcalidrawData = (data?: {
);
};
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
export const isValidLibrary = (json: any) => {
return (
typeof json === "object" &&
json &&
@@ -124,18 +118,14 @@ export const isValidLibrary = (json: any): json is ImportedLibraryData => {
);
};
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
version: 2,
source: EXPORT_SOURCE,
libraryItems,
};
return JSON.stringify(data, null, 2);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const serialized = serializeLibraryAsJSON(libraryItems);
const serialized = JSON.stringify(data, null, 2);
await fileSave(
new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,

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