Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle
88691b1c3c generate username on portal init instead of collab mount 2021-10-16 16:05:30 +02:00
dwelle
146c510faa feat: generate random username for new users during collab 2021-10-16 15:58:27 +02:00
447 changed files with 26169 additions and 73694 deletions

View File

@@ -1,43 +0,0 @@
{
// These tasks will run in order when initializing your CodeSandbox project.
"setupTasks": [
{
"name": "Install Dependencies",
"command": "yarn install"
}
],
// These tasks can be run from CodeSandbox. Running one will open a log in the app.
"tasks": {
"build": {
"name": "Build",
"command": "yarn build",
"runAtStart": false
},
"fix": {
"name": "Fix",
"command": "yarn fix",
"runAtStart": false
},
"prettier": {
"name": "Prettify",
"command": "yarn prettier",
"runAtStart": false
},
"start": {
"name": "Start Excalidraw",
"command": "yarn start",
"runAtStart": true
},
"test": {
"name": "Run Tests",
"command": "yarn test",
"runAtStart": false
},
"install-deps": {
"name": "Install Dependencies",
"command": "yarn install",
"restartOn": { "files": ["yarn.lock"] }
}
}
}

View File

@@ -1,6 +1,5 @@
* *
!.env.development !.env
!.env.production
!.eslintrc.json !.eslintrc.json
!.npmrc !.npmrc
!.prettierrc !.prettierrc

5
.env Normal file
View File

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

View File

@@ -1,24 +0,0 @@
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
# 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_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false

View File

@@ -1,17 +1 @@
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
REACT_APP_PLUS_APP=https://app.excalidraw.com

View File

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

View File

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

34
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- lipis
assignees:
- lipis
- package-ecosystem: npm
directory: /src/packages/excalidraw/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
- package-ecosystem: npm
directory: /src/packages/utils/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992

View File

@@ -1,8 +1,8 @@
name: Auto release excalidraw next name: Auto release @excalidraw/excalidraw-next
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
Auto-release-excalidraw-next: Auto-release-excalidraw-next:
@@ -23,5 +23,4 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release - name: Auto release
run: | run: |
yarn add @actions/core
yarn autorelease yarn autorelease

View File

@@ -1,55 +0,0 @@
name: Auto release excalidraw preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"

View File

@@ -3,7 +3,7 @@ name: Build Docker image
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
build-docker: build-docker:

29
.github/workflows/build-packages.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Build packages
on:
push:
branches:
- master
pull_request:
jobs:
packages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Install dependencies
run: |
yarn --frozen-lockfile
yarn --cwd src/packages/excalidraw
yarn --cwd src/packages/utils
- name: Build @excalidraw/excalidraw
run: |
yarn --cwd src/packages/excalidraw run pack
- name: Build @excalidraw/utils
run: |
yarn --cwd src/packages/utils run pack

View File

@@ -3,7 +3,7 @@ name: Cancel previous runs
on: on:
push: push:
branches: branches:
- release - master
pull_request: pull_request:
jobs: jobs:

View File

@@ -3,23 +3,18 @@ name: Publish Docker
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
publish-docker: publish-docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - uses: actions/checkout@v2
uses: actions/checkout@v3 - uses: docker/build-push-action@v1
- name: Login to DockerHub
uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push repository: excalidraw/excalidraw
uses: docker/build-push-action@v3 tag_with_ref: true
with: tag_with_sha: true
context: .
push: true
tags: excalidraw/excalidraw:latest

View File

@@ -3,7 +3,7 @@ name: New Sentry production release
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
sentry: sentry:

4
.gitignore vendored
View File

@@ -19,9 +19,7 @@ logs
node_modules node_modules
npm-debug.log* npm-debug.log*
package-lock.json package-lock.json
static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
src/packages/excalidraw/types src/packages/excalidraw/types
src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js

View File

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

View File

@@ -32,10 +32,6 @@ Last but not least, we're thankful to these companies for offering their service
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com) [![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
## Documentation ## Documentation
### Shortcuts ### Shortcuts
@@ -88,7 +84,7 @@ Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/exc
### Code Sandbox ### Code Sandbox
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw - Go to https://codesandbox.io/s/github/excalidraw/excalidraw
- You may need to sign in with GitHub and reload the page - You may need to sign in with GitHub and reload the page
- You can start coding instantly, and even send PRs from there! - You can start coding instantly, and even send PRs from there!
@@ -122,47 +118,16 @@ yarn start
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor. Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Collaboration
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
#### Commands #### Commands
##### Install the dependencies | Command | Description |
| ------------------ | --------------------------------- |
``` | `yarn` | Install the dependencies |
yarn | `yarn start` | Run the project |
``` | `yarn fix` | Reformat all files with Prettier |
| `yarn test` | Run tests |
##### Run the project | `yarn test:update` | Update test snapshots |
| `yarn test:code` | Test for formatting with Prettier |
```
yarn start
```
##### Reformat all files with Prettier
```
yarn fix
```
##### Run tests
```
yarn test
```
##### Update test snapshots
```
yarn test:update
```
##### Test for formatting with Prettier
```
yarn test:code
```
#### Docker Compose #### Docker Compose

20
dev-docs/.gitignore vendored
View File

@@ -1,20 +0,0 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -1,41 +0,0 @@
# Website
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
Using SSH:
```
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@@ -1,3 +0,0 @@
module.exports = {
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
};

View File

@@ -1,6 +0,0 @@
---
sidebar_position: 1
title: Overview
---
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).

View File

@@ -1,8 +0,0 @@
---
sidebar_position: 1
title: Introduction
---
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).

View File

@@ -1,6 +0,0 @@
---
sidebar_position: 1
title: Overview
---
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).

View File

@@ -1,121 +0,0 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Excalidraw developer docs",
tagline:
"For Excalidraw contributors or those integrating the Excalidraw editor",
url: "https://docs.excalidraw.com.com",
baseUrl: "/",
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
favicon: "img/favicon.ico",
organizationName: "Excalidraw", // Usually your GitHub org/user name.
projectName: "excalidraw", // Usually your repo name.
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
// to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
editUrl: "https://github.com/excalidraw/docs/tree/master/",
},
theme: {
customCss: require.resolve("./src/css/custom.css"),
},
}),
],
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
navbar: {
title: "Excalidraw Docs",
logo: {
alt: "Excalidraw Logo",
src: "img/logo.svg",
},
items: [
{
type: "doc",
docId: "get-started",
position: "left",
label: "Get started",
},
{
to: "https://blog.excalidraw.com",
label: "Blog",
position: "left",
},
{
to: "https://github.com/excalidraw/excalidraw",
label: "GitHub",
position: "right",
},
],
},
footer: {
style: "dark",
links: [
{
title: "Docs",
items: [
{
label: "Get Started",
to: "/docs/get-started",
},
],
},
{
title: "Community",
items: [
{
label: "Discord",
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
},
],
},
{
title: "More",
items: [
{
label: "Blog",
to: "https://blog.excalidraw.com",
},
{
label: "GitHub",
to: "https://github.com/excalidraw/excalidraw",
},
],
},
],
copyright: `Made with ❤️ Built with Docusaurus`,
},
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
},
}),
};
module.exports = config;

View File

@@ -1,46 +0,0 @@
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start --port 3003",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "2.0.0-rc.1",
"@docusaurus/preset-classic": "2.0.0-rc.1",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
"@tsconfig/docusaurus": "^1.0.5",
"typescript": "^4.7.4"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=16.14"
}
}

View File

@@ -1,31 +0,0 @@
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
// But you can create a sidebar manually
/*
tutorialSidebar: [
{
type: 'category',
label: 'Tutorial',
items: ['hello'],
},
],
*/
};
module.exports = sidebars;

View File

@@ -1,62 +0,0 @@
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
const FeatureList = [
{
title: "Learn how Excalidraw works",
Svg: require("@site/static/img/undraw_innovative.svg").default,
description: (
<>Want to contribute to Excalidraw but got lost in the codebase?</>
),
},
{
title: "Integrate Excalidraw",
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
description: (
<>
Want to build your own app powered by Excalidraw by don't know where to
start?
</>
),
},
{
title: "Help us improve",
Svg: require("@site/static/img/undraw_add_files.svg").default,
description: (
<>
Are the docs missing something? Anything you had trouble understanding
or needs an explanation? Come contribute to the docs to make them even
better!
</>
),
},
];
function Feature({ Svg, title, description }) {
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
export default function HomepageFeatures() {
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
}

View File

@@ -1,70 +0,0 @@
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
type FeatureItem = {
title: string;
Svg: React.ComponentType<React.ComponentProps<"svg">>;
description: JSX.Element;
};
const FeatureList: FeatureItem[] = [
{
title: "Easy to Use",
Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
description: (
<>
Docusaurus was designed from the ground up to be easily installed and
used to get your website up and running quickly.
</>
),
},
{
title: "Focus on What Matters",
Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
description: (
<>
Docusaurus lets you focus on your docs, and we&apos;ll do the chores. Go
ahead and move your docs into the <code>docs</code> directory.
</>
),
},
{
title: "Powered by React",
Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
description: (
<>
Extend or customize your website layout by reusing React. Docusaurus can
be extended while reusing the same header and footer.
</>
),
},
];
function Feature({ title, Svg, description }: FeatureItem) {
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
export default function HomepageFeatures(): JSX.Element {
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
}

View File

@@ -1,11 +0,0 @@
.features {
display: flex;
align-items: center;
padding: 2rem 0;
width: 100%;
}
.featureSvg {
height: 200px;
width: 200px;
}

View File

@@ -1,43 +0,0 @@
/**
* Any CSS included here will be global. The classic template
* bundles Infima by default. Infima is a CSS framework designed to
* work well for content-centric websites.
*/
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #6965db;
--ifm-color-primary-dark: #5b57d1;
--ifm-color-primary-darker: #5b57d1;
--ifm-color-primary-darkest: #4a47b1;
--ifm-color-primary-light: #5b57d1;
--ifm-color-primary-lighter: #5b57d1;
--ifm-color-primary-lightest: #5b57d1;
--ifm-code-font-size: 95%;
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme="dark"] {
--ifm-color-primary: #5650f0;
--ifm-color-primary-dark: #4b46d8;
--ifm-color-primary-darker: #4b46d8;
--ifm-color-primary-darkest: #3e39be;
--ifm-color-primary-light: #3f3d64;
--ifm-color-primary-lighter: #3f3d64;
--ifm-color-primary-lightest: #3f3d64;
}
.docusaurus-highlight-code-line {
background-color: rgba(0, 0, 0, 0.1);
display: block;
margin: 0 calc(-1 * var(--ifm-pre-padding));
padding: 0 var(--ifm-pre-padding);
}
[data-theme="dark"] .docusaurus-highlight-code-line {
background-color: rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .navbar__logo {
filter: invert(93%) hue-rotate(180deg);
}

View File

@@ -1,42 +0,0 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx("hero hero--primary", styles.heroBanner)}>
<div className="container">
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs/get-started"
>
Get started
</Link>
</div>
</div>
</header>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
</main>
</Layout>
);
}

View File

@@ -1,27 +0,0 @@
/**
* CSS files with the .module.css suffix will be treated as CSS modules
* and scoped locally.
*/
.heroBanner {
padding: 4rem 0;
text-align: center;
position: relative;
overflow: hidden;
}
[data-theme="dark"] .heroBanner {
color: #fff;
}
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2rem;
}
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -1,42 +0,0 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx("hero hero--primary", styles.heroBanner)}>
<div className="container">
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs/get-started"
>
Get started
</Link>
</div>
</div>
</header>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
</main>
</Layout>
);
}

View File

@@ -1,7 +0,0 @@
---
title: Markdown page example
---
# Markdown page example
You don't need React to write simple standalone pages.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,4 +0,0 @@
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -1,7 +0,0 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@tsconfig/docusaurus/tsconfig.json",
"compilerOptions": {
"baseUrl": "."
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -19,82 +19,66 @@
] ]
}, },
"dependencies": { "dependencies": {
"@dwelle/browser-fs-access": "0.21.1",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "12.1.5", "@testing-library/react": "11.2.6",
"@tldraw/vec": "1.7.1", "@tldraw/vec": "0.0.106",
"@types/jest": "27.4.0", "@types/jest": "26.0.22",
"@types/pica": "5.1.3", "@types/react": "17.0.3",
"@types/react": "18.0.15", "@types/react-dom": "17.0.3",
"@types/react-dom": "18.0.6",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.0",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.3.3", "nanoid": "3.1.22",
"open-color": "1.9.1", "open-color": "1.8.0",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.2.0", "perfect-freehand": "1.0.15",
"pica": "7.1.1",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0", "points-on-curve": "0.2.0",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "18.2.0", "react": "17.0.2",
"react-dom": "18.2.0", "react-dom": "17.0.2",
"react-scripts": "5.0.1", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.4.1",
"sass": "1.51.0", "sass": "1.32.10",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.9.4", "typescript": "4.2.4"
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/lodash.throttle": "4.1.6",
"@types/lodash.throttle": "4.1.7", "@types/pako": "1.0.1",
"@types/pako": "1.0.3", "@types/resize-observer-browser": "0.1.5",
"@types/resize-observer-browser": "0.1.7", "eslint-config-prettier": "8.3.0",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1", "firebase-tools": "9.9.0",
"husky": "7.0.4", "husky": "4.3.8",
"jest-canvas-mock": "2.4.0", "jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.7", "lint-staged": "10.5.4",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.6.2", "prettier": "2.2.1",
"rewire": "6.0.0" "rewire": "5.0.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
}, },
"homepage": ".", "homepage": ".",
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
], ],
"resetMocks": false "resetMocks": false
}, },
@@ -103,8 +87,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build", "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
@@ -113,10 +97,8 @@
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start", "start": "react-scripts start",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "react-scripts test --passWithNoTests", "test:app": "react-scripts test --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -125,8 +107,6 @@
"test:typecheck": "tsc", "test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false", "test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app", "test": "yarn test:app",
"autorelease": "node scripts/autorelease.js", "autorelease": "node scripts/autorelease.js"
"prerelease": "node scripts/prerelease.js",
"release": "node scripts/release.js"
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -11,28 +11,3 @@
src: url("Cascadia.woff2"); src: url("Cascadia.woff2");
font-display: swap; font-display: swap;
} }
@font-face {
font-family: "Assistant";
src: url("Assistant-Regular.woff2");
font-display: swap;
font-weight: 400;
}
@font-face {
font-family: "Assistant";
src: url("Assistant-Medium.woff2");
font-display: swap;
font-weight: 500;
}
@font-face {
font-family: "Assistant";
src: url("Assistant-SemiBold.woff2");
font-display: swap;
font-weight: 600;
}
@font-face {
font-family: "Assistant";
src: url("Assistant-Bold.woff2");
font-display: swap;
font-weight: 700;
}

View File

@@ -8,50 +8,21 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
/> />
<meta name="referrer" content="origin" /> <meta name="referrer" content="origin" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#121212" />
<!-- Primary Meta Tags --> <meta name="theme-color" content="#000" />
<meta
name="title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta name="image" content="https://excalidraw.com/og-general-v1.png" />
<!-- Open Graph / Facebook --> <!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
<meta property="og:site_name" content="Excalidraw" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://excalidraw.com" />
<meta <meta
property="og:title" http-equiv="origin-trial"
content="Excalidraw — Collaborative whiteboarding made easy" content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
/> />
<meta property="og:image:alt" content="Excalidraw logo" />
<meta
property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta property="og:image" content="https://excalidraw.com/og-fb-v1.png" />
<!-- Twitter --> <!-- File Handling (https://web.dev/file-handling/) -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:site" content="@excalidraw" />
<meta property="twitter:url" content="https://excalidraw.com" />
<meta <meta
property="twitter:title" http-equiv="origin-trial"
content="Excalidraw — Collaborative whiteboarding made easy" content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
/>
<meta
property="twitter:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta
property="twitter:image"
content="https://excalidraw.com/og-twitter-v1.png"
/> />
<!-- General tags --> <!-- General tags -->
@@ -59,44 +30,39 @@
name="description" name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta name="image" content="og-image.png" />
<!-------------------------------------------------------------------------> <!-- OpenGraph tags -->
<!-- to minimize white flash on load when user has dark mode enabled --> <meta property="og:url" content="https://excalidraw.com" />
<script> <meta property="og:site_name" content="Excalidraw" />
try { <meta property="og:type" content="website" />
// <meta property="og:title" content="Excalidraw" />
const theme = window.localStorage.getItem("excalidraw-theme"); <meta
if (theme === "dark") { property="og:description"
document.documentElement.classList.add("dark"); content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
} />
} catch {} <!-- OG tags require an absolute url for images -->
</script> <meta
<style> property="og:image"
html.dark { name="twitter:image"
background-color: #121212; content="https://excalidraw.com/og-image.png"
color: #fff; />
} <meta
</style> property="og:image:secure_url"
<!-------------------------------------------------------------------------> name="twitter:image"
content="https://excalidraw.com/og-image.png"
/>
<meta property="og:image:width" content="1280" />
<meta property="og:image:height" content="669" />
<meta property="og:image:alt" content="Excalidraw logo with byline." />
<script> <!-- Twitter Card tags -->
// Redirect Excalidraw+ users which have auto-redirect enabled. <meta name="twitter:card" content="summary_large_image" />
// <meta name="twitter:title" content="Excalidraw" />
// Redirect only the bare root path, so link/room/library urls are not <meta
// redirected. name="twitter:description"
// content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
// Putting into index.html for best performance (can't redirect on server />
// due to location.hash checks).
if (
window.location.pathname === "/" &&
!window.location.hash &&
!window.location.search &&
// if its present redirect
document.cookie.includes("excplus-autoredirect=true")
) {
window.location.href = "https://app.excalidraw.com";
}
</script>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
@@ -118,6 +84,12 @@
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
rel="preconnect"
crossorigin="anonymous"
/>
<link <link
rel="manifest" rel="manifest"
href="manifest.json" href="manifest.json"
@@ -125,29 +97,12 @@
/> />
<link rel="stylesheet" href="fonts.css" type="text/css" /> <link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
<script>
{
const _WebSocket = window.WebSocket;
window.WebSocket = function (url) {
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
console.info(
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
);
} else {
return new _WebSocket(url);
}
};
}
</script>
<% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/"; window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' && <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
async async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%" src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
@@ -167,8 +122,8 @@
body, body,
html { html {
margin: 0; margin: 0;
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Segoe UI, Roboto, Helvetica, Arial, sans-serif; Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font); font-family: var(--ui-font);
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@@ -183,10 +138,30 @@
width: 1px; width: 1px;
overflow: hidden; overflow: hidden;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; white-space: nowrap; /* added line */
user-select: none; user-select: none;
} }
.LoadingMessage {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
#root { #root {
height: 100%; height: 100%;
-webkit-touch-callout: none; -webkit-touch-callout: none;
@@ -195,10 +170,8 @@
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
}
@media screen and (min-width: 1200px) { @media screen and (min-width: 1200px) {
#root {
-webkit-touch-callout: default; -webkit-touch-callout: default;
-webkit-user-select: auto; -webkit-user-select: auto;
-khtml-user-select: auto; -khtml-user-select: auto;
@@ -215,6 +188,10 @@
<header> <header>
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root">
<div class="LoadingMessage">
<span>Loading scene...</span>
</div>
</div>
</body> </body>
</html> </html>

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,9 +1,3 @@
User-agent: Twitterbot
Disallow:
User-agent: facebookexternalhit
Disallow:
user-agent: * user-agent: *
Allow: /$ Allow: /$
Disallow: / Disallow: /

View File

@@ -1,72 +1,51 @@
const fs = require("fs"); const fs = require("fs");
const { exec, execSync } = require("child_process"); const { exec, execSync } = require("child_process");
const core = require("@actions/core");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
const isPreview = process.argv.slice(2)[0] === "preview";
const getShortCommitHash = () => { const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim(); return execSync("git rev-parse --short HEAD").toString().trim();
}; };
const publish = () => { const publish = () => {
const tag = isPreview ? "preview" : "next";
try { try {
execSync(`yarn --frozen-lockfile`); execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`); execSync(`yarn --cwd ${excalidrawDir} publish`);
console.info(`Published ${pkg.name}@${tag}🎉`); } catch (e) {
core.setOutput( console.error(e);
"result",
`**Preview version has been shipped** :rocket:
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
);
} catch (error) {
core.setOutput("result", "package couldn't be published :warning:!");
console.error(error);
process.exit(1);
} }
}; };
// get files changed between prev and head commit // get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => { exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) { if (error || stderr) {
console.error(error); console.error(error);
core.setOutput("result", ":warning: Package couldn't be published!");
process.exit(1); process.exit(1);
} }
const changedFiles = stdout.trim().split("\n"); const changedFiles = stdout.trim().split("\n");
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/; const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => { const excalidrawPackageFiles = changedFiles.filter((file) => {
return ( return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
!filesToIgnoreRegex.test(file)
);
}); });
if (!excalidrawPackageFiles.length) { if (!excalidrawPackageFiles.length) {
console.info("Skipping release as no valid diff found");
core.setOutput("result", "Skipping release as no valid diff found");
process.exit(0); process.exit(0);
} }
// update package.json // update package.json
let version = `${pkg.version}-${getShortCommitHash()}`; pkg.version = `${pkg.version}-${getShortCommitHash()}`;
pkg.name = "@excalidraw/excalidraw-next";
// update readme
if (isPreview) {
// use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0];
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
}
pkg.version = version;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8"); fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
console.info("Publish in progress..."); // update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
publish(); publish();
}); });

View File

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

View File

@@ -1,21 +0,0 @@
const { exec } = require("child_process");
// 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);
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const docFiles = changedFiles.filter((file) => {
return file.indexOf("docs") >= 0;
});
if (!docFiles.length) {
console.info("Skipping building docs as no valid diff found");
process.exit(0);
}
// Exit code 1 to build the docs in ignoredBuildStep
process.exit(1);
});

View File

@@ -5,17 +5,13 @@ const THRESSHOLD = 85;
const crowdinMap = { const crowdinMap = {
"ar-SA": "en-ar", "ar-SA": "en-ar",
"bg-BG": "en-bg", "bg-BG": "en-bg",
"bn-BD": "en-bn",
"ca-ES": "en-ca", "ca-ES": "en-ca",
"da-DK": "en-da",
"de-DE": "en-de", "de-DE": "en-de",
"el-GR": "en-el", "el-GR": "en-el",
"es-ES": "en-es", "es-ES": "en-es",
"eu-ES": "en-eu",
"fa-IR": "en-fa", "fa-IR": "en-fa",
"fi-FI": "en-fi", "fi-FI": "en-fi",
"fr-FR": "en-fr", "fr-FR": "en-fr",
"gl-ES": "en-gl",
"he-IL": "en-he", "he-IL": "en-he",
"hi-IN": "en-hi", "hi-IN": "en-hi",
"hu-HU": "en-hu", "hu-HU": "en-hu",
@@ -24,7 +20,6 @@ const crowdinMap = {
"ja-JP": "en-ja", "ja-JP": "en-ja",
"kab-KAB": "en-kab", "kab-KAB": "en-kab",
"ko-KR": "en-ko", "ko-KR": "en-ko",
"ku-TR": "en-ku",
"my-MM": "en-my", "my-MM": "en-my",
"nb-NO": "en-nb", "nb-NO": "en-nb",
"nl-NL": "en-nl", "nl-NL": "en-nl",
@@ -36,38 +31,27 @@ const crowdinMap = {
"pt-PT": "en-pt", "pt-PT": "en-pt",
"ro-RO": "en-ro", "ro-RO": "en-ro",
"ru-RU": "en-ru", "ru-RU": "en-ru",
"si-LK": "en-silk",
"sk-SK": "en-sk", "sk-SK": "en-sk",
"sl-SI": "en-sl",
"sv-SE": "en-sv", "sv-SE": "en-sv",
"ta-IN": "en-ta",
"tr-TR": "en-tr", "tr-TR": "en-tr",
"uk-UA": "en-uk", "uk-UA": "en-uk",
"zh-CN": "en-zhcn", "zh-CN": "en-zhcn",
"zh-HK": "en-zhhk",
"zh-TW": "en-zhtw", "zh-TW": "en-zhtw",
"lt-LT": "en-lt",
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "en-cs",
"kk-KZ": "en-kk", "kk-KZ": "en-kk",
"vi-VN": "en-vi",
"mr-IN": "en-mr",
}; };
const flags = { const flags = {
"ar-SA": "🇸🇦", "ar-SA": "🇸🇦",
"bg-BG": "🇧🇬", "bg-BG": "🇧🇬",
"bn-BD": "🇧🇩",
"ca-ES": "🏳", "ca-ES": "🏳",
"cs-CZ": "🇨🇿",
"da-DK": "🇩🇰",
"de-DE": "🇩🇪", "de-DE": "🇩🇪",
"el-GR": "🇬🇷", "el-GR": "🇬🇷",
"es-ES": "🇪🇸", "es-ES": "🇪🇸",
"fa-IR": "🇮🇷", "fa-IR": "🇮🇷",
"fi-FI": "🇫🇮", "fi-FI": "🇫🇮",
"fr-FR": "🇫🇷", "fr-FR": "🇫🇷",
"gl-ES": "🇪🇸",
"he-IL": "🇮🇱", "he-IL": "🇮🇱",
"hi-IN": "🇮🇳", "hi-IN": "🇮🇳",
"hu-HU": "🇭🇺", "hu-HU": "🇭🇺",
@@ -75,11 +59,7 @@ const flags = {
"it-IT": "🇮🇹", "it-IT": "🇮🇹",
"ja-JP": "🇯🇵", "ja-JP": "🇯🇵",
"kab-KAB": "🏳", "kab-KAB": "🏳",
"kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷", "ko-KR": "🇰🇷",
"ku-TR": "🏳",
"lt-LT": "🇱🇹",
"lv-LV": "🇱🇻",
"my-MM": "🇲🇲", "my-MM": "🇲🇲",
"nb-NO": "🇳🇴", "nb-NO": "🇳🇴",
"nl-NL": "🇳🇱", "nl-NL": "🇳🇱",
@@ -91,36 +71,27 @@ const flags = {
"pt-PT": "🇵🇹", "pt-PT": "🇵🇹",
"ro-RO": "🇷🇴", "ro-RO": "🇷🇴",
"ru-RU": "🇷🇺", "ru-RU": "🇷🇺",
"si-LK": "🇱🇰",
"sk-SK": "🇸🇰", "sk-SK": "🇸🇰",
"sl-SI": "🇸🇮",
"sv-SE": "🇸🇪", "sv-SE": "🇸🇪",
"ta-IN": "🇮🇳",
"tr-TR": "🇹🇷", "tr-TR": "🇹🇷",
"uk-UA": "🇺🇦", "uk-UA": "🇺🇦",
"zh-CN": "🇨🇳", "zh-CN": "🇨🇳",
"zh-HK": "🇭🇰",
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"eu-ES": "🇪🇦", "lv-LV": "🇱🇻",
"vi-VN": "🇻🇳", "cs-CZ": "🇨🇿",
"mr-IN": "🇮🇳", "kk-KZ": "🇰🇿",
}; };
const languages = { const languages = {
"ar-SA": "العربية", "ar-SA": "العربية",
"bg-BG": "Български", "bg-BG": "Български",
"bn-BD": "Bengali",
"ca-ES": "Català", "ca-ES": "Català",
"cs-CZ": "Česky",
"da-DK": "Dansk",
"de-DE": "Deutsch", "de-DE": "Deutsch",
"el-GR": "Ελληνικά", "el-GR": "Ελληνικά",
"es-ES": "Español", "es-ES": "Español",
"eu-ES": "Euskara",
"fa-IR": "فارسی", "fa-IR": "فارسی",
"fi-FI": "Suomi", "fi-FI": "Suomi",
"fr-FR": "Français", "fr-FR": "Français",
"gl-ES": "Galego",
"he-IL": "עברית", "he-IL": "עברית",
"hi-IN": "हिन्दी", "hi-IN": "हिन्दी",
"hu-HU": "Magyar", "hu-HU": "Magyar",
@@ -128,11 +99,7 @@ const languages = {
"it-IT": "Italiano", "it-IT": "Italiano",
"ja-JP": "日本語", "ja-JP": "日本語",
"kab-KAB": "Taqbaylit", "kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어", "ko-KR": "한국어",
"ku-TR": "Kurdî",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu",
"my-MM": "Burmese", "my-MM": "Burmese",
"nb-NO": "Norsk bokmål", "nb-NO": "Norsk bokmål",
"nl-NL": "Nederlands", "nl-NL": "Nederlands",
@@ -144,18 +111,15 @@ const languages = {
"pt-PT": "Português", "pt-PT": "Português",
"ro-RO": "Română", "ro-RO": "Română",
"ru-RU": "Русский", "ru-RU": "Русский",
"si-LK": "සිංහල",
"sk-SK": "Slovenčina", "sk-SK": "Slovenčina",
"sl-SI": "Slovenščina",
"sv-SE": "Svenska", "sv-SE": "Svenska",
"ta-IN": "Tamil",
"tr-TR": "Türkçe", "tr-TR": "Türkçe",
"uk-UA": "Українська", "uk-UA": "Українська",
"zh-CN": "简体中文", "zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"vi-VN": "Tiếng Việt", "lv-LV": "Latviešu",
"mr-IN": "मराठी", "cs-CZ": "Česky",
"kk-KZ": "Қазақ тілі",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(

View File

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

View File

@@ -1,44 +1,39 @@
const fs = require("fs"); const fs = require("fs");
const { execSync } = require("child_process"); const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateReadme = require("./updateReadme");
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8"); const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
const updateReadme = () => { pkg.version = nextVersion;
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw"); const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
// remove note for stable readme
const data = originalReadMe.slice(excalidrawIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
}; };
const publish = () => { const release = async (nextVersion) => {
try { try {
execSync(`yarn --frozen-lockfile`); updateReadme();
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); await updateChangelog(nextVersion);
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); updatePackageVersion(nextVersion);
execSync(`yarn --cwd ${excalidrawDir} publish`); await exec(`git add -u`);
} catch (error) { await exec(
console.error(error); `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (e) {
console.error(e);
process.exit(1); process.exit(1);
} }
}; };
const release = () => { const nextVersion = process.argv.slice(2)[0];
updateReadme(); if (!nextVersion) {
console.info("Note for stable readme removed"); console.error("Pass the next version to release!");
process.exit(1);
publish(); }
console.info(`Published ${pkg.version}!`); release(nextVersion);
// revert readme after release
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Readme reverted");
};
release();

View File

@@ -20,7 +20,7 @@ const headerForType = {
perf: "Performance", perf: "Performance",
build: "Build", build: "Build",
}; };
const badCommits = [];
const getCommitHashForLastVersion = async () => { const getCommitHashForLastVersion = async () => {
try { try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`; const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
@@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => {
`git log --format=format:"%H" --grep=${commitMessage}`, `git log --format=format:"%H" --grep=${commitMessage}`,
); );
return stdout; return stdout;
} catch (error) { } catch (e) {
console.error(error); console.error(e);
} }
}; };
@@ -53,26 +53,19 @@ const getLibraryCommitsSinceLastRelease = async () => {
const messageWithoutType = commit.slice(indexOfColon + 1).trim(); const messageWithoutType = commit.slice(indexOfColon + 1).trim();
const messageWithCapitalizeFirst = const messageWithCapitalizeFirst =
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1); messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
const prMatch = commit.match(/\(#([0-9]*)\)/); const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
if (prMatch) {
const prNumber = prMatch[1];
// return if the changelog already contains the pr number which would happen for package updates // return if the changelog already contains the pr number which would happen for package updates
if (existingChangeLog.includes(prNumber)) { if (existingChangeLog.includes(prNumber)) {
return; return;
}
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
} else {
badCommits.push(commit);
commitList[type].push(messageWithCapitalizeFirst);
} }
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
}); });
console.info("Bad commits:", badCommits);
return commitList; return commitList;
}; };

27
scripts/updateReadme.js Normal file
View File

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

View File

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

View File

@@ -8,13 +8,13 @@ import {
CenterVerticallyIcon, CenterVerticallyIcon,
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
@@ -34,16 +34,13 @@ const alignSelectedElements = (
const updatedElements = alignElements(selectedElements, alignment); const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -60,7 +57,7 @@ export const actionAlignTop = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignTopIcon} icon={<AlignTopIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignTop")}${getShortcutKey( title={`${t("labels.alignTop")}${getShortcutKey(
"CtrlOrCmd+Shift+Up", "CtrlOrCmd+Shift+Up",
@@ -73,7 +70,6 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -90,7 +86,7 @@ export const actionAlignBottom = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignBottomIcon} icon={<AlignBottomIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignBottom")}${getShortcutKey( title={`${t("labels.alignBottom")}${getShortcutKey(
"CtrlOrCmd+Shift+Down", "CtrlOrCmd+Shift+Down",
@@ -103,7 +99,6 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -120,7 +115,7 @@ export const actionAlignLeft = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignLeftIcon} icon={<AlignLeftIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignLeft")}${getShortcutKey( title={`${t("labels.alignLeft")}${getShortcutKey(
"CtrlOrCmd+Shift+Left", "CtrlOrCmd+Shift+Left",
@@ -133,8 +128,6 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -151,7 +144,7 @@ export const actionAlignRight = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignRightIcon} icon={<AlignRightIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignRight")}${getShortcutKey( title={`${t("labels.alignRight")}${getShortcutKey(
"CtrlOrCmd+Shift+Right", "CtrlOrCmd+Shift+Right",
@@ -164,8 +157,6 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -180,7 +171,7 @@ export const actionAlignVerticallyCentered = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={<CenterVerticallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerVertically")} title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")} aria-label={t("labels.centerVertically")}
@@ -191,7 +182,6 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -206,7 +196,7 @@ export const actionAlignHorizontallyCentered = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={<CenterHorizontallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerHorizontally")} title={t("labels.centerHorizontally")}
aria-label={t("labels.centerHorizontally")} aria-label={t("labels.centerHorizontally")}

View File

@@ -1,148 +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 {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
} from "../element/textWysiwyg";
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" },
predicate: (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),
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
resetOriginalContainerCache(element.id);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
height: originalContainerHeight
? originalContainerHeight
: element.height,
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
predicate: (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,
};
},
});

View File

@@ -1,39 +1,32 @@
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { eraser, ZoomInIcon, ZoomOutIcon } from "../components/icons"; import { trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene"; import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState";
import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
!appState.viewModeEnabled
);
},
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor, commitToHistory: !!value.viewBackgroundColor,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ appState, updateData }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<ColorPicker <ColorPicker
@@ -46,8 +39,6 @@ export const actionChangeViewBackgroundColor = register({
updateData({ openPopup: active ? "canvasColorPicker" : null }) updateData({ openPopup: active ? "canvasColorPicker" : null })
} }
data-testid="canvas-background-picker" data-testid="canvas-background-picker"
elements={elements}
appState={appState}
/> />
</div> </div>
); );
@@ -56,56 +47,54 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
trackEvent: { category: "canvas" }, perform: (elements, appState: AppState) => {
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled
);
},
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return { return {
elements: elements.map((element) => elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }), newElementWith(element, { isDeleted: true }),
), ),
appState: { appState: {
...getDefaultAppState(), ...getDefaultAppState(),
files: {},
theme: appState.theme, theme: appState.theme,
penMode: appState.penMode, elementLocked: appState.elementLocked,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
showStats: appState.showStats, showStats: appState.showStats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
}, },
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={() => {
if (window.confirm(t("alerts.clearReset"))) {
updateData(null);
}
}}
data-testid="clear-canvas-button"
/>
),
}); });
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
viewMode: true, perform: (_elements, appState) => {
trackEvent: { category: "canvas" }, const zoom = getNewZoom(
perform: (_elements, appState, _, app) => { getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
return { return {
appState: { appState: {
...appState, ...appState,
...getStateForZoom( zoom,
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -113,13 +102,13 @@ export const actionZoomIn = register({
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
className="zoom-in-button zoom-button" icon={zoomIn}
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`} title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")} aria-label={t("buttons.zoomIn")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@@ -129,20 +118,18 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
viewMode: true, perform: (_elements, appState) => {
trackEvent: { category: "canvas" }, const zoom = getNewZoom(
perform: (_elements, appState, _, app) => { getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
return { return {
appState: { appState: {
...appState, ...appState,
...getStateForZoom( zoom,
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -150,13 +137,13 @@ export const actionZoomOut = register({
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
className="zoom-out-button zoom-button" icon={zoomOut}
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`} title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")} aria-label={t("buttons.zoomOut")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@@ -166,34 +153,34 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
viewMode: true, perform: (_elements, appState) => {
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
...appState, ...appState,
...getStateForZoom( zoom: getNewZoom(
1 as NormalizedZoomValue,
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ {
viewportX: appState.width / 2 + appState.offsetLeft, x: appState.width / 2,
viewportY: appState.height / 2 + appState.offsetTop, y: appState.height / 2,
nextZoom: getNormalizedZoom(1),
}, },
appState,
), ),
}, },
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> <Tooltip label={t("buttons.resetZoom")}>
<ToolButton <ToolButton
type="button" type="button"
className="reset-zoom-button zoom-button" className="reset-zoom-button"
title={t("buttons.resetZoom")} title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")} aria-label={t("buttons.resetZoom")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
> >
{(appState.zoom.value * 100).toFixed(0)}% {(appState.zoom.value * 100).toFixed(0)}%
</ToolButton> </ToolButton>
@@ -217,7 +204,7 @@ const zoomValueToFitBoundsOnViewport = (
const zoomAdjustedToSteps = const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min( const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM), Math.max(zoomAdjustedToSteps, ZOOM_STEP),
1, 1,
); );
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
@@ -236,12 +223,14 @@ const zoomToFitElements = (
? getCommonBounds(selectedElements) ? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements); : getCommonBounds(nonDeletedElements);
const newZoom = { const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
value: zoomValueToFitBoundsOnViewport(commonBounds, { width: appState.width,
width: appState.width, height: appState.height,
height: appState.height, });
}), const newZoom = getNewZoom(zoomValue, appState.zoom, {
}; left: appState.offsetLeft,
top: appState.offsetTop,
});
const [x1, y1, x2, y2] = commonBounds; const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
@@ -265,7 +254,6 @@ const zoomToFitElements = (
export const actionZoomToSelected = register({ export const actionZoomToSelected = register({
name: "zoomToSelection", name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true), perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.TWO && event.code === CODES.TWO &&
@@ -276,8 +264,6 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
@@ -288,8 +274,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { appState: {
@@ -300,54 +284,15 @@ export const actionToggleTheme = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, PanelComponent: ({ appState, updateData }) => (
predicate: (elements, appState, props, app) => { <div style={{ marginInlineStart: "0.25rem" }}>
return !!app.props.UIOptions.canvasActions.toggleTheme; <DarkModeToggle
}, value={appState.theme}
}); onChange={(theme) => {
updateData(theme);
export const actionErase = register({ }}
name: "eraser", />
trackEvent: { category: "toolbar" }, </div>
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "eraser",
lastActiveToolBeforeEraser: appState.activeTool,
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
},
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
type="button"
icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })}
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
}}
size={data?.size || "medium"}
></ToolButton>
), ),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
}); });

View File

@@ -1,71 +1,38 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { import { copyToClipboard } from "../clipboard";
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index"; import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, perform: (elements, appState) => {
perform: (elements, appState, _, app) => { copyToClipboard(getNonDeletedElements(elements), appState);
const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
}); });
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState, data, app);
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
}, },
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
}); });
export const actionCopyAsSvg = register({ export const actionCopyAsSvg = register({
name: "copyAsSvg", name: "copyAsSvg",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
@@ -75,7 +42,6 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -84,13 +50,12 @@ export const actionCopyAsSvg = register({
? selectedElements ? selectedElements
: getNonDeletedElements(elements), : getNonDeletedElements(elements),
appState, appState,
app.files,
appState, appState,
); );
return { return {
commitToHistory: false, commitToHistory: false,
}; };
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
@@ -101,15 +66,11 @@ export const actionCopyAsSvg = register({
}; };
} }
}, },
predicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg", contextItemLabel: "labels.copyAsSvg",
}); });
export const actionCopyAsPng = register({ export const actionCopyAsPng = register({
name: "copyAsPng", name: "copyAsPng",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
@@ -119,7 +80,6 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -128,26 +88,23 @@ export const actionCopyAsPng = register({
? selectedElements ? selectedElements
: getNonDeletedElements(elements), : getNonDeletedElements(elements),
appState, appState,
app.files,
appState, appState,
); );
return { return {
appState: { appState: {
...appState, ...appState,
toast: { toastMessage: t("toast.copyToClipboardAsPng", {
message: t("toast.copyToClipboardAsPng", { exportSelection: selectedElements.length
exportSelection: selectedElements.length ? t("toast.selection")
? t("toast.selection") : t("toast.canvas"),
: t("toast.canvas"), exportColorScheme: appState.exportWithDarkMode
exportColorScheme: appState.exportWithDarkMode ? t("buttons.darkMode")
? t("buttons.darkMode") : t("buttons.lightMode"),
: t("buttons.lightMode"), }),
}),
},
}, },
commitToHistory: false, commitToHistory: false,
}; };
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
@@ -158,41 +115,6 @@ export const actionCopyAsPng = register({
}; };
} }
}, },
predicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng", contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
}); });
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
};
},
predicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
});

View File

@@ -1,6 +1,7 @@
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { trash } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
@@ -10,9 +11,6 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -23,12 +21,6 @@ const deleteSelectedElements = (
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
) {
return newElementWith(el, { isDeleted: true });
}
return el; return el;
}), }),
appState: { appState: {
@@ -59,12 +51,11 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({ export const actionDeleteSelected = register({
name: "deleteSelectedElements", name: "deleteSelectedElements",
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
elementId, elementId,
selectedPointsIndices, activePointIndex,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
} = appState.editingLinearElement; } = appState.editingLinearElement;
@@ -72,22 +63,14 @@ export const actionDeleteSelected = register({
if (!element) { if (!element) {
return false; return false;
} }
// case: no point selected → do nothing, as deleting the whole element if (
// is most likely a mistake, where you wanted to delete a specific point // case: no point selected delete whole element
// but failed to select it (or you thought it's selected, while it was activePointIndex == null ||
// only in a hover state) activePointIndex === -1 ||
if (selectedPointsIndices == null) { // case: deleting last remaining point
return false; element.points.length < 2
} ) {
const nextElements = elements.filter((el) => el.id !== element.id);
// case: deleting last remaining point
if (element.points.length < 2) {
const nextElements = elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
const nextAppState = handleGroupEditingState(appState, nextElements); const nextAppState = handleGroupEditingState(appState, nextElements);
return { return {
@@ -103,17 +86,15 @@ export const actionDeleteSelected = register({
// We cannot do this inside `movePoint` because it is also called // We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding) // when deleting the uncommitted point (which hasn't caused any binding)
const binding = { const binding = {
startBindingElement: selectedPointsIndices?.includes(0) startBindingElement:
? null activePointIndex === 0 ? null : startBindingElement,
: startBindingElement, endBindingElement:
endBindingElement: selectedPointsIndices?.includes( activePointIndex === element.points.length - 1
element.points.length - 1, ? null
) : endBindingElement,
? null
: endBindingElement,
}; };
LinearElementEditor.deletePoints(element, selectedPointsIndices); LinearElementEditor.movePoint(element, activePointIndex, "delete");
return { return {
elements, elements,
@@ -122,17 +103,17 @@ export const actionDeleteSelected = register({
editingLinearElement: { editingLinearElement: {
...appState.editingLinearElement, ...appState.editingLinearElement,
...binding, ...binding,
selectedPointsIndices: activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]
: [0],
}, },
}, },
commitToHistory: true, commitToHistory: true,
}; };
} }
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState); let {
elements: nextElements,
appState: nextAppState,
} = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),
@@ -144,7 +125,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }), elementType: "selection",
multiElement: null, multiElement: null,
}, },
commitToHistory: isSomeElementSelected( commitToHistory: isSomeElementSelected(
@@ -158,7 +139,7 @@ export const actionDeleteSelected = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={TrashIcon} icon={trash}
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}

View File

@@ -3,14 +3,14 @@ import {
DistributeVerticallyIcon, DistributeVerticallyIcon,
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../disitrubte";
import { getNonDeletedElements } from "../element"; import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
@@ -30,16 +30,13 @@ const distributeSelectedElements = (
const updatedElements = distributeElements(selectedElements, distribution); const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
export const distributeHorizontally = register({ export const distributeHorizontally = register({
name: "distributeHorizontally", name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -50,13 +47,12 @@ export const distributeHorizontally = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.altKey && event.code === CODES.H,
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={DistributeHorizontallyIcon} icon={<DistributeHorizontallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeHorizontally")}${getShortcutKey( title={`${t("labels.distributeHorizontally")}${getShortcutKey(
"Alt+H", "Alt+H",
@@ -69,7 +65,6 @@ export const distributeHorizontally = register({
export const distributeVertically = register({ export const distributeVertically = register({
name: "distributeVertically", name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -80,13 +75,12 @@ export const distributeVertically = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.altKey && event.code === CODES.V,
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={DistributeVerticallyIcon} icon={<DistributeVerticallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`} title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`}
aria-label={t("labels.distributeVertically")} aria-label={t("labels.distributeVertically")}

View File

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

View File

@@ -1,12 +1,14 @@
import { questionCircle, saveAs } from "../components/icons"; import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useIsMobile } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
@@ -14,16 +16,15 @@ import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants"; import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types"; import { Theme } from "../element/types";
import "../components/ToolIcon.scss";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false }; return { appState: { ...appState, name: value }, commitToHistory: false };
}, },
PanelComponent: ({ appState, updateData, appProps }) => ( PanelComponent: ({ appState, updateData, appProps }) => (
@@ -40,7 +41,6 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({ export const actionChangeExportScale = register({
name: "changeExportScale", name: "changeExportScale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportScale: value }, appState: { ...appState, exportScale: value },
@@ -89,7 +89,6 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({ export const actionChangeExportBackground = register({
name: "changeExportBackground", name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportBackground: value }, appState: { ...appState, exportBackground: value },
@@ -108,7 +107,6 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({ export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene", name: "changeExportEmbedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportEmbedScene: value }, appState: { ...appState, exportEmbedScene: value },
@@ -130,72 +128,58 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
trackEvent: { category: "export" }, perform: async (elements, appState, value) => {
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
!!appState.fileHandle &&
!appState.viewModeEnabled
);
},
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle; const fileHandleExists = !!appState.fileHandle;
try { try {
const { fileHandle } = isImageFileHandle(appState.fileHandle) const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState, app.files) ? await resaveAsImageWithScene(elements, appState)
: await saveAsJSON(elements, appState, app.files); : await saveAsJSON(elements, appState);
return { return {
commitToHistory: false, commitToHistory: false,
appState: { appState: {
...appState, ...appState,
fileHandle, fileHandle,
toast: fileHandleExists toastMessage: fileHandleExists
? { ? fileHandle?.name
message: fileHandle?.name ? t("toast.fileSavedToFilename").replace(
? t("toast.fileSavedToFilename").replace( "{filename}",
"{filename}", `"${fileHandle.name}"`,
`"${fileHandle.name}"`, )
) : t("toast.fileSaved")
: t("toast.fileSaved"),
}
: null, : null,
}, },
}; };
} catch (error: any) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
}, },
keyTest: (event) => keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey, event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => (
<ActiveFile
onSave={() => updateData(null)}
fileName={appState.fileHandle?.name}
/>
),
}); });
export const actionSaveFileToDisk = register({ export const actionSaveFileToDisk = register({
name: "saveFileToDisk", name: "saveFileToDisk",
viewMode: true, perform: async (elements, appState, value) => {
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
try { try {
const { fileHandle } = await saveAsJSON( const { fileHandle } = await saveAsJSON(elements, {
elements, ...appState,
{ fileHandle: null,
...appState, });
fileHandle: null,
},
app.files,
);
return { commitToHistory: false, appState: { ...appState, fileHandle } }; return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error: any) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -208,7 +192,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs} icon={saveAs}
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
@@ -218,44 +202,44 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({ export const actionLoadScene = register({
name: "loadScene", name: "loadScene",
trackEvent: { category: "export" }, perform: async (elements, appState) => {
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
);
},
perform: async (elements, appState, _, app) => {
try { try {
const { const {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements); } = await loadFromJSON(appState, elements);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files,
commitToHistory: true, commitToHistory: true,
}; };
} catch (error: any) { } catch (error) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
console.warn(error);
return false; return false;
} }
return { return {
elements, elements,
appState: { ...appState, errorMessage: error.message }, appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false, commitToHistory: false,
}; };
} }
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
icon={load}
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
onClick={updateData}
data-testid="load-button"
/>
),
}); });
export const actionExportWithDarkMode = register({ export const actionExportWithDarkMode = register({
name: "exportWithDarkMode", name: "exportWithDarkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },

View File

@@ -1,6 +1,6 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element"; import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils"; import { resetCursor } from "../utils";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons"; import { done } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -13,16 +13,17 @@ import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "../element/binding"; } from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { isBindingElement } from "../element/typeChecks";
import { AppState } from "../types";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
trackEvent: false, perform: (elements, appState, _, { canvas, focusContainer }) => {
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const {
appState.editingLinearElement; elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (element) { if (element) {
@@ -40,7 +41,6 @@ export const actionFinalize = register({
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
}, },
commitToHistory: true, commitToHistory: true,
@@ -49,15 +49,6 @@ export const actionFinalize = register({
} }
let newElements = elements; let newElements = elements;
const pendingImageElement =
appState.pendingImageElementId &&
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
@@ -126,47 +117,27 @@ export const actionFinalize = register({
); );
} }
if ( if (!appState.elementLocked && appState.elementType !== "freedraw") {
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
} }
if ( if (
(!appState.activeTool.locked && (!appState.elementLocked && appState.elementType !== "freedraw") ||
appState.activeTool.type !== "freedraw") ||
!multiPointElement !multiPointElement
) { ) {
resetCursor(canvas); resetCursor(canvas);
} }
let activeTool: AppState["activeTool"];
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
...appState, ...appState,
cursorButton: "up", elementType:
activeTool: (appState.elementLocked || appState.elementType === "freedraw") &&
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
multiPointElement multiPointElement
? appState.activeTool ? appState.elementType
: activeTool, : "selection",
draggingElement: null, draggingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
@@ -174,21 +145,15 @@ export const actionFinalize = register({
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && multiPointElement &&
!appState.activeTool.locked && !appState.elementLocked &&
appState.activeTool.type !== "freedraw" appState.elementType !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement, scene)
: appState.selectedLinearElement,
pendingImageElementId: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.elementType === "freedraw",
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
@@ -197,7 +162,7 @@ export const actionFinalize = register({
(!appState.draggingElement && appState.multiElement === null))) || (!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={done} icon={done}
@@ -205,7 +170,6 @@ export const actionFinalize = register({
aria-label={t("buttons.done")} aria-label={t("buttons.done")}
onClick={updateData} onClick={updateData}
visible={appState.multiElement != null} visible={appState.multiElement != null}
size={data?.size || "medium"}
/> />
), ),
}); });

View File

@@ -1,20 +1,14 @@
import { register } from "./register"; import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getElementMap, getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types"; import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles"; import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding"; import { updateBoundElements } from "../element/binding";
import { arrayToMap } from "../utils";
import {
getElementAbsoluteCoords,
getElementPointsCoords,
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
const enableActionFlipHorizontal = ( const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -40,7 +34,6 @@ const enableActionFlipVertical = (
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "horizontal"), elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -50,13 +43,12 @@ export const actionFlipHorizontal = register({
}, },
keyTest: (event) => event.shiftKey && event.code === "KeyH", keyTest: (event) => event.shiftKey && event.code === "KeyH",
contextItemLabel: "labels.flipHorizontal", contextItemLabel: "labels.flipHorizontal",
predicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState), enableActionFlipHorizontal(elements, appState),
}); });
export const actionFlipVertical = register({ export const actionFlipVertical = register({
name: "flipVertical", name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "vertical"), elements: flipSelectedElements(elements, appState, "vertical"),
@@ -64,10 +56,9 @@ export const actionFlipVertical = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.shiftKey && event.code === "KeyV",
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical", contextItemLabel: "labels.flipVertical",
predicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState), enableActionFlipVertical(elements, appState),
}); });
@@ -92,11 +83,9 @@ const flipSelectedElements = (
flipDirection, flipDirection,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = getElementMap(updatedElements);
return elements.map( return elements.map((element) => updatedElementsMap[element.id] || element);
(element) => updatedElementsMap.get(element.id) || element,
);
}; };
const flipElements = ( const flipElements = (
@@ -104,13 +93,13 @@ const flipElements = (
appState: AppState, appState: AppState,
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
elements.forEach((element) => { for (let i = 0; i < elements.length; i++) {
flipElement(element, appState); flipElement(elements[i], appState);
// If vertical flip, rotate an extra 180 // If vertical flip, rotate an extra 180
if (flipDirection === "vertical") { if (flipDirection === "vertical") {
rotateElement(element, Math.PI); rotateElement(elements[i], Math.PI);
} }
}); }
return elements; return elements;
}; };
@@ -124,6 +113,13 @@ const flipElement = (
const height = element.height; const height = element.height;
const originalAngle = normalizeAngle(element.angle); const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
}
// Rotate back to zero, if necessary // Rotate back to zero, if necessary
mutateElement(element, { mutateElement(element, {
angle: normalizeAngle(0), angle: normalizeAngle(0),
@@ -131,6 +127,7 @@ const flipElement = (
// Flip unrotated by pulling TransformHandle to opposite side // Flip unrotated by pulling TransformHandle to opposite side
const transformHandles = getTransformHandles(element, appState.zoom); const transformHandles = getTransformHandles(element, appState.zoom);
let usingNWHandle = true; let usingNWHandle = true;
let newNCoordsX = 0;
let nHandle = transformHandles.nw; let nHandle = transformHandles.nw;
if (!nHandle) { if (!nHandle) {
// Use ne handle instead // Use ne handle instead
@@ -144,47 +141,31 @@ const flipElement = (
} }
} }
let finalOffsetX = 0;
if (isLinearElement(element) && element.points.length < 3) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
}
let initialPointsCoords;
if (isLinearElement(element)) { if (isLinearElement(element)) {
initialPointsCoords = getElementPointsCoords(element, element.points); for (let i = 1; i < element.points.length; i++) {
} LinearElementEditor.movePoint(element, i, [
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element); -element.points[i][0],
element.points[i][1],
if (isLinearElement(element) && element.points.length < 3) {
for (let index = 1; index < element.points.length; index++) {
LinearElementEditor.movePoints(element, [
{
index,
point: [-element.points[index][0], element.points[index][1]],
},
]); ]);
} }
LinearElementEditor.normalizePoints(element); LinearElementEditor.normalizePoints(element);
} else { } else {
const elWidth = initialPointsCoords // calculate new x-coord for transformation
? initialPointsCoords[2] - initialPointsCoords[0] newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
const startPoint = initialPointsCoords
? [initialPointsCoords[0], initialPointsCoords[1]]
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
resizeSingleElement( resizeSingleElement(
new Map().set(element.id, element), element,
false, true,
element, element,
usingNWHandle ? "nw" : "ne", usingNWHandle ? "nw" : "ne",
true, false,
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth, newNCoordsX,
startPoint[1], nHandle[1],
); );
// fix the size to account for handle sizes
mutateElement(element, {
width,
height,
});
} }
// Rotate by (360 degrees - original angle) // Rotate by (360 degrees - original angle)
@@ -201,30 +182,9 @@ const flipElement = (
mutateElement(element, { mutateElement(element, {
x: originalX + finalOffsetX, x: originalX + finalOffsetX,
y: originalY, y: originalY,
width,
height,
}); });
updateBoundElements(element); updateBoundElements(element);
if (initialPointsCoords && isLinearElement(element)) {
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
// There's still room for improvement since when the line roughness is > 1
// we still have a small offset of the origin when fliipping the element.
const finalPointsCoords = getElementPointsCoords(element, element.points);
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
mutateElement(element, {
x: element.x + coordsDiff * 0.5,
y: element.y,
width,
height,
});
}
}; };
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => { const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {

View File

@@ -1,6 +1,6 @@
import { KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons"; import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
@@ -17,9 +17,8 @@ import {
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { randomId } from "../random"; import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@@ -45,7 +44,6 @@ const enableActionGroup = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -54,12 +52,10 @@ const enableActionGroup = (
export const actionGroup = register({ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true,
); );
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
@@ -87,9 +83,8 @@ export const actionGroup = register({
} }
} }
const newGroupId = randomId(); const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => { const updatedElements = elements.map((element) => {
if (!selectElementIds.get(element.id)) { if (!appState.selectedElementIds[element.id]) {
return element; return element;
} }
return newElementWith(element, { return newElementWith(element, {
@@ -104,8 +99,9 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack // to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId); const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = const lastGroupElementIndex = updatedElements.lastIndexOf(
updatedElements.lastIndexOf(lastElementInGroup); lastElementInGroup,
);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1); const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex) .slice(0, lastGroupElementIndex)
@@ -129,9 +125,10 @@ export const actionGroup = register({
}; };
}, },
contextItemLabel: "labels.group", contextItemLabel: "labels.group",
predicate: (elements, appState) => enableActionGroup(elements, appState), contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
keyTest: (event) => keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
@@ -147,18 +144,12 @@ export const actionGroup = register({
export const actionUngroup = register({ export const actionUngroup = register({
name: "ungroup", name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => { const nextElements = elements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
const nextGroupIds = removeFromSelectedGroups( const nextGroupIds = removeFromSelectedGroups(
element.groupIds, element.groupIds,
appState.selectedGroupIds, appState.selectedGroupIds,
@@ -170,29 +161,20 @@ export const actionUngroup = register({
groupIds: nextGroupIds, groupIds: nextGroupIds,
}); });
}); });
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
);
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return { return {
appState: updateAppState, appState: selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
elements: nextElements, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
event.shiftKey && event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(),
contextItemLabel: "labels.ungroup", contextItemLabel: "labels.ungroup",
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton

View File

@@ -1,14 +1,14 @@
import { Action, ActionResult } from "./types"; import { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons"; import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import History, { HistoryEntry } from "../history"; import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isWindows, KEYS } from "../keys"; import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], prevElements: readonly ExcalidrawElement[],
@@ -27,17 +27,17 @@ const writeData = (
return { commitToHistory }; return { commitToHistory };
} }
const prevElementMap = arrayToMap(prevElements); const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements; const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements); const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter( const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id), (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
); );
const elements = nextElements const elements = nextElements
.map((nextElement) => .map((nextElement) =>
newElementWith( newElementWith(
prevElementMap.get(nextElement.id) || nextElement, prevElementMap[nextElement.id] || nextElement,
nextElement, nextElement,
), ),
) )
@@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()), writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) => keyTest: (event) =>
@@ -72,7 +71,7 @@ export const createUndoAction: ActionCreator = (history) => ({
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={UndoIcon} icon={undo}
aria-label={t("buttons.undo")} aria-label={t("buttons.undo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history) => ({ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()), writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) => keyTest: (event) =>
@@ -94,7 +92,7 @@ export const createRedoAction: ActionCreator = (history) => ({
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={RedoIcon} icon={redo}
aria-label={t("buttons.redo")} aria-label={t("buttons.redo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}

View File

@@ -1,49 +0,0 @@
import { getNonDeletedElements } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
trackEvent: {
category: "element",
},
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
)[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement, app.scene);
return {
appState: {
...appState,
editingLinearElement,
},
commitToHistory: false,
};
},
contextItemLabel: (elements, appState) => {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
)[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";
},
});

View File

@@ -1,14 +1,14 @@
import { HamburgerMenuIcon, palette } from "../components/icons"; import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({ perform: (_, appState) => ({
appState: { appState: {
...appState, ...appState,
@@ -19,7 +19,7 @@ export const actionToggleCanvasMenu = register({
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={HamburgerMenuIcon} icon={menu}
aria-label={t("buttons.menu")} aria-label={t("buttons.menu")}
onClick={updateData} onClick={updateData}
selected={appState.openMenu === "canvas"} selected={appState.openMenu === "canvas"}
@@ -29,7 +29,6 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({ export const actionToggleEditMenu = register({
name: "toggleEditMenu", name: "toggleEditMenu",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({ perform: (_elements, appState) => ({
appState: { appState: {
...appState, ...appState,
@@ -54,8 +53,6 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({ export const actionFullScreen = register({
name: "toggleFullScreen", name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => { perform: () => {
if (!isFullScreen()) { if (!isFullScreen()) {
allowFullScreen(); allowFullScreen();
@@ -67,24 +64,25 @@ export const actionFullScreen = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD], keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
}); });
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") { if (appState.showHelpDialog) {
focusContainer(); focusContainer();
} }
return { return {
appState: { appState: {
...appState, ...appState,
openDialog: appState.openDialog === "help" ? null : "help", showHelpDialog: !appState.showHelpDialog,
}, },
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData }) => (
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
),
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
}); });

View File

@@ -1,4 +1,4 @@
import { getClientColors } from "../clients"; import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
@@ -6,8 +6,6 @@ import { register } from "./register";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"]; const point = value as Collaborator["pointer"];
if (!point) { if (!point) {
@@ -32,18 +30,28 @@ export const actionGoToCollaborator = register({
}; };
}, },
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator]; const clientId: string | undefined = data?.id;
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const { background, stroke } = getClientColors(clientId, appState); const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return ( return (
<Avatar <Avatar
color={background} color={background}
border={stroke} border={stroke}
onClick={() => updateData(collaborator.pointer)} onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""} >
src={collaborator.avatarUrl} {shortName}
/> </Avatar>
); );
}, },
}); });

View File

@@ -2,64 +2,46 @@ import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
import { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadDotIcon,
ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
StrokeStyleDashedIcon, EdgeRoundIcon,
StrokeStyleDottedIcon, EdgeSharpIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
FillHachureIcon,
FillCrossHatchIcon, FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon, FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon, SloppinessArchitectIcon,
SloppinessArtistIcon, SloppinessArtistIcon,
SloppinessCartoonistIcon, SloppinessCartoonistIcon,
StrokeWidthBaseIcon, StrokeStyleDashedIcon,
StrokeWidthBoldIcon, StrokeStyleDottedIcon,
StrokeWidthExtraBoldIcon, StrokeStyleSolidIcon,
FontSizeSmallIcon, StrokeWidthIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon, TextAlignRightIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS,
VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
getNonDeletedElements, getNonDeletedElements,
isTextElement, isTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { import { isLinearElement, isLinearElementType } from "../element/typeChecks";
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
isBoundToContainer,
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import { import {
Arrowhead, Arrowhead,
ExcalidrawElement, ExcalidrawElement,
@@ -67,37 +49,26 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeRoundness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements,
getTargetElements, getTargetElements,
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
callback: (element: ExcalidrawElement) => ExcalidrawElement, callback: (element: ExcalidrawElement) => ExcalidrawElement,
includeBoundText = false,
) => { ) => {
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText),
);
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || appState.selectedElementIds[element.id] ||
element.id === appState.editingElement?.id element.id === appState.editingElement?.id
) { ) {
return callback(element); return callback(element);
@@ -127,93 +98,15 @@ const getFormValue = function <T>(
); );
}; };
const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
) => {
if (isBoundToContainer(nextElement)) {
return nextElement;
}
return mutateElement(
nextElement,
{
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
};
const changeFontSize = (
elements: readonly ExcalidrawElement[],
appState: AppState,
getNewFontSize: (element: ExcalidrawTextElement) => number,
fallbackValue?: ExcalidrawTextElement["fontSize"],
) => {
const newFontSizes = new Set<number>();
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
// update state only if we've set all select text elements to
// the same font size
currentItemFontSize:
newFontSizes.size === 1
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
};
};
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({ export const actionChangeStrokeColor = register({
name: "changeStrokeColor", name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
elements: changeProperty( elements: changeProperty(elements, appState, (el) =>
elements, newElementWith(el, {
appState, strokeColor: value.currentItemStrokeColor,
(el) => { }),
return hasStrokeColor(el.type)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
},
true,
), ),
}), }),
appState: { appState: {
@@ -240,8 +133,6 @@ export const actionChangeStrokeColor = register({
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null }) updateData({ openPopup: active ? "strokeColorPicker" : null })
} }
elements={elements}
appState={appState}
/> />
</> </>
), ),
@@ -249,7 +140,6 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor", name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemBackgroundColor && { ...(value.currentItemBackgroundColor && {
@@ -283,8 +173,6 @@ export const actionChangeBackgroundColor = register({
setActive={(active) => setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null }) updateData({ openPopup: active ? "backgroundColorPicker" : null })
} }
elements={elements}
appState={appState}
/> />
</> </>
), ),
@@ -292,7 +180,6 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -312,17 +199,17 @@ export const actionChangeFillStyle = register({
{ {
value: "hachure", value: "hachure",
text: t("labels.hachure"), text: t("labels.hachure"),
icon: FillHachureIcon, icon: <FillHachureIcon theme={appState.theme} />,
}, },
{ {
value: "cross-hatch", value: "cross-hatch",
text: t("labels.crossHatch"), text: t("labels.crossHatch"),
icon: FillCrossHatchIcon, icon: <FillCrossHatchIcon theme={appState.theme} />,
}, },
{ {
value: "solid", value: "solid",
text: t("labels.solid"), text: t("labels.solid"),
icon: FillSolidIcon, icon: <FillSolidIcon theme={appState.theme} />,
}, },
]} ]}
group="fill" group="fill"
@@ -342,7 +229,6 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth", name: "changeStrokeWidth",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -363,17 +249,17 @@ export const actionChangeStrokeWidth = register({
{ {
value: 1, value: 1,
text: t("labels.thin"), text: t("labels.thin"),
icon: StrokeWidthBaseIcon, icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
}, },
{ {
value: 2, value: 2,
text: t("labels.bold"), text: t("labels.bold"),
icon: StrokeWidthBoldIcon, icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
}, },
{ {
value: 4, value: 4,
text: t("labels.extraBold"), text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon, icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@@ -390,7 +276,6 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({ export const actionChangeSloppiness = register({
name: "changeSloppiness", name: "changeSloppiness",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -412,17 +297,17 @@ export const actionChangeSloppiness = register({
{ {
value: 0, value: 0,
text: t("labels.architect"), text: t("labels.architect"),
icon: SloppinessArchitectIcon, icon: <SloppinessArchitectIcon theme={appState.theme} />,
}, },
{ {
value: 1, value: 1,
text: t("labels.artist"), text: t("labels.artist"),
icon: SloppinessArtistIcon, icon: <SloppinessArtistIcon theme={appState.theme} />,
}, },
{ {
value: 2, value: 2,
text: t("labels.cartoonist"), text: t("labels.cartoonist"),
icon: SloppinessCartoonistIcon, icon: <SloppinessCartoonistIcon theme={appState.theme} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@@ -439,7 +324,6 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({ export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle", name: "changeStrokeStyle",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -460,17 +344,17 @@ export const actionChangeStrokeStyle = register({
{ {
value: "solid", value: "solid",
text: t("labels.strokeStyle_solid"), text: t("labels.strokeStyle_solid"),
icon: StrokeWidthBaseIcon, icon: <StrokeStyleSolidIcon theme={appState.theme} />,
}, },
{ {
value: "dashed", value: "dashed",
text: t("labels.strokeStyle_dashed"), text: t("labels.strokeStyle_dashed"),
icon: StrokeStyleDashedIcon, icon: <StrokeStyleDashedIcon theme={appState.theme} />,
}, },
{ {
value: "dotted", value: "dotted",
text: t("labels.strokeStyle_dotted"), text: t("labels.strokeStyle_dotted"),
icon: StrokeStyleDottedIcon, icon: <StrokeStyleDottedIcon theme={appState.theme} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@@ -487,17 +371,12 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) =>
elements, newElementWith(el, {
appState, opacity: value,
(el) => }),
newElementWith(el, {
opacity: value,
}),
true,
), ),
appState: { ...appState, currentItemOpacity: value }, appState: { ...appState, currentItemOpacity: value },
commitToHistory: true, commitToHistory: true,
@@ -512,6 +391,20 @@ export const actionChangeOpacity = register({
max="100" max="100"
step="10" step="10"
onChange={(event) => updateData(+event.target.value)} onChange={(event) => updateData(+event.target.value)}
onWheel={(event) => {
event.stopPropagation();
const target = event.target as HTMLInputElement;
const STEP = 10;
const MAX = 100;
const MIN = 0;
const value = +target.value;
if (event.deltaY < 0 && value < MAX) {
updateData(value + STEP);
} else if (event.deltaY > 0 && value > MIN) {
updateData(value - STEP);
}
}}
value={ value={
getFormValue( getFormValue(
elements, elements,
@@ -527,9 +420,25 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value, value); return {
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontSize: value,
});
redrawTextBoundingBox(element);
return element;
}
return el;
}),
appState: {
...appState,
currentItemFontSize: value,
},
commitToHistory: true,
};
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
@@ -540,41 +449,28 @@ export const actionChangeFontSize = register({
{ {
value: 16, value: 16,
text: t("labels.small"), text: t("labels.small"),
icon: FontSizeSmallIcon, icon: <FontSizeSmallIcon theme={appState.theme} />,
testId: "fontSize-small",
}, },
{ {
value: 20, value: 20,
text: t("labels.medium"), text: t("labels.medium"),
icon: FontSizeMediumIcon, icon: <FontSizeMediumIcon theme={appState.theme} />,
testId: "fontSize-medium",
}, },
{ {
value: 28, value: 28,
text: t("labels.large"), text: t("labels.large"),
icon: FontSizeLargeIcon, icon: <FontSizeLargeIcon theme={appState.theme} />,
testId: "fontSize-large",
}, },
{ {
value: 36, value: 36,
text: t("labels.veryLarge"), text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon, icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
testId: "fontSize-veryLarge",
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.fontSize,
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
appState.currentItemFontSize || DEFAULT_FONT_SIZE, appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -583,70 +479,21 @@ export const actionChangeFontSize = register({
), ),
}); });
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
// get previous value before relative increase (doesn't work fully
// due to rounding and float precision issues)
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.COMMA needed for MacOS
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
);
},
});
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.PERIOD needed for MacOS
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
);
},
});
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, if (isTextElement(el)) {
appState, const element: ExcalidrawTextElement = newElementWith(el, {
(oldElement) => { fontFamily: value,
if (isTextElement(oldElement)) { });
const newElement: ExcalidrawTextElement = newElementWith( redrawTextBoundingBox(element);
oldElement, return element;
{ }
fontFamily: value,
},
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemFontFamily: value, currentItemFontFamily: value,
@@ -663,17 +510,17 @@ export const actionChangeFontFamily = register({
{ {
value: FONT_FAMILY.Virgil, value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
icon: FreedrawIcon, icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
}, },
{ {
value: FONT_FAMILY.Helvetica, value: FONT_FAMILY.Helvetica,
text: t("labels.normal"), text: t("labels.normal"),
icon: FontFamilyNormalIcon, icon: <FontFamilyNormalIcon theme={appState.theme} />,
}, },
{ {
value: FONT_FAMILY.Cascadia, value: FONT_FAMILY.Cascadia,
text: t("labels.code"), text: t("labels.code"),
icon: FontFamilyCodeIcon, icon: <FontFamilyCodeIcon theme={appState.theme} />,
}, },
]; ];
@@ -686,16 +533,7 @@ export const actionChangeFontFamily = register({
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => { (element) => isTextElement(element) && element.fontFamily,
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
@@ -707,26 +545,19 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(elements, appState, (el) => {
elements, if (isTextElement(el)) {
appState, const element: ExcalidrawTextElement = newElementWith(el, {
(oldElement) => { textAlign: value,
if (isTextElement(oldElement)) { });
const newElement: ExcalidrawTextElement = newElementWith( redrawTextBoundingBox(element);
oldElement, return element;
{ textAlign: value }, }
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement; return el;
}, }),
true,
),
appState: { appState: {
...appState, ...appState,
currentItemTextAlign: value, currentItemTextAlign: value,
@@ -734,191 +565,106 @@ export const actionChangeTextAlign = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => (
return ( <fieldset>
<fieldset> <legend>{t("labels.textAlign")}</legend>
<legend>{t("labels.textAlign")}</legend> <ButtonIconSelect<TextAlign | false>
<ButtonIconSelect<TextAlign | false> group="text-align"
group="text-align" options={[
options={[ {
{ value: "left",
value: "left", text: t("labels.left"),
text: t("labels.left"), icon: <TextAlignLeftIcon theme={appState.theme} />,
icon: TextAlignLeftIcon, },
}, {
{ value: "center",
value: "center", text: t("labels.center"),
text: t("labels.center"), icon: <TextAlignCenterIcon theme={appState.theme} />,
icon: TextAlignCenterIcon, },
}, {
{ value: "right",
value: "right", text: t("labels.right"),
text: t("labels.right"), icon: <TextAlignRightIcon theme={appState.theme} />,
icon: TextAlignRightIcon, },
}, ]}
]} value={getFormValue(
value={getFormValue( elements,
elements, appState,
appState, (element) => isTextElement(element) && element.textAlign,
(element) => { appState.currentItemTextAlign,
if (isTextElement(element)) { )}
return element.textAlign; onChange={(value) => updateData(value)}
} />
const boundTextElement = getBoundTextElement(element); </fieldset>
if (boundTextElement) { ),
return boundTextElement.textAlign;
}
return null;
},
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{ verticalAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<ButtonIconSelect<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
},
]}
value={getFormValue(elements, appState, (element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
})}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
}); });
export const actionChangeRoundness = register({ export const actionChangeSharpness = register({
name: "changeRoundness", name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
type: isUsingAdaptiveRadius(el.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}),
),
appState: {
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
); );
const shouldUpdateForNonLinearElements = targetElements.length
const hasLegacyRoundness = targetElements.some( ? targetElements.every((el) => !isLinearElement(el))
(el) => el.roundness?.type === ROUNDNESS.LEGACY, : !isLinearElementType(appState.elementType);
); const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
return ( : isLinearElementType(appState.elementType);
<fieldset> return {
<legend>{t("labels.edges")}</legend> elements: changeProperty(elements, appState, (el) =>
<ButtonIconSelect newElementWith(el, {
group="edges" strokeSharpness: value,
options={[ }),
{ ),
value: "sharp", appState: {
text: t("labels.sharp"), ...appState,
icon: EdgeSharpIcon, currentItemStrokeSharpness: shouldUpdateForNonLinearElements
}, ? value
{ : appState.currentItemStrokeSharpness,
value: "round", currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
text: t("labels.round"), ? value
icon: EdgeRoundIcon, : appState.currentItemLinearStrokeSharpness,
}, },
]} commitToHistory: true,
value={getFormValue( };
elements,
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
}, },
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: <EdgeSharpIcon theme={appState.theme} />,
},
{
value: "round",
text: t("labels.round"),
icon: <EdgeRoundIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
}); });
export const actionChangeArrowhead = register({ export const actionChangeArrowhead = register({
name: "changeArrowhead", name: "changeArrowhead",
trackEvent: false,
perform: ( perform: (
elements, elements,
appState, appState,
@@ -959,40 +705,36 @@ export const actionChangeArrowhead = register({
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList">
<IconPicker <IconPicker
label="arrowhead_start" label="arrowhead_start"
options={[ options={[
{ {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
icon: ArrowheadNoneIcon, icon: <ArrowheadNoneIcon theme={appState.theme} />,
keyBinding: "q", keyBinding: "q",
}, },
{ {
value: "arrow", value: "arrow",
text: t("labels.arrowhead_arrow"), text: t("labels.arrowhead_arrow"),
icon: <ArrowheadArrowIcon flip={!isRTL} />, icon: (
<ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "w", keyBinding: "w",
}, },
{ {
value: "bar", value: "bar",
text: t("labels.arrowhead_bar"), text: t("labels.arrowhead_bar"),
icon: <ArrowheadBarIcon flip={!isRTL} />, icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "e", keyBinding: "e",
}, },
{ {
value: "dot", value: "dot",
text: t("labels.arrowhead_dot"), text: t("labels.arrowhead_dot"),
icon: <ArrowheadDotIcon flip={!isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r", keyBinding: "r",
}, },
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={!isRTL} />,
keyBinding: "t",
},
]} ]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
@@ -1013,31 +755,27 @@ export const actionChangeArrowhead = register({
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: ArrowheadNoneIcon, icon: <ArrowheadNoneIcon theme={appState.theme} />,
}, },
{ {
value: "arrow", value: "arrow",
text: t("labels.arrowhead_arrow"), text: t("labels.arrowhead_arrow"),
keyBinding: "w", keyBinding: "w",
icon: <ArrowheadArrowIcon flip={isRTL} />, icon: (
<ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
),
}, },
{ {
value: "bar", value: "bar",
text: t("labels.arrowhead_bar"), text: t("labels.arrowhead_bar"),
keyBinding: "e", keyBinding: "e",
icon: <ArrowheadBarIcon flip={isRTL} />, icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
}, },
{ {
value: "dot", value: "dot",
text: t("labels.arrowhead_dot"), text: t("labels.arrowhead_dot"),
keyBinding: "r", keyBinding: "r",
icon: <ArrowheadDotIcon flip={isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={isRTL} />,
keyBinding: "t",
}, },
]} ]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(

View File

@@ -1,44 +1,25 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
trackEvent: { category: "canvas" }, perform: (elements, appState) => {
perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
} }
const selectedElementIds = elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked
) {
map[element.id] = true;
}
return map;
},
{},
);
return { return {
appState: selectGroupsForSelectedElements( appState: selectGroupsForSelectedElements(
{ {
...appState, ...appState,
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene)
: null,
editingGroupId: null, editingGroupId: null,
selectedElementIds, selectedElementIds: elements.reduce((map, element) => {
if (!element.isDeleted) {
map[element.id] = true;
}
return map;
}, {} as any),
}, },
getNonDeletedElements(elements), getNonDeletedElements(elements),
), ),

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)[0];
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
API.setSelectedElements([h.elements[0]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.V);
});
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
});

View File

@@ -6,41 +6,27 @@ import {
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
export const actionCopyStyles = register({ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const elementsCopied = [];
const element = elements.find((el) => appState.selectedElementIds[el.id]); const element = elements.find((el) => appState.selectedElementIds[el.id]);
elementsCopied.push(element);
if (element && hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element);
elementsCopied.push(boundTextElement);
}
if (element) { if (element) {
copiedStyles = JSON.stringify(elementsCopied); copiedStyles = JSON.stringify(element);
} }
return { return {
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.copyStyles") }, toastMessage: t("toast.copyStyles"),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -52,72 +38,31 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({ export const actionPasteStyles = register({
name: "pasteStyles", name: "pasteStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const elementsCopied = JSON.parse(copiedStyles); const pastedElement = JSON.parse(copiedStyles);
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElementIds = selectedElements.map((element) => element.id);
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (selectedElementIds.includes(element.id)) { if (appState.selectedElementIds[element.id]) {
let elementStylesToCopyFrom = pastedElement; const newElement = newElementWith(element, {
if (isTextElement(element) && element.containerId) { backgroundColor: pastedElement?.backgroundColor,
elementStylesToCopyFrom = boundTextElement; strokeWidth: pastedElement?.strokeWidth,
} strokeColor: pastedElement?.strokeColor,
if (!elementStylesToCopyFrom) { strokeStyle: pastedElement?.strokeStyle,
return element; fillStyle: pastedElement?.fillStyle,
} opacity: pastedElement?.opacity,
let newElement = newElementWith(element, { roughness: pastedElement?.roughness,
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
strokeColor: elementStylesToCopyFrom?.strokeColor,
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
roundness: elementStylesToCopyFrom.roundness
? canApplyRoundnessTypeToElement(
elementStylesToCopyFrom.roundness.type,
element,
)
? elementStylesToCopyFrom.roundness
: getDefaultRoundnessTypeForElement(element)
: null,
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
newElement = newElementWith(newElement, { mutateElement(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
let container = null; redrawTextBoundingBox(newElement);
if (newElement.containerId) {
container =
selectedElements.find(
(element) =>
isTextElement(newElement) &&
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(newElement, container);
} }
if (newElement.type === "arrow") {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,
});
}
return newElement; return newElement;
} }
return element; return element;

View File

@@ -2,15 +2,12 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { AppState } from "../types"; import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "grid");
return { return {
appState: { appState: {
...appState, ...appState,
@@ -20,9 +17,6 @@ export const actionToggleGridMode = register({
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridSize !== null,
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid", contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
}); });

View File

@@ -1,60 +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);
const lock = operation === "lock";
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: lock });
}),
appState: {
...appState,
selectedLinearElement: lock ? null : appState.selectedLinearElement,
},
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";
}
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
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");

View File

@@ -3,8 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
viewMode: true,
trackEvent: { category: "menu" },
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {

View File

@@ -1,14 +1,11 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "view");
return { return {
appState: { appState: {
...appState, ...appState,
@@ -18,9 +15,6 @@ export const actionToggleViewMode = register({
}; };
}, },
checked: (appState) => appState.viewModeEnabled, checked: (appState) => appState.viewModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode", contextItemLabel: "labels.viewMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,

View File

@@ -1,14 +1,12 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "zen");
return { return {
appState: { appState: {
...appState, ...appState,
@@ -18,9 +16,6 @@ export const actionToggleZenMode = register({
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode", contextItemLabel: "buttons.zenMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,

View File

@@ -10,15 +10,14 @@ import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { import {
BringForwardIcon,
BringToFrontIcon,
SendBackwardIcon, SendBackwardIcon,
BringToFrontIcon,
SendToBackIcon, SendToBackIcon,
BringForwardIcon,
} from "../components/icons"; } from "../components/icons";
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneLeft(elements, appState), elements: moveOneLeft(elements, appState),
@@ -39,14 +38,13 @@ export const actionSendBackward = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`} title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
> >
{SendBackwardIcon} <SendBackwardIcon theme={appState.theme} />
</button> </button>
), ),
}); });
export const actionBringForward = register({ export const actionBringForward = register({
name: "bringForward", name: "bringForward",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneRight(elements, appState), elements: moveOneRight(elements, appState),
@@ -67,14 +65,13 @@ export const actionBringForward = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`} title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
> >
{BringForwardIcon} <BringForwardIcon theme={appState.theme} />
</button> </button>
), ),
}); });
export const actionSendToBack = register({ export const actionSendToBack = register({
name: "sendToBack", name: "sendToBack",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllLeft(elements, appState), elements: moveAllLeft(elements, appState),
@@ -102,15 +99,13 @@ export const actionSendToBack = register({
: getShortcutKey("CtrlOrCmd+Shift+[") : getShortcutKey("CtrlOrCmd+Shift+[")
}`} }`}
> >
{SendToBackIcon} <SendToBackIcon theme={appState.theme} />
</button> </button>
), ),
}); });
export const actionBringToFront = register({ export const actionBringToFront = register({
name: "bringToFront", name: "bringToFront",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllRight(elements, appState), elements: moveAllRight(elements, appState),
@@ -138,7 +133,7 @@ export const actionBringToFront = register({
: getShortcutKey("CtrlOrCmd+Shift+]") : getShortcutKey("CtrlOrCmd+Shift+]")
}`} }`}
> >
{BringToFrontIcon} <BringToFrontIcon theme={appState.theme} />
</button> </button>
), ),
}); });

View File

@@ -17,7 +17,6 @@ export {
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -75,14 +74,9 @@ export {
actionCut, actionCut,
actionCopyAsPng, actionCopyAsPng,
actionCopyAsSvg, actionCopyAsSvg,
copyText,
} from "./actionClipboard"; } from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";

View File

@@ -1,58 +1,40 @@
import React from "react"; import React from "react";
import { import {
Action, Action,
ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionName, ActionName,
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppProps, AppState } from "../types";
import { trackEvent } from "../analytics"; import { MODES } from "../constants";
import Library from "../data/library";
const trackAction = ( // This is the <App> component, but for now we don't care about anything but its
action: Action, // `canvas` state.
source: ActionSource, type App = {
appState: Readonly<AppState>, canvas: HTMLCanvasElement | null;
elements: readonly ExcalidrawElement[], focusContainer: () => void;
app: AppClassProperties, props: AppProps;
value: any, library: Library;
) => {
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.device.isMobile ? "mobile" : "desktop"})`,
);
}
}
} catch (error) {
console.error("error while logging action:", error);
}
}
}; };
export class ActionManager { export class ActionManager implements ActionsManagerInterface {
actions = {} as Record<ActionName, Action>; actions = {} as ActionsManagerInterface["actions"];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>; getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: AppClassProperties; app: App;
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
getAppState: () => AppState, getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[], getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: AppClassProperties, app: App,
) { ) {
this.updater = (actionResult) => { this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) { if (actionResult && "then" in actionResult) {
@@ -93,39 +75,37 @@ export class ActionManager {
), ),
); );
if (data.length !== 1) { if (data.length === 0) {
if (data.length > 1) { return false;
console.warn("Canceling as multiple actions match this shortcut", data); }
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
} }
return false;
} }
const action = data[0];
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
return false;
}
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, "keyboard", appState, elements, this.app, null);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); this.updater(
this.updater(data[0].perform(elements, appState, value, this.app)); data[0].perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true; return true;
} }
executeAction(action: Action, source: ActionSource = "api") { executeAction(action: Action) {
const elements = this.getElementsIncludingDeleted(); this.updater(
const appState = this.getAppState(); action.perform(
const value = null; this.getElementsIncludingDeleted(),
this.getAppState(),
trackAction(action, source, appState, elements, this.app, value); null,
this.app,
this.updater(action.perform(elements, appState, value, this.app)); ),
);
} }
/** /**
@@ -143,12 +123,7 @@ export class ActionManager {
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
PanelComponent.displayName = "PanelComponent";
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const updateData = (formState?: any) => { const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater( this.updater(
action.perform( action.perform(
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
@@ -172,14 +147,4 @@ export class ActionManager {
return null; return null;
}; };
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
};
} }

View File

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

View File

@@ -1,54 +1,40 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin } from "../keys"; import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
export type ShortcutName = export type ShortcutName =
| SubtypeOf< | "cut"
ActionName, | "copy"
| "toggleTheme" | "paste"
| "loadScene" | "copyStyles"
| "cut" | "pasteStyles"
| "copy" | "selectAll"
| "paste" | "deleteSelectedElements"
| "copyStyles" | "duplicateSelection"
| "pasteStyles" | "sendBackward"
| "selectAll" | "bringForward"
| "deleteSelectedElements" | "sendToBack"
| "duplicateSelection" | "bringToFront"
| "sendBackward" | "copyAsPng"
| "bringForward" | "copyAsSvg"
| "sendToBack" | "group"
| "bringToFront" | "ungroup"
| "copyAsPng" | "gridMode"
| "copyAsSvg" | "zenMode"
| "group" | "stats"
| "ungroup" | "addToLibrary"
| "gridMode" | "viewMode"
| "zenMode" | "flipHorizontal"
| "stats" | "flipVertical";
| "addToLibrary"
| "viewMode"
| "flipHorizontal"
| "flipVertical"
| "hyperlink"
| "toggleLock"
>
| "saveScene"
| "imageExport";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")],
loadScene: [getShortcutKey("CtrlOrCmd+O")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")], copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")], paste: [getShortcutKey("CtrlOrCmd+V")],
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")], selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Delete")], deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [ duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"), getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`), getShortcutKey(`Alt+${t("helpDialog.drag")}`),
@@ -76,12 +62,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipHorizontal: [getShortcutKey("Shift+H")], flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name]; const shortcuts = shortcutMap[name];
// if multiple shortcuts available, take the first one // if multiple shortcuts availiable, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
}; };

View File

@@ -1,13 +1,8 @@
import React from "react"; import React from "react";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { import { AppState, ExcalidrawProps } from "../types";
AppClassProperties, import Library from "../data/library";
AppState, import { ToolButtonSize } from "../components/ToolButton";
ExcalidrawProps,
BinaryFiles,
} from "../types";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */ /** if false, the action should be prevented */
export type ActionResult = export type ActionResult =
@@ -17,18 +12,22 @@ export type ActionResult =
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> | null; > | null;
files?: BinaryFiles | null;
commitToHistory: boolean; commitToHistory: boolean;
syncHistory?: boolean; syncHistory?: boolean;
replaceFiles?: boolean;
} }
| false; | false;
type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = ( type ActionFn = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
formData: any, formData: any,
app: AppClassProperties, app: AppAPI,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
@@ -40,7 +39,6 @@ export type ActionName =
| "paste" | "paste"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg" | "copyAsSvg"
| "copyText"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
| "sendToBack" | "sendToBack"
@@ -84,14 +82,13 @@ export type ActionName =
| "zoomToSelection" | "zoomToSelection"
| "changeFontFamily" | "changeFontFamily"
| "changeTextAlign" | "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen" | "toggleFullScreen"
| "toggleShortcuts" | "toggleShortcuts"
| "group" | "group"
| "ungroup" | "ungroup"
| "goToCollaborator" | "goToCollaborator"
| "addToLibrary" | "addToLibrary"
| "changeRoundness" | "changeSharpness"
| "alignTop" | "alignTop"
| "alignBottom" | "alignBottom"
| "alignLeft" | "alignLeft"
@@ -104,22 +101,14 @@ export type ActionName =
| "flipVertical" | "flipVertical"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme";
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock"
| "toggleLinearEditor";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: AppState; appState: AppState;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
appProps: ExcalidrawProps; appProps: ExcalidrawProps;
data?: Record<string, any>; data?: Partial<{ id: string; size: ToolButtonSize }>;
}; };
export interface Action { export interface Action {
@@ -132,39 +121,18 @@ export interface Action {
appState: AppState, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => boolean; ) => boolean;
contextItemLabel?: contextItemLabel?: string;
| string contextItemPredicate?: (
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
) => string);
predicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: }
| false
| { export interface ActionsManagerInterface {
category: actions: Record<ActionName, Action>;
| "toolbar" registerAction: (action: Action) => void;
| "element" handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
| "canvas" renderAction: (name: ActionName) => React.ReactElement | null;
| "export" executeAction: (action: Action) => void;
| "history"
| "menu"
| "collab"
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
/** if set to `true`, allow action to be performed in viewMode.
* Defaults to `false` */
viewMode?: boolean;
} }

View File

@@ -1,7 +1,13 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds"; import { getCommonBounds } from "./element";
import { getMaximumGroups } from "./groups";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -31,6 +37,28 @@ export const alignElements = (
}); });
}; };
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const calculateTranslation = ( const calculateTranslation = (
group: ExcalidrawElement[], group: ExcalidrawElement[],
selectionBoundingBox: Box, selectionBoundingBox: Box,
@@ -60,3 +88,8 @@ const calculateTranslation = (
(groupBoundingBox[min] + groupBoundingBox[max]) / 2, (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
}; };
}; };
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { minX, minY, maxX, maxY };
};

View File

@@ -3,20 +3,16 @@ export const trackEvent =
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" && typeof window !== "undefined" &&
window.gtag window.gtag
? (category: string, action: string, label?: string, value?: number) => { ? (category: string, name: string, label?: string, value?: number) => {
try { window.gtag("event", name, {
window.gtag("event", action, { event_category: category,
event_category: category, event_label: label,
event_label: label, value,
value, });
});
} catch (error) {
console.error("error logging to ga", error);
}
} }
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, action: string, label?: string, value?: number) => {} ? (category: string, name: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => { : (category: string, name: string, label?: string, value?: number) => {
// Uncomment the next line to track locally // Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value }); // console.info("Track Event", category, name, label, value);
}; };

View File

@@ -19,7 +19,6 @@ export const getDefaultAppState = (): Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> => { > => {
return { return {
showWelcomeScreen: false,
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
@@ -28,11 +27,12 @@ export const getDefaultAppState = (): Omit<
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100, currentItemOpacity: 100,
currentItemRoughness: 1, currentItemRoughness: 1,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black, currentItemStrokeColor: oc.black,
currentItemRoundness: "round", currentItemStrokeSharpness: "sharp",
currentItemStrokeStyle: "solid", currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1, currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@@ -41,14 +41,8 @@ export const getDefaultAppState = (): Omit<
editingElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
activeTool: { elementLocked: false,
type: "selection", elementType: "selection",
customType: null,
locked: false,
lastActiveToolBeforeEraser: null,
},
penMode: false,
penDetected: false,
errorMessage: null, errorMessage: null,
exportBackground: true, exportBackground: true,
exportScale: defaultExportScale, exportScale: defaultExportScale,
@@ -57,18 +51,15 @@ export const getDefaultAppState = (): Omit<
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
isSidebarDocked: false, isLibraryOpen: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
lastPointerDownWith: "mouse", lastPointerDownWith: "mouse",
multiElement: null, multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null, openMenu: null,
openPopup: null, openPopup: null,
openSidebar: null,
openDialog: null,
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {}, previousSelectedElementIds: {},
resizingElement: null, resizingElement: null,
@@ -79,19 +70,15 @@ export const getDefaultAppState = (): Omit<
selectedGroupIds: {}, selectedGroupIds: {},
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
toast: null, toastMessage: null,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
}; };
}; };
@@ -105,91 +92,78 @@ const APP_STATE_STORAGE_CONF = (<
browser: boolean; browser: boolean;
/** whether to keep when exporting to file/database */ /** whether to keep when exporting to file/database */
export: boolean; export: boolean;
/** server (shareLink/collab/...) */
server: boolean;
}, },
T extends Record<keyof AppState, Values>, T extends Record<keyof AppState, Values>
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => >(
config)({ config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
showWelcomeScreen: { browser: true, export: false, server: false }, ) => config)({
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false, server: false }, currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false, server: false }, currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false, server: false }, currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false, server: false }, currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false, server: false }, currentItemFontSize: { browser: true, export: false },
currentItemRoundness: { currentItemLinearStrokeSharpness: { browser: true, export: false },
browser: true, currentItemOpacity: { browser: true, export: false },
export: false, currentItemRoughness: { browser: true, export: false },
server: false, currentItemStartArrowhead: { browser: true, export: false },
}, currentItemStrokeColor: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false, server: false }, currentItemStrokeSharpness: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, draggingElement: { browser: false, export: false },
currentItemTextAlign: { browser: true, export: false, server: false }, editingElement: { browser: false, export: false },
cursorButton: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false },
draggingElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false },
editingElement: { browser: false, export: false, server: false }, elementLocked: { browser: true, export: false },
editingGroupId: { browser: true, export: false, server: false }, elementType: { browser: true, export: false },
editingLinearElement: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false },
activeTool: { browser: true, export: false, server: false }, exportBackground: { browser: true, export: false },
penMode: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false },
penDetected: { browser: true, export: false, server: false }, exportScale: { browser: true, export: false },
errorMessage: { browser: false, export: false, server: false }, exportWithDarkMode: { browser: true, export: false },
exportBackground: { browser: true, export: false, server: false }, fileHandle: { browser: false, export: false },
exportEmbedScene: { browser: true, export: false, server: false }, gridSize: { browser: true, export: true },
exportScale: { browser: true, export: false, server: false }, height: { browser: false, export: false },
exportWithDarkMode: { browser: true, export: false, server: false }, isBindingEnabled: { browser: false, export: false },
fileHandle: { browser: false, export: false, server: false }, isLibraryOpen: { browser: false, export: false },
gridSize: { browser: true, export: true, server: true }, isLoading: { browser: false, export: false },
height: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false },
isSidebarDocked: { browser: true, export: false, server: false }, lastPointerDownWith: { browser: true, export: false },
isLoading: { browser: false, export: false, server: false }, multiElement: { browser: false, export: false },
isResizing: { browser: false, export: false, server: false }, name: { browser: true, export: false },
isRotating: { browser: false, export: false, server: false }, offsetLeft: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false, server: false }, offsetTop: { browser: false, export: false },
multiElement: { browser: false, export: false, server: false }, openMenu: { browser: true, export: false },
name: { browser: true, export: false, server: false }, openPopup: { browser: false, export: false },
offsetLeft: { browser: false, export: false, server: false }, pasteDialog: { browser: false, export: false },
offsetTop: { browser: false, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false },
contextMenu: { browser: false, export: false, server: false }, resizingElement: { browser: false, export: false },
openMenu: { browser: true, export: false, server: false }, scrolledOutside: { browser: true, export: false },
openPopup: { browser: false, export: false, server: false }, scrollX: { browser: true, export: false },
openSidebar: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false },
openDialog: { browser: false, export: false, server: false }, selectedElementIds: { browser: true, export: false },
pasteDialog: { browser: false, export: false, server: false }, selectedGroupIds: { browser: true, export: false },
previousSelectedElementIds: { browser: true, export: false, server: false }, selectionElement: { browser: false, export: false },
resizingElement: { browser: false, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false },
scrolledOutside: { browser: true, export: false, server: false }, showHelpDialog: { browser: false, export: false },
scrollX: { browser: true, export: false, server: false }, showStats: { browser: true, export: false },
scrollY: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false },
selectedElementIds: { browser: true, export: false, server: false }, suggestedBindings: { browser: false, export: false },
selectedGroupIds: { browser: true, export: false, server: false }, toastMessage: { browser: false, export: false },
selectionElement: { browser: false, export: false, server: false }, viewBackgroundColor: { browser: true, export: true },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, width: { browser: false, export: false },
showStats: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false },
startBoundElement: { browser: false, export: false, server: false }, zoom: { browser: true, export: false },
suggestedBindings: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false },
toast: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
ExportType extends "export" | "browser" | "server",
>(
appState: Partial<AppState>, appState: Partial<AppState>,
exportType: ExportType, exportType: ExportType,
) => { ) => {
@@ -202,10 +176,8 @@ const _clearAppStateForStorage = <
for (const key of Object.keys(appState) as (keyof typeof appState)[]) { for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key]; const propConfig = APP_STATE_STORAGE_CONF[key];
if (propConfig?.[exportType]) { if (propConfig?.[exportType]) {
const nextValue = appState[key]; // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
// https://github.com/microsoft/TypeScript/issues/31445
(stateForExport as any)[key] = nextValue;
} }
} }
return stateForExport; return stateForExport;
@@ -218,13 +190,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
export const cleanAppStateForExport = (appState: Partial<AppState>) => { export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export"); return _clearAppStateForStorage(appState, "export");
}; };
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
export const isEraserActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";

View File

@@ -1,121 +0,0 @@
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {
it.each<[string, number]>([
["1", 1],
["0", 0],
["-1", -1],
["0.1", 0.1],
[".1", 0.1],
["1.", 1],
["424.", 424],
["$1", 1],
["-.1", -0.1],
["-$1", -1],
["$-1", -1],
])("should correctly identify %s as numbers", (given, expected) => {
expect(tryParseNumber(given)).toEqual(expected);
});
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
"should correctly identify %s as not a number",
(given) => {
expect(tryParseNumber(given)).toBeNull();
},
);
});
describe("tryParseCells", () => {
it("Successfully parses a spreadsheet", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("Uses the second column as the label if it is not a number", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("treats the first column as labels if both columns are numbers", () => {
const spreadsheet = [
["time", "value"],
["01", "61"],
["02", "-60"],
["03", "85"],
["04", "-67"],
["05", "54"],
["06", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
});
});

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