Compare commits

..

5 Commits

Author SHA1 Message Date
pomdtr
8984d8f19a don't clean export options on load 2022-06-29 10:23:48 +00:00
Achille Lacoin
b80706cd4a Update appState.ts 2022-06-28 17:53:01 +02:00
pomdtr
cf34cbdd30 fix export.test.tsx 2022-06-28 08:16:14 +00:00
pomdtr
6ead3ff839 fix export snapshot 2022-06-28 08:12:38 +00:00
pomdtr
d7f0d4ee21 [feat] serialize export options when embedding scene in an image 2022-06-27 20:31:05 +00:00
198 changed files with 7332 additions and 16559 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

@@ -11,12 +11,3 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002
REACT_APP_PORTAL_URL= REACT_APP_PORTAL_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# 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=

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

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

View File

@@ -1,4 +1,4 @@
name: Auto release excalidraw next name: Auto release @excalidraw/excalidraw-next
on: on:
push: push:
branches: branches:

View File

@@ -1,4 +1,4 @@
name: Auto release excalidraw preview name: Auto release preview @excalidraw/excalidraw-preview
on: on:
issue_comment: issue_comment:
types: [created, edited] types: [created, edited]

View File

@@ -10,16 +10,11 @@ jobs:
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@v2
- 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

1
.gitignore vendored
View File

@@ -19,6 +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

View File

@@ -88,7 +88,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!

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

@@ -23,17 +23,17 @@
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.4.3",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "18.0.15", "@types/react": "17.0.39",
"@types/react-dom": "18.0.6", "@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "1.6.4", "jotai": "1.6.4",
@@ -42,14 +42,13 @@
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.16", "perfect-freehand": "1.0.16",
"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": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
@@ -60,11 +59,11 @@
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3", "@types/pako": "1.0.3",
"@types/resize-observer-browser": "0.1.7", "@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "10.0.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"husky": "7.0.4", "husky": "7.0.4",
@@ -72,7 +71,7 @@
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0" "rewire": "5.0.0"
}, },
"resolutions": { "resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2" "@typescript-eslint/typescript-estree": "5.10.2"
@@ -95,8 +94,7 @@
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "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:prebuild": "node ./scripts/prebuild.js", "build": "yarn build:app && yarn build:version",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
@@ -114,8 +112,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"
} }
} }

View File

@@ -98,22 +98,6 @@
/> />
<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.

View File

@@ -5,25 +5,22 @@ 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}🎉`); console.info("Published 🎉");
core.setOutput( core.setOutput(
"result", "result",
`**Preview version has been shipped** :rocket: `**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!`, You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
); );
} catch (error) { } catch (error) {
core.setOutput("result", "package couldn't be published :warning:!"); core.setOutput("result", "package couldn't be published :warning:!");
@@ -54,19 +51,27 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
} }
// update package.json // update package.json
pkg.name = "@excalidraw/excalidraw-next";
let version = `${pkg.version}-${getShortCommitHash()}`; let version = `${pkg.version}-${getShortCommitHash()}`;
// update readme // update readme
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
const isPreview = process.argv.slice(2)[0] === "preview";
if (isPreview) { if (isPreview) {
// use pullNumber-commithash as the version for preview // use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0]; const pullRequestNumber = process.argv.slice(3)[0];
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`; version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
// replace "excalidraw-next" with "excalidraw-preview"
pkg.name = "@excalidraw/excalidraw-preview";
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
data = data.trim();
} }
pkg.version = version; pkg.version = version;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8"); fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
console.info("Publish in progress..."); console.info("Publish in progress...");
publish(); publish();
}); });

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

@@ -36,7 +36,6 @@ const crowdinMap = {
"ru-RU": "en-ru", "ru-RU": "en-ru",
"si-LK": "en-silk", "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", "ta-IN": "en-ta",
"tr-TR": "en-tr", "tr-TR": "en-tr",
@@ -48,8 +47,6 @@ const crowdinMap = {
"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 = {
@@ -89,7 +86,6 @@ const flags = {
"ru-RU": "🇷🇺", "ru-RU": "🇷🇺",
"si-LK": "🇱🇰", "si-LK": "🇱🇰",
"sk-SK": "🇸🇰", "sk-SK": "🇸🇰",
"sl-SI": "🇸🇮",
"sv-SE": "🇸🇪", "sv-SE": "🇸🇪",
"ta-IN": "🇮🇳", "ta-IN": "🇮🇳",
"tr-TR": "🇹🇷", "tr-TR": "🇹🇷",
@@ -97,9 +93,6 @@ const flags = {
"zh-CN": "🇨🇳", "zh-CN": "🇨🇳",
"zh-HK": "🇭🇰", "zh-HK": "🇭🇰",
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"eu-ES": "🇪🇦",
"vi-VN": "🇻🇳",
"mr-IN": "🇮🇳",
}; };
const languages = { const languages = {
@@ -140,7 +133,6 @@ const languages = {
"ru-RU": "Русский", "ru-RU": "Русский",
"si-LK": "සිංහල", "si-LK": "සිංහල",
"sk-SK": "Slovenčina", "sk-SK": "Slovenčina",
"sl-SI": "Slovenščina",
"sv-SE": "Svenska", "sv-SE": "Svenska",
"ta-IN": "Tamil", "ta-IN": "Tamil",
"tr-TR": "Türkçe", "tr-TR": "Türkçe",
@@ -148,8 +140,6 @@ const languages = {
"zh-CN": "简体中文", "zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)", "zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"vi-VN": "Tiếng Việt",
"mr-IN": "मराठी",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(

View File

@@ -1,21 +0,0 @@
const fs = require("fs");
const path = require("path");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
const newPath = path.resolve(__dirname, "../src/service-worker.js");
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();

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`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
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();

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

@@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
commitToHistory: false, commitToHistory: false,
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.addedToLibrary") }, toastMessage: t("toast.addedToLibrary"),
}, },
}; };
}) })

View File

@@ -107,16 +107,14 @@ export const actionCopyAsPng = register({
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,
}; };

View File

@@ -128,15 +128,12 @@ const duplicateElements = (
{ {
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: newElements.reduce( selectedElementIds: newElements.reduce((acc, element) => {
(acc: Record<ExcalidrawElement["id"], true>, element) => { if (!isBoundToContainer(element)) {
if (!isBoundToContainer(element)) { acc[element.id] = true;
acc[element.id] = true; }
} return acc;
return acc; }, {} as any),
},
{},
),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(finalElements),
), ),

View File

@@ -144,15 +144,13 @@ export const actionSaveToActiveFile = register({
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,
}, },
}; };
@@ -244,7 +242,7 @@ export const actionLoadScene = register({
} }
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData, appState }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={load} icon={load}

View File

@@ -13,7 +13,7 @@ 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"; import { AppState } from "../types";
export const actionFinalize = register({ export const actionFinalize = register({
@@ -33,9 +33,6 @@ export const actionFinalize = register({
endBindingElement, endBindingElement,
); );
} }
const selectedLinearElement = appState.selectedLinearElement
? new LinearElementEditor(element, scene, appState)
: null;
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
@@ -45,7 +42,6 @@ export const actionFinalize = register({
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
selectedLinearElement,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -185,11 +181,6 @@ export const actionFinalize = register({
[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)
: appState.selectedLinearElement,
pendingImageElementId: null, pendingImageElementId: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.activeTool.type === "freedraw",

View File

@@ -2,43 +2,29 @@ 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, isTextElement } 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" }, trackEvent: { category: "canvas" },
perform: (elements, appState, value, app) => { perform: (elements, appState) => {
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, appState)
: null,
editingGroupId: null, editingGroupId: null,
selectedElementIds, selectedElementIds: elements.reduce((map, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
element.locked === false
) {
map[element.id] = true;
}
return map;
}, {} as any),
}, },
getNonDeletedElements(elements), getNonDeletedElements(elements),
), ),

View File

@@ -36,7 +36,7 @@ export const actionCopyStyles = register({
return { return {
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.copyStyles") }, toastMessage: t("toast.copyStyles"),
}, },
commitToHistory: false, commitToHistory: false,
}; };

View File

@@ -17,19 +17,16 @@ export const actionToggleLock = register({
const operation = getOperation(selectedElements); const operation = getOperation(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements); const selectedElementsMap = arrayToMap(selectedElements);
const lock = operation === "lock";
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) { if (!selectedElementsMap.has(element.id)) {
return element; return element;
} }
return newElementWith(element, { locked: lock }); return newElementWith(element, { locked: operation === "lock" });
}), }),
appState: { appState,
...appState,
selectedLinearElement: lock ? null : appState.selectedLinearElement,
},
commitToHistory: true, commitToHistory: true,
}; };
}, },

View File

@@ -147,7 +147,6 @@ 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 elements = this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const updateData = (formState?: any) => { const updateData = (formState?: any) => {

View File

@@ -81,7 +81,7 @@ export const getDefaultAppState = (): Omit<
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: {
@@ -90,7 +90,6 @@ export const getDefaultAppState = (): Omit<
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null, pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null,
}; };
}; };
@@ -102,91 +101,228 @@ const APP_STATE_STORAGE_CONF = (<
Values extends { Values extends {
/** whether to keep when storing to browser storage (localStorage/IDB) */ /** whether to keep when storing to browser storage (localStorage/IDB) */
browser: boolean; browser: boolean;
/** whether to keep when exporting to file/database */ /** whether to keep when exporting to a text file */
export: boolean; text: boolean;
/** whether to keep when exporting to an image file */
image: boolean;
/** server (shareLink/collab/...) */ /** server (shareLink/collab/...) */
server: boolean; server: boolean;
}, },
T extends Record<keyof AppState, Values>, T extends Record<keyof AppState, Values>,
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({ config)({
theme: { browser: true, export: false, server: false }, theme: { browser: true, text: false, image: false, server: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, text: false, image: false, server: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, text: false, image: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false }, currentItemBackgroundColor: {
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemLinearStrokeSharpness: {
browser: true, browser: true,
export: false, text: false,
image: false,
server: false,
},
currentItemEndArrowhead: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemFillStyle: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemFontFamily: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemFontSize: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemLinearStrokeSharpness: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemOpacity: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemRoughness: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStartArrowhead: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeColor: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeSharpness: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeStyle: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeWidth: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemTextAlign: {
browser: true,
text: false,
image: false,
server: false,
},
cursorButton: { browser: true, text: false, image: false, server: false },
draggingElement: { browser: false, text: false, image: false, server: false },
editingElement: { browser: false, text: false, image: false, server: false },
editingGroupId: { browser: true, text: false, image: false, server: false },
editingLinearElement: {
browser: false,
text: false,
image: false,
server: false,
},
activeTool: { browser: true, text: false, image: false, server: false },
penMode: { browser: true, text: false, image: false, server: false },
penDetected: { browser: true, text: false, image: false, server: false },
errorMessage: { browser: false, text: false, image: false, server: false },
exportBackground: { browser: true, text: false, image: true, server: false },
exportEmbedScene: { browser: true, text: false, image: true, server: false },
exportScale: { browser: true, text: false, image: true, server: false },
exportWithDarkMode: {
browser: true,
text: false,
image: true,
server: false,
},
fileHandle: { browser: false, text: false, image: false, server: false },
gridSize: { browser: true, text: true, image: true, server: true },
height: { browser: false, text: false, image: false, server: false },
isBindingEnabled: {
browser: false,
text: false,
image: false,
server: false,
},
isLibraryOpen: { browser: true, text: false, image: false, server: false },
isLibraryMenuDocked: {
browser: true,
text: false,
image: false,
server: false,
},
isLoading: { browser: false, text: false, image: false, server: false },
isResizing: { browser: false, text: false, image: false, server: false },
isRotating: { browser: false, text: false, image: false, server: false },
lastPointerDownWith: {
browser: true,
text: false,
image: false,
server: false,
},
multiElement: { browser: false, text: false, image: false, server: false },
name: { browser: true, text: false, image: false, server: false },
offsetLeft: { browser: false, text: false, image: false, server: false },
offsetTop: { browser: false, text: false, image: false, server: false },
openMenu: { browser: true, text: false, image: false, server: false },
openPopup: { browser: false, text: false, image: false, server: false },
pasteDialog: { browser: false, text: false, image: false, server: false },
previousSelectedElementIds: {
browser: true,
text: false,
image: false,
server: false,
},
resizingElement: { browser: false, text: false, image: false, server: false },
scrolledOutside: { browser: true, text: false, image: false, server: false },
scrollX: { browser: true, text: false, image: false, server: false },
scrollY: { browser: true, text: false, image: false, server: false },
selectedElementIds: {
browser: true,
text: false,
image: false,
server: false,
},
selectedGroupIds: { browser: true, text: false, image: false, server: false },
selectionElement: {
browser: false,
text: false,
image: false,
server: false,
},
shouldCacheIgnoreZoom: {
browser: true,
text: false,
image: false,
server: false,
},
showHelpDialog: { browser: false, text: false, image: false, server: false },
showStats: { browser: true, text: false, image: false, server: false },
startBoundElement: {
browser: false,
text: false,
image: false,
server: false,
},
suggestedBindings: {
browser: false,
text: false,
image: false,
server: false,
},
toastMessage: { browser: false, text: false, image: false, server: false },
viewBackgroundColor: {
browser: true,
text: true,
image: true,
server: true,
},
width: { browser: false, text: false, image: false, server: false },
zenModeEnabled: { browser: true, text: false, image: false, server: false },
zoom: { browser: true, text: false, image: false, server: false },
viewModeEnabled: { browser: false, text: false, image: false, server: false },
pendingImageElementId: {
browser: false,
text: false,
image: false,
server: false,
},
showHyperlinkPopup: {
browser: false,
text: false,
image: false,
server: false, server: false,
}, },
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: true, export: false, server: false },
isLibraryMenuDocked: { browser: true, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
lastPointerDownWith: { browser: true, export: false, server: false },
multiElement: { browser: false, export: false, server: false },
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
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" | "server", ExportType extends "image" | "text" | "browser" | "server",
>( >(
appState: Partial<AppState>, appState: Partial<AppState>,
exportType: ExportType, exportType: ExportType,
@@ -213,8 +349,12 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "browser"); return _clearAppStateForStorage(appState, "browser");
}; };
export const cleanAppStateForExport = (appState: Partial<AppState>) => { export const cleanAppStateForTextExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export"); return _clearAppStateForStorage(appState, "text");
};
export const cleanAppStateForImageExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "image");
}; };
export const clearAppStateForDatabase = (appState: Partial<AppState>) => { export const clearAppStateForDatabase = (appState: Partial<AppState>) => {

View File

@@ -26,17 +26,17 @@ import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
activeTool,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
activeTool: AppState["activeTool"]["type"];
}) => { }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -56,13 +56,13 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(appState.activeTool.type) || hasBackground(activeTool) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showChangeBackgroundIcons = const showChangeBackgroundIcons =
hasBackground(appState.activeTool.type) || hasBackground(activeTool) ||
targetElements.some((element) => hasBackground(element.type)); targetElements.some((element) => hasBackground(element.type));
const showLinkIcon = const showLinkIcon =
@@ -79,23 +79,23 @@ export const SelectedShapeActions = ({
return ( return (
<div className="panelColumn"> <div className="panelColumn">
{((hasStrokeColor(appState.activeTool.type) && {((hasStrokeColor(activeTool) &&
appState.activeTool.type !== "image" && activeTool !== "image" &&
commonSelectedType !== "image") || commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(activeTool) ||
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(appState.activeTool.type === "freedraw" || {(activeTool === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")} renderAction("changeStrokeShape")}
{(hasStrokeStyle(appState.activeTool.type) || {(hasStrokeStyle(activeTool) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
@@ -103,12 +103,12 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeSharpness(appState.activeTool.type) || {(canChangeSharpness(activeTool) ||
targetElements.some((element) => canChangeSharpness(element.type))) && ( targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</> <>{renderAction("changeSharpness")}</>
)} )}
{(hasText(appState.activeTool.type) || {(hasText(activeTool) ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some((element) => hasText(element.type))) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
@@ -123,7 +123,7 @@ export const SelectedShapeActions = ({
(element) => (element) =>
hasBoundTextElement(element) || isBoundToContainer(element), hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")} ) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) || {(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
@@ -271,45 +271,3 @@ export const ZoomActions = ({
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
); );
export const UndoRedoActions = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`undo-redo-buttons ${className}`}>
{renderAction("undo", { size: "small" })}
{renderAction("redo", { size: "small" })}
</div>
);
export const ExitZenModeAction = ({
actionManager,
showExitZenModeBtn,
}: {
actionManager: ActionManager;
showExitZenModeBtn: boolean;
}) => (
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={() => actionManager.executeAction(actionToggleZenMode)}
>
{t("buttons.exitZenMode")}
</button>
);
export const FinalizeAction = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`finalize-button ${className}`}>
{renderAction("finalize", { size: "small" })}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ import "./Card.scss";
export const Card: React.FC<{ export const Card: React.FC<{
color: keyof OpenColor | "primary"; color: keyof OpenColor | "primary";
children?: React.ReactNode;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div

View File

@@ -8,7 +8,6 @@ export const CheckboxItem: React.FC<{
checked: boolean; checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void; onChange: (checked: boolean, event: React.MouseEvent) => void;
className?: string; className?: string;
children?: React.ReactNode;
}> = ({ children, checked, onChange, className }) => { }> = ({ children, checked, onChange, className }) => {
return ( return (
<div <div

View File

@@ -18,15 +18,13 @@
left: -5px; left: -5px;
} }
min-width: 1em; min-width: 1em;
min-height: 1em;
line-height: 1;
position: absolute; position: absolute;
bottom: -5px; bottom: -5px;
padding: 3px; padding: 3px;
border-radius: 50%; border-radius: 50%;
background-color: $oc-green-6; background-color: $oc-green-6;
color: $oc-white; color: $oc-white;
font-size: 0.6em; font-size: 0.7em;
font-family: "Cascadia"; font-family: var(--ui-font);
} }
} }

View File

@@ -28,7 +28,7 @@ const CollabButton = ({
aria-label={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")}
showAriaLabel={useDevice().isMobile} showAriaLabel={useDevice().isMobile}
> >
{isCollaborating && ( {collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div> <div className="CollabButton-collaborators">{collaboratorCount}</div>
)} )}
</ToolButton> </ToolButton>

View File

@@ -343,8 +343,6 @@ const ColorInput = React.forwardRef(
}, },
); );
ColorInput.displayName = "ColorInput";
export const ColorPicker = ({ export const ColorPicker = ({
type, type,
color, color,

View File

@@ -85,7 +85,6 @@ export const Dialog = (props: DialogProps) => {
<button <button
className="Modal__close" className="Modal__close"
onClick={onClose} onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDevice().isMobile ? back : close} {useDevice().isMobile ? back : close}

View File

@@ -1,106 +0,0 @@
import clsx from "clsx";
import { ActionManager } from "../actions/manager";
import { AppState, ExcalidrawProps } from "../types";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "./Actions";
import { useDevice } from "./App";
import { Island } from "./Island";
import { Section } from "./Section";
import Stack from "./Stack";
const Footer = ({
appState,
actionManager,
renderCustomFooter,
showExitZenModeBtn,
}: {
appState: AppState;
actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean;
}) => {
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
>
<div
className={clsx("layer-ui__wrapper__footer-left zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{!appState.viewModeEnabled && (
<>
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
/>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)}
{showFinalize && (
<FinalizeAction
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
/>
)}
</Section>
</Stack.Col>
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled,
})}
>
{actionManager.renderAction("toggleShortcuts")}
</div>
<ExitZenModeAction
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
/>
</footer>
);
};
export default Footer;

View File

@@ -58,7 +58,6 @@ const ExportButton: React.FC<{
onClick: () => void; onClick: () => void;
title: string; title: string;
shade?: number; shade?: number;
children?: React.ReactNode;
}> = ({ children, title, onClick, color, shade = 6 }) => { }> = ({ children, title, onClick, color, shade = 6 }) => {
return ( return (
<button <button
@@ -171,9 +170,7 @@ const ImageExportModal = ({
<Stack.Row gap={2}> <Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")} {actionManager.renderAction("changeExportScale")}
</Stack.Row> </Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}> <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
{t("buttons.scale")}
</p>
</div> </div>
<div <div
style={{ style={{

View File

@@ -10,7 +10,7 @@ import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
@@ -39,7 +39,6 @@ import { trackEvent } from "../analytics";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./Footer";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -52,13 +51,16 @@ interface LayerUIProps {
onLockToggle: () => void; onLockToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
showThemeBtn: boolean; showThemeBtn: boolean;
toggleZenMode: () => void;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"]; renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void; focusContainer: () => void;
@@ -71,18 +73,21 @@ const LayerUI = ({
appState, appState,
files, files,
setAppState, setAppState,
elements,
canvas, canvas,
elements,
onCollabButtonClick, onCollabButtonClick,
onLockToggle, onLockToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements, onInsertElements,
zenModeEnabled,
showExitZenModeBtn, showExitZenModeBtn,
showThemeBtn, showThemeBtn,
toggleZenMode,
isCollaborating, isCollaborating,
renderTopRightUI, renderTopRightUI,
renderCustomFooter, renderCustomFooter,
renderCustomStats, renderCustomStats,
viewModeEnabled,
libraryReturnUrl, libraryReturnUrl,
UIOptions, UIOptions,
focusContainer, focusContainer,
@@ -166,7 +171,7 @@ const LayerUI = ({
<Section <Section
heading="canvasActions" heading="canvasActions"
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
@@ -187,7 +192,7 @@ const LayerUI = ({
<Section <Section
heading="canvasActions" heading="canvasActions"
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
@@ -210,8 +215,8 @@ const LayerUI = ({
)} )}
</Stack.Row> </Stack.Row>
<BackgroundPickerAndDarkModeToggle <BackgroundPickerAndDarkModeToggle
appState={appState}
actionManager={actionManager} actionManager={actionManager}
appState={appState}
setAppState={setAppState} setAppState={setAppState}
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
/> />
@@ -227,7 +232,7 @@ const LayerUI = ({
<Section <Section
heading="selectedShapeActions" heading="selectedShapeActions"
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
<Island <Island
@@ -244,6 +249,7 @@ const LayerUI = ({
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
/> />
</Island> </Island>
</Section> </Section>
@@ -278,6 +284,7 @@ const LayerUI = ({
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer} focusContainer={focusContainer}
library={library} library={library}
theme={appState.theme}
files={files} files={files}
id={id} id={id}
appState={appState} appState={appState}
@@ -295,34 +302,32 @@ const LayerUI = ({
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col
gap={4} gap={4}
className={clsx({ className={clsx({ "disable-pointerEvents": zenModeEnabled })}
"disable-pointerEvents": appState.zenModeEnabled,
})}
> >
{appState.viewModeEnabled {viewModeEnabled
? renderViewModeCanvasActions() ? renderViewModeCanvasActions()
: renderCanvasActions()} : renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && ( {!viewModeEnabled && (
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading) => (
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={1} gap={1}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": zenModeEnabled,
})} })}
> >
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
onChange={onPenModeToggle} onChange={onPenModeToggle}
title={t("toolBar.penMode")} title={t("toolBar.penMode")}
penDetected={appState.penDetected} penDetected={appState.penDetected}
/> />
<LockButton <LockButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.activeTool.locked} checked={appState.activeTool.locked}
onChange={() => onLockToggle()} onChange={() => onLockToggle()}
title={t("toolBar.lock")} title={t("toolBar.lock")}
@@ -330,7 +335,7 @@ const LayerUI = ({
<Island <Island
padding={1} padding={1}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": zenModeEnabled,
})} })}
> >
<HintViewer <HintViewer
@@ -366,7 +371,7 @@ const LayerUI = ({
className={clsx( className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": zenModeEnabled,
}, },
)} )}
> >
@@ -381,7 +386,99 @@ const LayerUI = ({
); );
}; };
return ( const renderBottomAppMenu = () => {
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
>
<div
className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
},
)}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{!viewModeEnabled && (
<>
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)}
{!viewModeEnabled &&
appState.multiElement &&
device.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
"transition-right disable-pointerEvents": zenModeEnabled,
},
)}
>
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
</footer>
);
};
const dialogs = (
<> <>
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && ( {appState.errorMessage && (
@@ -409,81 +506,87 @@ const LayerUI = ({
} }
/> />
)} )}
{device.isMobile && ( </>
<MobileMenu );
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
/>
)}
{!device.isMobile && ( const renderStats = () => {
<> if (!appState.showStats) {
<div return null;
className={clsx("layer-ui__wrapper", { }
"disable-pointerEvents": return (
appState.draggingElement || <Stats
appState.resizingElement || appState={appState}
(appState.editingElement && setAppState={setAppState}
!isTextElement(appState.editingElement)), elements={elements}
})} onClose={() => {
style={ actionManager.executeAction(actionToggleStats);
appState.isLibraryOpen && }}
appState.isLibraryMenuDocked && renderCustomStats={renderCustomStats}
device.canDeviceFitSidebar />
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } );
: {} };
}
return device.isMobile ? (
<>
{dialogs}
<MobileMenu
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderStats={renderStats}
/>
</>
) : (
<>
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement &&
!isTextElement(appState.editingElement)),
})}
style={
appState.isLibraryOpen &&
appState.isLibraryMenuDocked &&
device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderStats()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
> >
{renderFixedSideContainer()} {t("buttons.scrollBackToContent")}
<Footer </button>
appState={appState} )}
actionManager={actionManager} </div>
renderCustomFooter={renderCustomFooter} {appState.isLibraryOpen && (
showExitZenModeBtn={showExitZenModeBtn} <div className="layer-ui__sidebar">{libraryMenu}</div>
/>
{appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
{appState.isLibraryOpen && (
<div className="layer-ui__sidebar">{libraryMenu}</div>
)}
</>
)} )}
</> </>
); );

View File

@@ -80,6 +80,7 @@ export const LibraryMenu = ({
onInsertLibraryItems, onInsertLibraryItems,
pendingElements, pendingElements,
onAddToLibrary, onAddToLibrary,
theme,
setAppState, setAppState,
files, files,
libraryReturnUrl, libraryReturnUrl,
@@ -92,6 +93,7 @@ export const LibraryMenu = ({
onClose: () => void; onClose: () => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles; files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -103,12 +105,12 @@ export const LibraryMenu = ({
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const device = useDevice(); const device = useDevice();
useOnClickOutside( useOnClickOutside(
ref, ref,
useCallback( useCallback(
(event) => { (event) => {
// If click on the library icon, do nothing so that LibraryButton // If click on the library icon, do nothing.
// can toggle library menu
if ((event.target as Element).closest(".ToolIcon__library")) { if ((event.target as Element).closest(".ToolIcon__library")) {
return; return;
} }
@@ -222,7 +224,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]); }, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback( const onPublishLibSuccess = useCallback(
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => { (data, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false); setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName }); setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice(); const nextLibItems = libraryItems.slice();
@@ -288,7 +290,7 @@ export const LibraryMenu = ({
appState={appState} appState={appState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
library={library} library={library}
theme={appState.theme} theme={theme}
files={files} files={files}
id={id} id={id}
selectedItems={selectedItems} selectedItems={selectedItems}

View File

@@ -1,3 +1,4 @@
import { chunk } from "lodash";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
import Library from "../data/library"; import Library from "../data/library";
@@ -10,7 +11,7 @@ import {
LibraryItem, LibraryItem,
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
import { arrayToMap, chunk, muteFSAbortError } from "../utils"; import { arrayToMap, muteFSAbortError } from "../utils";
import { useDevice } from "./App"; import { useDevice } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons"; import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { AppState, ExcalidrawProps } from "../types"; import { AppState } from "../types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n"; import { t } from "../i18n";
import Stack from "./Stack"; import Stack from "./Stack";
@@ -18,8 +18,6 @@ import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@@ -38,13 +36,14 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
viewModeEnabled: boolean;
showThemeBtn: boolean; showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderStats: () => JSX.Element | null;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@@ -61,16 +60,17 @@ export const MobileMenu = ({
canvas, canvas,
isCollaborating, isCollaborating,
renderCustomFooter, renderCustomFooter,
viewModeEnabled,
showThemeBtn, showThemeBtn,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderStats,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container"> <Stack.Row gap={1} className="App-toolbar-container">
<Island padding={1} className="App-toolbar"> <Island padding={1} className="App-toolbar">
@@ -121,10 +121,11 @@ export const MobileMenu = ({
const renderAppToolbar = () => { const renderAppToolbar = () => {
// Render eraser conditionally in mobile // Render eraser conditionally in mobile
const showEraser = const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement && !appState.editingElement &&
getSelectedElements(elements, appState).length === 0; getSelectedElements(elements, appState).length === 0;
if (appState.viewModeEnabled) { if (viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")} {actionManager.renderAction("toggleCanvasMenu")}
@@ -139,18 +140,18 @@ export const MobileMenu = ({
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
{showEraser {showEraser && actionManager.renderAction("eraser")}
? actionManager.renderAction("eraser")
: actionManager.renderAction( {actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection", appState.multiElement ? "finalize" : "duplicateSelection",
)} )}
{actionManager.renderAction("deleteSelectedElements")} {actionManager.renderAction("deleteSelectedElements")}
</div> </div>
); );
}; };
const renderCanvasActions = () => { const renderCanvasActions = () => {
if (appState.viewModeEnabled) { if (viewModeEnabled) {
return ( return (
<> <>
{renderJSONExportDialog()} {renderJSONExportDialog()}
@@ -184,18 +185,8 @@ export const MobileMenu = ({
}; };
return ( return (
<> <>
{!appState.viewModeEnabled && renderToolbar()} {!viewModeEnabled && renderToolbar()}
{!appState.openMenu && appState.showStats && ( {renderStats()}
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
@@ -225,32 +216,31 @@ export const MobileMenu = ({
</div> </div>
</Section> </Section>
) : appState.openMenu === "shape" && ) : appState.openMenu === "shape" &&
!appState.viewModeEnabled && !viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? ( showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions"> <Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
/> />
</Section> </Section>
) : null} ) : null}
<footer className="App-toolbar"> <footer className="App-toolbar">
{renderAppToolbar()} {renderAppToolbar()}
{appState.scrolledOutside && {appState.scrolledOutside && !appState.openMenu && (
!appState.openMenu && <button
!appState.isLibraryOpen && ( className="scroll-back-to-content"
<button onClick={() => {
className="scroll-back-to-content" setAppState({
onClick={() => { ...calculateScrollCenter(elements, appState, canvas),
setAppState({ });
...calculateScrollCenter(elements, appState, canvas), }}
}); >
}} {t("buttons.scrollBackToContent")}
> </button>
{t("buttons.scrollBackToContent")} )}
</button>
)}
</footer> </footer>
</Island> </Island>
</div> </div>

View File

@@ -8,7 +8,7 @@ import { useExcalidrawContainer, useDevice } from "./App";
import { AppState } from "../types"; import { AppState } from "../types";
import { THEME } from "../constants"; import { THEME } from "../constants";
export const Modal: React.FC<{ export const Modal = (props: {
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
maxWidth?: number; maxWidth?: number;
@@ -16,7 +16,7 @@ export const Modal: React.FC<{
labelledBy: string; labelledBy: string;
theme?: AppState["theme"]; theme?: AppState["theme"];
closeOnClickOutside?: boolean; closeOnClickOutside?: boolean;
}> = (props) => { }) => {
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props; const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
const modalRoot = useBodyRoot(theme); const modalRoot = useBodyRoot(theme);

View File

@@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
}, },
null, // files null, // files
); );
previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
if (props.selected) { if (props.selected) {
@@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
})(); })();
return () => { return () => {
previewNode.replaceChildren(); previewNode.removeChild(svg);
}; };
}, [props.spreadsheet, props.chartType, props.selected]); }, [props.spreadsheet, props.chartType, props.selected]);

View File

@@ -2,6 +2,5 @@
.popover { .popover {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
padding: 5px 0 5px;
} }
} }

View File

@@ -69,26 +69,12 @@ export const Popover = ({
if (fitInViewport && popoverRef.current) { if (fitInViewport && popoverRef.current) {
const element = popoverRef.current; const element = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect(); const { x, y, width, height } = element.getBoundingClientRect();
//Position correctly when clicked on rightmost part or the bottom part of viewport
if (x + width - offsetLeft > viewportWidth) { if (x + width - offsetLeft > viewportWidth) {
element.style.left = `${viewportWidth - width - 10}px`; element.style.left = `${viewportWidth - width}px`;
} }
if (y + height - offsetTop > viewportHeight) { if (y + height - offsetTop > viewportHeight) {
element.style.top = `${viewportHeight - height}px`; element.style.top = `${viewportHeight - height}px`;
} }
//Resize to fit viewport on smaller screens
if (height >= viewportHeight) {
element.style.height = `${viewportHeight - 20}px`;
element.style.top = "10px";
element.style.overflowY = "scroll";
}
if (width >= viewportWidth) {
element.style.width = `${viewportWidth}px`;
element.style.left = "0px";
element.style.overflowX = "scroll";
}
} }
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]); }, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);

View File

@@ -2,11 +2,12 @@ import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
export const Section: React.FC<{ interface SectionProps extends React.HTMLProps<HTMLElement> {
heading: string; heading: string;
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode); children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode);
className?: string; }
}> = ({ heading, children, ...props }) => {
export const Section = ({ heading, children, ...props }: SectionProps) => {
const { id } = useExcalidrawContainer(); const { id } = useExcalidrawContainer();
const header = ( const header = (
<h2 className="visually-hidden" id={`${id}-${heading}-title`}> <h2 className="visually-hidden" id={`${id}-${heading}-title`}>

View File

@@ -2,6 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons"; import { close } from "./icons";
@@ -15,10 +16,13 @@ export const Stats = (props: {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const device = useDevice();
const boundingBox = getCommonBounds(props.elements); const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState); const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements); const selectedBoundingBox = getCommonBounds(selectedElements);
if (device.isMobile && props.appState.openMenu) {
return null;
}
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>

View File

@@ -2,9 +2,6 @@
.excalidraw { .excalidraw {
.Toast { .Toast {
$closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem;
animation: fade-in 0.5s; animation: fade-in 0.5s;
background-color: var(--button-gray-1); background-color: var(--button-gray-1);
border-radius: 4px; border-radius: 4px;
@@ -18,24 +15,11 @@
text-align: center; text-align: center;
width: 300px; width: 300px;
z-index: 999999; z-index: 999999;
}
.Toast__message { .Toast__message {
padding: 0 $closeButtonSize + ($closeButtonPadding); color: var(--popup-text-color);
color: var(--popup-text-color); white-space: pre-wrap;
white-space: pre-wrap;
}
.close {
position: absolute;
top: 0;
right: 0;
padding: $closeButtonPadding;
.ToolIcon__icon {
width: $closeButtonSize;
height: $closeButtonSize;
}
}
} }
@keyframes fade-in { @keyframes fade-in {

View File

@@ -1,59 +1,34 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { close } from "./icons"; import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss"; import "./Toast.scss";
import { ToolButton } from "./ToolButton";
const DEFAULT_TOAST_TIMEOUT = 5000;
export const Toast = ({ export const Toast = ({
message, message,
onClose, clearToast,
closable = false,
// To prevent autoclose, pass duration as Infinity
duration = DEFAULT_TOAST_TIMEOUT,
}: { }: {
message: string; message: string;
onClose: () => void; clearToast: () => void;
closable?: boolean;
duration?: number;
}) => { }) => {
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const shouldAutoClose = duration !== Infinity;
const scheduleTimeout = useCallback(() => { const scheduleTimeout = useCallback(
if (!shouldAutoClose) { () =>
return; (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
} [clearToast],
timerRef.current = window.setTimeout(() => onClose(), duration); );
}, [onClose, duration, shouldAutoClose]);
useEffect(() => { useEffect(() => {
if (!shouldAutoClose) {
return;
}
scheduleTimeout(); scheduleTimeout();
return () => clearTimeout(timerRef.current); return () => clearTimeout(timerRef.current);
}, [scheduleTimeout, message, duration, shouldAutoClose]); }, [scheduleTimeout, message]);
const onMouseEnter = shouldAutoClose
? () => clearTimeout(timerRef?.current)
: undefined;
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
return ( return (
<div <div
className="Toast" className="Toast"
onMouseEnter={onMouseEnter} onMouseEnter={() => clearTimeout(timerRef?.current)}
onMouseLeave={onMouseLeave} onMouseLeave={scheduleTimeout}
> >
<p className="Toast__message">{message}</p> <p className="Toast__message">{message}</p>
{closable && (
<ToolButton
icon={close}
aria-label="close"
type="icon"
onClick={onClose}
className="close"
/>
)}
</div> </div>
); );
}; };

View File

@@ -187,5 +187,3 @@ ToolButton.defaultProps = {
className: "", className: "",
size: "medium", size: "medium",
}; };
ToolButton.displayName = "ToolButton";

View File

@@ -212,14 +212,16 @@
} }
} }
.ToolIcon.ToolIcon__library { .ToolIcon.ToolIcon__library {
top: calc(var(--sat) + 100px); top: 100px;
} }
.ToolIcon.ToolIcon__lock { .ToolIcon.ToolIcon__lock {
top: calc(var(--sat) + 60px); margin-inline-end: 0;
top: 60px;
} }
.ToolIcon.ToolIcon__penMode { .ToolIcon.ToolIcon__penMode {
top: calc(var(--sat) + 140px); margin-inline-end: 0;
top: 140px;
} }
} }

View File

@@ -32,6 +32,7 @@
} }
.ToolIcon.ToolIcon__lock { .ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating { &.ToolIcon_type_floating {
margin-left: 0.1rem; margin-left: 0.1rem;
} }

View File

@@ -116,6 +116,7 @@ export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;

View File

@@ -0,0 +1,42 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};

View File

@@ -1,5 +1,5 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForImageExport } from "../appState";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
@@ -143,7 +143,7 @@ export const loadSceneOrLibraryFromBlob = async (
appState: { appState: {
theme: localAppState?.theme, theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null, fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}), ...cleanAppStateForImageExport(data.appState || {}),
...(localAppState ...(localAppState
? calculateScrollCenter( ? calculateScrollCenter(
data.elements || [], data.elements || [],
@@ -356,7 +356,7 @@ export const getFileHandle = async (
}; };
/** /**
* attempts to detect if a buffer is a valid image by checking its leading bytes * attemps to detect if a buffer is a valid image by checking its leading bytes
*/ */
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null = let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
@@ -396,7 +396,7 @@ export const createFile = (
}); });
}; };
/** attempts to detect correct mimeType if none is set, or if an image /** attemps to detect correct mimeType if none is set, or if an image
* has an incorrect extension. * has an incorrect extension.
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
export const normalizeFile = async (file: File) => { export const normalizeFile = async (file: File) => {

View File

@@ -1,4 +1,5 @@
import { import {
FileWithHandle,
fileOpen as _fileOpen, fileOpen as _fileOpen,
fileSave as _fileSave, fileSave as _fileSave,
FileSystemHandle, FileSystemHandle,
@@ -25,9 +26,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
description: string; description: string;
multiple?: M; multiple?: M;
}): Promise<M extends false | undefined ? File : File[]> => { }): Promise<
M extends false | undefined ? FileWithHandle : FileWithHandle[]
> => {
// an unsafe TS hack, alas not much we can do AFAIK // an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined ? File : File[]; type RetType = M extends false | undefined
? FileWithHandle
: FileWithHandle[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]); mimeTypes.push(MIME_TYPES[type]);

View File

@@ -82,7 +82,7 @@ export const exportCanvas = async (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
).encodePngMetadata({ ).encodePngMetadata({
blob, blob,
metadata: serializeAsJSON(elements, appState, files, "local"), metadata: serializeAsJSON(elements, appState, files, "image"),
}); });
} }

View File

@@ -1,5 +1,9 @@
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import {
cleanAppStateForImageExport,
cleanAppStateForTextExport,
clearAppStateForDatabase,
} from "../appState";
import { import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
@@ -43,25 +47,32 @@ export const serializeAsJSON = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
type: "local" | "database", destination: "text" | "image" | "database",
): string => { ): string => {
const cleanAppState = () => {
switch (destination) {
case "database":
return clearAppStateForDatabase(appState);
case "text":
return cleanAppStateForTextExport(appState);
case "image":
return cleanAppStateForImageExport(appState);
}
};
const data: ExportedDataState = { const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw, type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw, version: VERSIONS.excalidraw,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
elements: elements:
type === "local" destination === "database"
? clearElementsForExport(elements) ? clearElementsForDatabase(elements)
: clearElementsForDatabase(elements), : clearElementsForExport(elements),
appState: appState: cleanAppState(),
type === "local"
? cleanAppStateForExport(appState)
: clearAppStateForDatabase(appState),
files: files:
type === "local" destination === "database"
? filterOutDeletedFiles(elements, files) ? // will be stripped from JSON
: // will be stripped from JSON undefined
undefined, : filterOutDeletedFiles(elements, files),
}; };
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
@@ -72,7 +83,7 @@ export const saveAsJSON = async (
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
const serialized = serializeAsJSON(elements, appState, files, "local"); const serialized = serializeAsJSON(elements, appState, files, "text");
const blob = new Blob([serialized], { const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
}); });
@@ -98,12 +109,7 @@ export const loadFromJSON = async (
// gets resolved. Else, iOS users cannot open `.excalidraw` files. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"], // extensions: ["json", "excalidraw", "png", "svg"],
}); });
return loadFromBlob( return loadFromBlob(await normalizeFile(file), localAppState, localElements);
await normalizeFile(file),
localAppState,
localElements,
file.handle,
);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {

View File

@@ -67,14 +67,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & { T extends ExcalidrawElement,
customData?: ExcalidrawElement["customData"]; K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
element: Required<T> & {
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
}, },
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
element: T,
extra: Pick< extra: Pick<
T, T,
// This extra Pick<T, keyof K> ensure no excess properties are passed. // This extra Pick<T, keyof K> ensure no excess properties are passed.
@@ -116,10 +115,6 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("customData" in element) {
base.customData = element.customData;
}
return { return {
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),

View File

@@ -5,7 +5,7 @@ import {
LibraryItems, LibraryItems,
LibraryItems_anyVersion, LibraryItems_anyVersion,
} from "../types"; } from "../types";
import type { cleanAppStateForExport } from "../appState"; import type { cleanAppStateForTextExport } from "../appState";
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
export interface ExportedDataState { export interface ExportedDataState {
@@ -13,7 +13,7 @@ export interface ExportedDataState {
version: number; version: number;
source: string; source: string;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>; appState: ReturnType<typeof cleanAppStateForTextExport>;
files: BinaryFiles | undefined; files: BinaryFiles | undefined;
} }

View File

@@ -32,7 +32,6 @@ import { getElementAbsoluteCoords } from "./";
import "./Hyperlink.scss"; import "./Hyperlink.scss";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useExcalidrawAppState } from "../components/App";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@@ -49,15 +48,15 @@ let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
export const Hyperlink = ({ export const Hyperlink = ({
element, element,
appState,
setAppState, setAppState,
onLinkOpen, onLinkOpen,
}: { }: {
element: NonDeletedExcalidrawElement; element: NonDeletedExcalidrawElement;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"]; onLinkOpen: ExcalidrawProps["onLinkOpen"];
}) => { }) => {
const appState = useExcalidrawAppState();
const linkVal = element.link || ""; const linkVal = element.link || "";
const [inputVal, setInputVal] = useState(linkVal); const [inputVal, setInputVal] = useState(linkVal);

View File

@@ -18,7 +18,6 @@ import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number]; export type Bounds = readonly [number, number, number, number];
type MaybeQuadraticSolution = [number | null, number | null] | false;
// If the element is created from right to left, the width is going to be negative // If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
@@ -69,102 +68,11 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
return shape.sets[0].ops; return shape.sets[0].ops;
}; };
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
const getBezierValueForT = (
t: number,
p0: number,
p1: number,
p2: number,
p3: number,
) => {
const oneMinusT = 1 - t;
return (
Math.pow(oneMinusT, 3) * p0 +
3 * Math.pow(oneMinusT, 2) * t * p1 +
3 * oneMinusT * Math.pow(t, 2) * p2 +
Math.pow(t, 3) * p3
);
};
const solveQuadratic = (
p0: number,
p1: number,
p2: number,
p3: number,
): MaybeQuadraticSolution => {
const i = p1 - p0;
const j = p2 - p1;
const k = p3 - p2;
const a = 3 * i - 6 * j + 3 * k;
const b = 6 * j - 6 * i;
const c = 3 * i;
const sqrtPart = b * b - 4 * a * c;
const hasSolution = sqrtPart >= 0;
if (!hasSolution) {
return false;
}
let s1 = null;
let s2 = null;
let t1 = Infinity;
let t2 = Infinity;
if (a === 0) {
t1 = t2 = -c / b;
} else {
t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
}
if (t1 >= 0 && t1 <= 1) {
s1 = getBezierValueForT(t1, p0, p1, p2, p3);
}
if (t2 >= 0 && t2 <= 1) {
s2 = getBezierValueForT(t2, p0, p1, p2, p3);
}
return [s1, s2];
};
const getCubicBezierCurveBound = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
let minX = Math.min(p0[0], p3[0]);
let maxX = Math.max(p0[0], p3[0]);
if (solX) {
const xs = solX.filter((x) => x !== null) as number[];
minX = Math.min(minX, ...xs);
maxX = Math.max(maxX, ...xs);
}
let minY = Math.min(p0[1], p3[1]);
let maxY = Math.max(p0[1], p3[1]);
if (solY) {
const ys = solY.filter((y) => y !== null) as number[];
minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys);
}
return [minX, minY, maxX, maxY];
};
const getMinMaxXYFromCurvePathOps = ( const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => { ): [number, number, number, number] => {
let currentP: Point = [0, 0]; let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => { (limits, { op, data }) => {
// There are only four operation types: // There are only four operation types:
@@ -175,29 +83,38 @@ const getMinMaxXYFromCurvePathOps = (
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
const _p1 = [data[0], data[1]] as Point; // create points from bezier curve
const _p2 = [data[2], data[3]] as Point; // bezier curve stores data as a flattened array of three positions
const _p3 = [data[4], data[5]] as Point; // [x1, y1, x2, y2, x3, y3]
const p1 = [data[0], data[1]] as Point;
const p2 = [data[2], data[3]] as Point;
const p3 = [data[4], data[5]] as Point;
const p1 = transformXY ? transformXY(..._p1) : _p1; const p0 = currentP;
const p2 = transformXY ? transformXY(..._p2) : _p2; currentP = p3;
const p3 = transformXY ? transformXY(..._p3) : _p3;
const p0 = transformXY ? transformXY(...currentP) : currentP; const equation = (t: number, idx: number) =>
currentP = _p3; Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound( let t = 0;
p0, while (t <= 1.0) {
p1, let x = equation(t, 0);
p2, let y = equation(t, 1);
p3, if (transformXY) {
); [x, y] = transformXY(x, y);
}
limits.minX = Math.min(limits.minX, minX); limits.minY = Math.min(limits.minY, y);
limits.minY = Math.min(limits.minY, minY); limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, maxX); limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, maxY); limits.maxY = Math.max(limits.maxY, y);
t += 0.1;
}
} else if (op === "lineTo") { } else if (op === "lineTo") {
// TODO: Implement this // TODO: Implement this
} else if (op === "qcurveTo") { } else if (op === "qcurveTo") {
@@ -207,6 +124,7 @@ const getMinMaxXYFromCurvePathOps = (
}, },
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
@@ -502,7 +420,6 @@ export const getResizedElementAbsoluteCoords = (
element: ExcalidrawElement, element: ExcalidrawElement,
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
normalizePoints: boolean,
): [number, number, number, number] => { ): [number, number, number, number] => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [ return [
@@ -516,8 +433,7 @@ export const getResizedElementAbsoluteCoords = (
const points = rescalePoints( const points = rescalePoints(
0, 0,
nextWidth, nextWidth,
rescalePoints(1, nextHeight, element.points, normalizePoints), rescalePoints(1, nextHeight, element.points),
normalizePoints,
); );
let bounds: [number, number, number, number]; let bounds: [number, number, number, number];

View File

@@ -35,7 +35,6 @@ import { getShapeForElement } from "../renderer/renderElement";
import { hasBoundTextElement, isImageElement } from "./typeChecks"; import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@@ -65,10 +64,7 @@ export const hitTest = (
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
const point: Point = [x, y]; const point: Point = [x, y];
if ( if (isElementSelected(appState, element)) {
isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState)
) {
return isPointHittingElementBoundingBox(element, point, threshold); return isPointHittingElementBoundingBox(element, point, threshold);
} }

View File

@@ -105,26 +105,15 @@ export const dragNewElement = (
true */ true */
widthAspectRatio?: number | null, widthAspectRatio?: number | null,
) => { ) => {
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") { if (shouldMaintainAspectRatio) {
if (widthAspectRatio) { if (widthAspectRatio) {
height = width / widthAspectRatio; height = width / widthAspectRatio;
} else { } else {
// Depending on where the cursor is at (x, y) relative to where the starting point is ({ width, height } = getPerfectElementSize(
// (originX, originY), we use ONLY width or height to control size increase. elementType,
// This allows the cursor to always "stick" to one of the sides of the bounding box. width,
if (Math.abs(y - originY) > Math.abs(x - originX)) { y < originY ? -height : height,
({ width, height } = getPerfectElementSize( ));
elementType,
height,
x < originX ? -width : width,
));
} else {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
}
if (height < 0) { if (height < 0) {
height = -height; height = -height;

View File

@@ -53,7 +53,6 @@ export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
getPerfectElementSize, getPerfectElementSize,
getLockedLinearCursorAlignSize,
isInvisiblySmallElement, isInvisiblySmallElement,
resizePerfectLineForNWHandler, resizePerfectLineForNWHandler,
getNormalizedDimensions, getNormalizedDimensions,

View File

@@ -5,20 +5,8 @@ import {
PointBinding, PointBinding,
ExcalidrawBindableElement, ExcalidrawBindableElement,
} from "./types"; } from "./types";
import { import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
distance2d, import { getElementAbsoluteCoords } from ".";
rotate,
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
getControlPointsForBezierCurve,
getBezierXY,
getBezierCurveLength,
mapIntervalToBezierT,
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import { getElementPointsCoords } from "./bounds"; import { getElementPointsCoords } from "./bounds";
import { Point, AppState } from "../types"; import { Point, AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -32,50 +20,28 @@ import {
} from "./binding"; } from "./binding";
import { tupleToCoors } from "../utils"; import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks"; import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
const visiblePointIndexesCache: {
points: number[];
zoom: number | null;
isEditingLinearElement: boolean;
} = { points: [], zoom: null, isEditingLinearElement: false };
export class LinearElementEditor { export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & { public elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
/** indices */ /** indices */
public readonly selectedPointsIndices: readonly number[] | null; public selectedPointsIndices: readonly number[] | null;
public readonly pointerDownState: Readonly<{ public pointerDownState: Readonly<{
prevSelectedPointsIndices: readonly number[] | null; prevSelectedPointsIndices: readonly number[] | null;
/** index */ /** index */
lastClickedPoint: number; lastClickedPoint: number;
}>; }>;
/** whether you're dragging a point */ /** whether you're dragging a point */
public readonly isDragging: boolean; public isDragging: boolean;
public readonly lastUncommittedPoint: Point | null; public lastUncommittedPoint: Point | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>; public pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement: public startBindingElement: ExcalidrawBindableElement | null | "keep";
| ExcalidrawBindableElement public endBindingElement: ExcalidrawBindableElement | null | "keep";
| null
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: Point | null;
constructor( constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
appState: AppState,
editingLinearElement = false,
) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
@@ -92,15 +58,14 @@ export class LinearElementEditor {
prevSelectedPointsIndices: null, prevSelectedPointsIndices: null,
lastClickedPoint: -1, lastClickedPoint: -1,
}; };
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// static methods // static methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 10; static POINT_HANDLE_SIZE = 20;
/** /**
* @param id the `elementId` from the instance of this class (so that we can * @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement) * statically guarantee this method returns an ExcalidrawLinearElement)
@@ -167,20 +132,22 @@ export class LinearElementEditor {
/** @returns whether point was dragged */ /** @returns whether point was dragged */
static handlePointDragging( static handlePointDragging(
event: PointerEvent,
appState: AppState, appState: AppState,
setState: React.Component<any, AppState>["setState"],
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: ( maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[], pointSceneCoords: { x: number; y: number }[],
) => void, ) => void,
linearElementEditor: LinearElementEditor,
): boolean { ): boolean {
if (!linearElementEditor) { if (!appState.editingLinearElement) {
return false; return false;
} }
const { selectedPointsIndices, elementId } = linearElementEditor; const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId, isDragging } =
editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return false; return false;
@@ -188,72 +155,55 @@ export class LinearElementEditor {
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[ const draggingPoint = element.points[
linearElementEditor.pointerDownState.lastClickedPoint editingLinearElement.pointerDownState.lastClickedPoint
] as [number, number] | undefined; ] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
if ( if (isDragging === false) {
shouldRotateWithDiscreteAngle(event) && setState({
selectedPointsIndices.length === 1 && editingLinearElement: {
element.points.length > 1 ...editingLinearElement,
) { isDragging: true,
const selectedIndex = selectedPointsIndices[0];
const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
referencePoint,
[scenePointerX, scenePointerY],
appState.gridSize,
);
LinearElementEditor.movePoints(element, [
{
index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]],
isDragging:
selectedIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
}, },
]); });
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
appState.gridSize,
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
pointIndex ===
linearElementEditor.pointerDownState.lastClickedPoint
? LinearElementEditor.createPointAt(
element,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
appState.gridSize,
)
: ([
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
return {
index: pointIndex,
point: newPointPosition,
isDragging:
pointIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
};
}),
);
} }
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.gridSize,
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
pointIndex ===
editingLinearElement.pointerDownState.lastClickedPoint
? LinearElementEditor.createPointAt(
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.gridSize,
)
: ([
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
return {
index: pointIndex,
point: newPointPosition,
isDragging:
pointIndex ===
editingLinearElement.pointerDownState.lastClickedPoint,
};
}),
);
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
const coords: { x: number; y: number }[] = []; const coords: { x: number; y: number }[] = [];
@@ -306,12 +256,10 @@ export class LinearElementEditor {
return editingLinearElement; return editingLinearElement;
} }
const bindings: Mutable< const bindings: Partial<
Partial< Pick<
Pick< InstanceType<typeof LinearElementEditor>,
InstanceType<typeof LinearElementEditor>, "startBindingElement" | "endBindingElement"
"startBindingElement" | "endBindingElement"
>
> >
> = {}; > = {};
@@ -379,310 +327,34 @@ export class LinearElementEditor {
}; };
} }
static getEditorMidPoints = (
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
): typeof editorMidPointsCache["points"] => {
// Since its not needed outside editor unless 2 pointer lines
if (!appState.editingLinearElement && element.points.length > 2) {
return [];
}
if (
editorMidPointsCache.version === element.version &&
editorMidPointsCache.zoom === appState.zoom.value
) {
return editorMidPointsCache.points;
}
LinearElementEditor.updateEditorMidPointsCache(element, appState);
return editorMidPointsCache.points!;
};
static updateEditorMidPointsCache = (
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
let index = 0;
const midpoints: (Point | null)[] = [];
while (index < points.length - 1) {
if (
LinearElementEditor.isSegmentTooShort(
element,
element.points[index],
element.points[index + 1],
appState.zoom,
)
) {
midpoints.push(null);
index++;
continue;
}
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
);
midpoints.push(segmentMidPoint);
index++;
}
editorMidPointsCache.points = midpoints;
editorMidPointsCache.version = element.version;
editorMidPointsCache.zoom = appState.zoom.value;
};
static getSegmentMidpointHitCoords = (
linearElementEditor: LinearElementEditor,
scenePointer: { x: number; y: number },
appState: AppState,
) => {
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return null;
}
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
appState.selectedLinearElement,
appState.zoom,
scenePointer.x,
scenePointer.y,
);
if (clickedPointIndex >= 0) {
return null;
}
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
if (points.length >= 3 && !appState.editingLinearElement) {
return null;
}
const threshold =
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
const distance = distance2d(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
scenePointer.x,
scenePointer.y,
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
}
}
let index = 0;
const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = distance2d(
midPoints[index]![0],
midPoints[index]![1],
scenePointer.x,
scenePointer.y,
);
if (distance <= threshold) {
return midPoints[index];
}
}
index++;
}
return null;
};
static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
zoom: AppState["zoom"],
) {
let distance = distance2d(
startPoint[0],
startPoint[1],
endPoint[0],
endPoint[1],
);
if (element.points.length > 2 && element.strokeSharpness === "round") {
distance = getBezierCurveLength(element, endPoint);
}
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
}
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
endPointIndex: number,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
if (element.points.length > 2 && element.strokeSharpness === "round") {
const controlPoints = getControlPointsForBezierCurve(
element,
element.points[endPointIndex],
);
if (controlPoints) {
const t = mapIntervalToBezierT(
element,
element.points[endPointIndex],
0.5,
);
const [tx, ty] = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
[tx, ty],
);
}
}
return segmentMidPoint;
}
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
appState: AppState,
midPoint: Point,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return -1;
}
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
let index = 0;
while (index < midPoints.length - 1) {
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
return index + 1;
}
index++;
}
return -1;
}
static getVisiblePointIndexes(
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
): typeof visiblePointIndexesCache["points"] {
const isEditingLinearElement = !!appState.editingLinearElement;
if (appState.editingLinearElement) {
// So that when we exit the editor the points are calculated again
visiblePointIndexesCache.isEditingLinearElement = true;
return element.points.map((_, index) => index);
}
if (
visiblePointIndexesCache.points &&
visiblePointIndexesCache.zoom === appState.zoom.value &&
isEditingLinearElement === visiblePointIndexesCache.isEditingLinearElement
) {
return visiblePointIndexesCache.points;
}
LinearElementEditor.updateVisiblePointIndexesCache(element, appState);
return visiblePointIndexesCache.points;
}
static updateVisiblePointIndexesCache(
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
) {
const visiblePointIndexes: number[] = [];
let previousPoint: Point | null = null;
element.points.forEach((point, index) => {
let distance = Infinity;
if (previousPoint) {
distance =
distance2d(point[0], point[1], previousPoint[0], previousPoint[1]) *
appState.zoom.value;
}
const isExtremePoint = index === 0 || index === element.points.length - 1;
const threshold = 2 * LinearElementEditor.POINT_HANDLE_SIZE;
if (isExtremePoint || distance >= threshold) {
// hide n-1 point if distance is less than threshold
if (isExtremePoint && distance < threshold) {
visiblePointIndexes.pop();
}
visiblePointIndexes.push(index);
previousPoint = point;
}
});
visiblePointIndexesCache.points = visiblePointIndexes;
visiblePointIndexesCache.zoom = appState.zoom.value;
visiblePointIndexesCache.isEditingLinearElement =
!!appState.editingLinearElement;
}
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
appState: AppState, appState: AppState,
setState: React.Component<any, AppState>["setState"],
history: History, history: History,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
): { ): {
didAddPoint: boolean; didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null; hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
isMidPoint: boolean;
} { } {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = { const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false, didAddPoint: false,
hitElement: null, hitElement: null,
linearElementEditor: null,
isMidPoint: false,
}; };
if (!linearElementEditor) { if (!appState.editingLinearElement) {
return ret; return ret;
} }
const { elementId } = linearElementEditor; const { elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return ret; return ret;
} }
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
linearElementEditor,
scenePointer,
appState,
);
if (segmentMidPoint) {
const index = LinearElementEditor.getSegmentMidPointIndex(
linearElementEditor,
appState,
segmentMidPoint,
);
const newMidPoint = LinearElementEditor.createPointAt(
element,
segmentMidPoint[0],
segmentMidPoint[1],
appState.gridSize,
);
const points = [
...element.points.slice(0, index),
newMidPoint,
...element.points.slice(index),
];
mutateElement(element, {
points,
});
ret.didAddPoint = true; if (event.altKey) {
ret.isMidPoint = true; if (appState.editingLinearElement.lastUncommittedPoint == null) {
}
if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
@@ -694,29 +366,30 @@ export class LinearElementEditor {
), ),
], ],
}); });
ret.didAddPoint = true;
} }
history.resumeRecording(); history.resumeRecording();
ret.linearElementEditor = { setState({
...linearElementEditor, editingLinearElement: {
pointerDownState: { ...appState.editingLinearElement,
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, pointerDownState: {
lastClickedPoint: -1, prevSelectedPointsIndices:
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: -1,
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
Scene.getScene(element)!,
),
}, },
selectedPointsIndices: [element.points.length - 1], });
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
Scene.getScene(element)!,
),
};
ret.didAddPoint = true; ret.didAddPoint = true;
return ret; return ret;
} }
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
appState.selectedLinearElement, element,
appState.zoom, appState.zoom,
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
@@ -724,7 +397,7 @@ export class LinearElementEditor {
// if we clicked on a point, set the element as hitElement otherwise // if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area // it would get deselected if the point is outside the hitbox area
if (clickedPointIndex >= 0 || segmentMidPoint) { if (clickedPointIndex > -1) {
ret.hitElement = element; ret.hitElement = element;
} else { } else {
// You might be wandering why we are storing the binding elements on // You might be wandering why we are storing the binding elements on
@@ -732,7 +405,8 @@ export class LinearElementEditor {
// from the end points of the `linearElement` - this is to allow disabling // from the end points of the `linearElement` - this is to allow disabling
// binding (which needs to happen at the point the user finishes moving // binding (which needs to happen at the point the user finishes moving
// the point). // the point).
const { startBindingElement, endBindingElement } = linearElementEditor; const { startBindingElement, endBindingElement } =
appState.editingLinearElement;
if (isBindingEnabled(appState) && isBindingElement(element)) { if (isBindingEnabled(appState) && isBindingElement(element)) {
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
element, element,
@@ -758,58 +432,47 @@ export class LinearElementEditor {
const nextSelectedPointsIndices = const nextSelectedPointsIndices =
clickedPointIndex > -1 || event.shiftKey clickedPointIndex > -1 || event.shiftKey
? event.shiftKey || ? event.shiftKey ||
linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex) appState.editingLinearElement.selectedPointsIndices?.includes(
clickedPointIndex,
)
? normalizeSelectedPoints([ ? normalizeSelectedPoints([
...(linearElementEditor.selectedPointsIndices || []), ...(appState.editingLinearElement.selectedPointsIndices || []),
clickedPointIndex, clickedPointIndex,
]) ])
: [clickedPointIndex] : [clickedPointIndex]
: null; : null;
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
? {
x: scenePointer.x - targetPoint[0],
y: scenePointer.y - targetPoint[1],
}
: { x: 0, y: 0 },
};
if (ret.didAddPoint) {
ret.linearElementEditor = {
...ret.linearElementEditor,
};
}
return ret;
}
static arePointsEqual(point1: Point | null, point2: Point | null) { setState({
if (!point1 && !point2) { editingLinearElement: {
return true; ...appState.editingLinearElement,
} pointerDownState: {
if (!point1 || !point2) { prevSelectedPointsIndices:
return false; appState.editingLinearElement.selectedPointsIndices,
} lastClickedPoint: clickedPointIndex,
return arePointsEqual(point1, point2); },
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
? {
x: scenePointer.x - targetPoint[0],
y: scenePointer.y - targetPoint[1],
}
: { x: 0, y: 0 },
},
});
return ret;
} }
static handlePointerMove( static handlePointerMove(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
appState: AppState, editingLinearElement: LinearElementEditor,
): LinearElementEditor | null { gridSize: number | null,
if (!appState.editingLinearElement) { ): LinearElementEditor {
return null; const { elementId, lastUncommittedPoint } = editingLinearElement;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return appState.editingLinearElement; return editingLinearElement;
} }
const { points } = element; const { points } = element;
@@ -819,36 +482,15 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]); LinearElementEditor.deletePoints(element, [points.length - 1]);
} }
return { return { ...editingLinearElement, lastUncommittedPoint: null };
...appState.editingLinearElement,
lastUncommittedPoint: null,
};
} }
let newPoint: Point; const newPoint = LinearElementEditor.createPointAt(
element,
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { scenePointerX - editingLinearElement.pointerOffset.x,
const lastCommittedPoint = points[points.length - 2]; scenePointerY - editingLinearElement.pointerOffset.y,
gridSize,
const [width, height] = LinearElementEditor._getShiftLockedDelta( );
element,
lastCommittedPoint,
[scenePointerX, scenePointerY],
appState.gridSize,
);
newPoint = [
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
];
} else {
newPoint = LinearElementEditor.createPointAt(
element,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
appState.gridSize,
);
}
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(element, [
@@ -858,10 +500,11 @@ export class LinearElementEditor {
}, },
]); ]);
} else { } else {
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]); LinearElementEditor.addPoints(element, [{ point: newPoint }]);
} }
return { return {
...appState.editingLinearElement, ...editingLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1], lastUncommittedPoint: element.points[element.points.length - 1],
}; };
} }
@@ -883,14 +526,14 @@ export class LinearElementEditor {
/** scene coords */ /** scene coords */
static getPointsGlobalCoordinates( static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
): Point[] { ) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
return element.points.map((point) => { return element.points.map((point) => {
let { x, y } = element; let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const; return [x, y];
}); });
} }
@@ -908,9 +551,7 @@ export class LinearElementEditor {
const point = element.points[index]; const point = element.points[index];
const { x, y } = element; const { x, y } = element;
return point return rotate(x + point[0], y + point[1], cx, cy, element.angle);
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
: rotate(x, y, cx, cy, element.angle);
} }
static pointFromAbsoluteCoords( static pointFromAbsoluteCoords(
@@ -931,37 +572,24 @@ export class LinearElementEditor {
} }
static getPointIndexUnderCursor( static getPointIndexUnderCursor(
linearElementEditor: LinearElementEditor | null, element: NonDeleted<ExcalidrawLinearElement>,
zoom: AppState["zoom"], zoom: AppState["zoom"],
x: number, x: number,
y: number, y: number,
) { ) {
if (!linearElementEditor) { const pointHandles = this.getPointsGlobalCoordinates(element);
return -1; let idx = pointHandles.length;
}
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return -1;
}
const pointHandles =
LinearElementEditor.getPointsGlobalCoordinates(element);
let counter = visiblePointIndexesCache.points.length;
// loop from right to left because points on the right are rendered over // loop from right to left because points on the right are rendered over
// points on the left, thus should take precedence when clicking, if they // points on the left, thus should take precedence when clicking, if they
// overlap // overlap
while (--counter >= 0) { while (--idx > -1) {
const index = visiblePointIndexesCache.points[counter]; const point = pointHandles[idx];
const point = pointHandles[index];
if ( if (
distance2d(x, y, point[0], point[1]) * zoom.value < distance2d(x, y, point[0], point[1]) * zoom.value <
// +1px to account for outline stroke // +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1 this.POINT_HANDLE_SIZE / 2 + 1
) { ) {
return index; return idx;
} }
} }
return -1; return -1;
@@ -1118,7 +746,6 @@ export class LinearElementEditor {
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
targetPoints: { point: Point }[], targetPoints: { point: Point }[],
) { ) {
const offsetX = 0; const offsetX = 0;
@@ -1148,9 +775,9 @@ export class LinearElementEditor {
if (selectedOriginPoint) { if (selectedOriginPoint) {
offsetX = offsetX =
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0]; selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
offsetY = offsetY =
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
} }
const nextPoints = points.map((point, idx) => { const nextPoints = points.map((point, idx) => {
@@ -1213,33 +840,6 @@ export class LinearElementEditor {
y: element.y + rotated[1], y: element.y + rotated[1],
}); });
} }
private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>,
referencePoint: Point,
scenePointer: Point,
gridSize: number | null,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element,
referencePoint,
);
const [gridX, gridY] = getGridPoint(
scenePointer[0],
scenePointer[1],
gridSize,
);
const { width, height } = getLockedLinearCursorAlignSize(
referencePointCoords[0],
referencePointCoords[1],
gridX,
gridY,
);
return rotatePoint([width, height], [0, 0], -element.angle);
}
} }
const normalizeSelectedPoints = ( const normalizeSelectedPoints = (

View File

@@ -198,7 +198,6 @@ const getAdjustedDimensions = (
element, element,
nextWidth, nextWidth,
nextHeight, nextHeight,
false,
); );
const deltaX1 = (x1 - nextX1) / 2; const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2; const deltaY1 = (y1 - nextY1) / 2;

View File

@@ -18,7 +18,6 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
getCommonBoundingBox,
} from "./bounds"; } from "./bounds";
import { import {
isFreeDrawElement, isFreeDrawElement,
@@ -138,10 +137,8 @@ export const transformElements = (
transformHandleType === "se" transformHandleType === "se"
) { ) {
resizeMultipleElements( resizeMultipleElements(
pointerDownState,
selectedElements, selectedElements,
transformHandleType, transformHandleType,
shouldResizeFromCenter,
pointerX, pointerX,
pointerY, pointerY,
); );
@@ -264,15 +261,13 @@ const rescalePointsInElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
width: number, width: number,
height: number, height: number,
normalizePoints: boolean,
) => ) =>
isLinearElement(element) || isFreeDrawElement(element) isLinearElement(element) || isFreeDrawElement(element)
? { ? {
points: rescalePoints( points: rescalePoints(
0, 0,
width, width,
rescalePoints(1, height, element.points, normalizePoints), rescalePoints(1, height, element.points),
normalizePoints,
), ),
} }
: {}; : {};
@@ -376,7 +371,6 @@ const resizeSingleTextElement = (
element, element,
nextWidth, nextWidth,
nextHeight, nextHeight,
false,
); );
const deltaX1 = (x1 - nextX1) / 2; const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2; const deltaY1 = (y1 - nextY1) / 2;
@@ -418,7 +412,6 @@ export const resizeSingleElement = (
stateAtResizeStart, stateAtResizeStart,
stateAtResizeStart.width, stateAtResizeStart.width,
stateAtResizeStart.height, stateAtResizeStart.height,
true,
); );
const startTopLeft: Point = [x1, y1]; const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2]; const startBottomRight: Point = [x2, y2];
@@ -436,7 +429,6 @@ export const resizeSingleElement = (
element, element,
element.width, element.width,
element.height, element.height,
true,
); );
const boundsCurrentWidth = esx2 - esx1; const boundsCurrentWidth = esx2 - esx1;
@@ -530,7 +522,6 @@ export const resizeSingleElement = (
stateAtResizeStart, stateAtResizeStart,
eleNewWidth, eleNewWidth,
eleNewHeight, eleNewHeight,
true,
); );
const newBoundsWidth = newBoundsX2 - newBoundsX1; const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1; const newBoundsHeight = newBoundsY2 - newBoundsY1;
@@ -601,7 +592,6 @@ export const resizeSingleElement = (
stateAtResizeStart, stateAtResizeStart,
eleNewWidth, eleNewWidth,
eleNewHeight, eleNewHeight,
true,
); );
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be // So we need to readjust (x,y) to be where the first point should be
@@ -647,147 +637,146 @@ export const resizeSingleElement = (
}; };
const resizeMultipleElements = ( const resizeMultipleElements = (
pointerDownState: PointerDownState, elements: readonly NonDeletedExcalidrawElement[],
selectedElements: readonly NonDeletedExcalidrawElement[],
transformHandleType: "nw" | "ne" | "sw" | "se", transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
) => { ) => {
// map selected elements to the original elements. While it never should const [x1, y1, x2, y2] = getCommonBounds(elements);
// happen that pointerDownState.originalElements won't contain the selected let scale: number;
// elements during resize, this coupling isn't guaranteed, so to ensure let getNextXY: (
// type safety we need to transform only those elements we filter. element: NonDeletedExcalidrawElement,
const targetElements = selectedElements.reduce( origCoords: readonly [number, number, number, number],
( finalCoords: readonly [number, number, number, number],
acc: { ) => { x: number; y: number };
/** element at resize start */ switch (transformHandleType) {
orig: NonDeletedExcalidrawElement; case "se":
/** latest element */ scale = Math.max(
latest: NonDeletedExcalidrawElement; (pointerX - x1) / (x2 - x1),
}[], (pointerY - y1) / (y2 - y1),
element,
) => {
const origElement = pointerDownState.originalElements.get(element.id);
if (origElement) {
acc.push({ orig: origElement, latest: element });
}
return acc;
},
[],
);
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
targetElements.map(({ orig }) => orig),
);
const direction = transformHandleType;
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
ne: [minX, maxY],
se: [minX, minY],
sw: [maxX, minY],
nw: [maxX, maxY],
};
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if alt is pressed
const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY]
: mapDirectionsToAnchors[direction];
const mapDirectionsToPointerSides: Record<
typeof direction,
[x: boolean, y: boolean]
> = {
ne: [pointerX >= anchorX, pointerY <= anchorY],
se: [pointerX >= anchorX, pointerY >= anchorY],
sw: [pointerX <= anchorX, pointerY >= anchorY],
nw: [pointerX <= anchorX, pointerY <= anchorY],
};
// pointer side relative to anchor
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
direction
].map((condition) => (condition ? 1 : -1));
// stop resizing if a pointer is on the other side of selection
if (pointerSideX < 0 && pointerSideY < 0) {
return;
}
const scale =
Math.max(
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
) * (shouldResizeFromCenter ? 2 : 1);
if (scale === 1) {
return;
}
targetElements.forEach((element) => {
const width = element.orig.width * scale;
const height = element.orig.height * scale;
const x = anchorX + (element.orig.x - anchorX) * scale;
const y = anchorY + (element.orig.y - anchorY) * scale;
// readjust points for linear & free draw elements
const rescaledPoints = rescalePointsInElement(
element.orig,
width,
height,
false,
);
const update: {
width: number;
height: number;
x: number;
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
x,
y,
...rescaledPoints,
};
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
height - optionalPadding,
); );
if (textMeasurements) { getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
if (isTextElement(element.orig)) { const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
update.fontSize = textMeasurements.size; const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
update.baseline = textMeasurements.baseline; return { x, y };
};
break;
case "nw":
scale = Math.max(
(x2 - pointerX) / (x2 - x1),
(y2 - pointerY) / (y2 - y1),
);
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
return { x, y };
};
break;
case "ne":
scale = Math.max(
(pointerX - x1) / (x2 - x1),
(y2 - pointerY) / (y2 - y1),
);
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
return { x, y };
};
break;
case "sw":
scale = Math.max(
(x2 - pointerX) / (x2 - x1),
(pointerY - y1) / (y2 - y1),
);
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
return { x, y };
};
break;
}
if (scale > 0) {
const updates = elements.reduce(
(prev, element) => {
if (!prev) {
return prev;
} }
const width = element.width * scale;
const height = element.height * scale;
const boundTextElement = getBoundTextElement(element);
let font: { fontSize?: number; baseline?: number } = {};
if (boundTextElement) { if (boundTextElement) {
boundTextUpdates = { const nextFont = measureFontSizeFromWH(
fontSize: textMeasurements.size, boundTextElement,
baseline: textMeasurements.baseline, width - BOUND_TEXT_PADDING * 2,
height - BOUND_TEXT_PADDING * 2,
);
if (nextFont === null) {
return null;
}
font = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
}; };
} }
}
}
mutateElement(element.latest, update); if (isTextElement(element)) {
const nextFont = measureFontSizeFromWH(element, width, height);
if (nextFont === null) {
return null;
}
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
}
const origCoords = getElementAbsoluteCoords(element);
if (boundTextElement && boundTextUpdates) { const rescaledPoints = rescalePointsInElement(element, width, height);
mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType); updateBoundElements(element, {
newSize: { width, height },
simultaneouslyUpdated: elements,
});
const finalCoords = getResizedElementAbsoluteCoords(
{
...element,
...rescaledPoints,
},
width,
height,
);
const { x, y } = getNextXY(element, origCoords, finalCoords);
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
},
[] as
| {
width: number;
height: number;
x: number;
y: number;
points?: (readonly [number, number])[];
fontSize?: number;
baseline?: number;
}[]
| null,
);
if (updates) {
elements.forEach((element, index) => {
mutateElement(element, updates[index]);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
mutateElement(boundTextElement, {
fontSize: updates[index].fontSize,
baseline: updates[index].baseline,
});
handleBindTextResize(element, transformHandleType);
}
});
} }
}); }
}; };
const rotateMultipleElements = ( const rotateMultipleElements = (

View File

@@ -1,51 +1,49 @@
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import * as constants from "../constants"; import * as constants from "../constants";
const EPSILON_DIGITS = 3;
describe("getPerfectElementSize", () => { describe("getPerfectElementSize", () => {
it("should return height:0 if `elementType` is line and locked angle is 0", () => { it("should return height:0 if `elementType` is line and locked angle is 0", () => {
const { height, width } = getPerfectElementSize("line", 149, 10); const { height, width } = getPerfectElementSize("line", 149, 10);
expect(width).toBeCloseTo(149, EPSILON_DIGITS); expect(width).toEqual(149);
expect(height).toBeCloseTo(0, EPSILON_DIGITS); expect(height).toEqual(0);
}); });
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => { it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
const { height, width } = getPerfectElementSize("line", 10, 140); const { height, width } = getPerfectElementSize("line", 10, 140);
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toEqual(0);
expect(height).toBeCloseTo(140, EPSILON_DIGITS); expect(height).toEqual(140);
}); });
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => { it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
const { height, width } = getPerfectElementSize("arrow", 200, 20); const { height, width } = getPerfectElementSize("arrow", 200, 20);
expect(width).toBeCloseTo(200, EPSILON_DIGITS); expect(width).toEqual(200);
expect(height).toBeCloseTo(0, EPSILON_DIGITS); expect(height).toEqual(0);
}); });
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => { it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
const { height, width } = getPerfectElementSize("arrow", 10, 100); const { height, width } = getPerfectElementSize("arrow", 10, 100);
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toEqual(0);
expect(height).toBeCloseTo(100, EPSILON_DIGITS); expect(height).toEqual(100);
}); });
it("should return adjust height to be width * tan(locked angle)", () => { it("should return adjust height to be width * tan(locked angle)", () => {
const { height, width } = getPerfectElementSize("arrow", 120, 185); const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS); expect(width).toEqual(120);
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS); expect(height).toEqual(208);
}); });
it("should return height equals to width if locked angle is 45 deg", () => { it("should return height equals to width if locked angle is 45 deg", () => {
const { height, width } = getPerfectElementSize("arrow", 135, 145); const { height, width } = getPerfectElementSize("arrow", 135, 145);
expect(width).toBeCloseTo(135, EPSILON_DIGITS); expect(width).toEqual(135);
expect(height).toBeCloseTo(135, EPSILON_DIGITS); expect(height).toEqual(135);
}); });
it("should return height:0 and width:0 when width and height are 0", () => { it("should return height:0 and width:0 when width and height are 0", () => {
const { height, width } = getPerfectElementSize("arrow", 0, 0); const { height, width } = getPerfectElementSize("arrow", 0, 0);
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toEqual(0);
expect(height).toBeCloseTo(0, EPSILON_DIGITS); expect(height).toEqual(0);
}); });
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => { describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => { it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4; (constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185); const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS); expect(width).toEqual(120);
expect(height).toBeCloseTo(120, EPSILON_DIGITS); expect(height).toEqual(120);
}); });
}); });
}); });

View File

@@ -37,7 +37,9 @@ export const getPerfectElementSize = (
} else if (lockedAngle === Math.PI / 2) { } else if (lockedAngle === Math.PI / 2) {
width = 0; width = 0;
} else { } else {
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height; height =
Math.round(absWidth * Math.tan(lockedAngle)) * Math.sign(height) ||
height;
} }
} else if (elementType !== "selection") { } else if (elementType !== "selection") {
height = absWidth * Math.sign(height); height = absWidth * Math.sign(height);
@@ -45,46 +47,6 @@ export const getPerfectElementSize = (
return { width, height }; return { width, height };
}; };
export const getLockedLinearCursorAlignSize = (
originX: number,
originY: number,
x: number,
y: number,
) => {
let width = x - originX;
let height = y - originY;
const lockedAngle =
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE;
if (lockedAngle === 0) {
height = 0;
} else if (lockedAngle === Math.PI / 2) {
width = 0;
} else {
// locked angle line, y = mx + b => mx - y + b = 0
const a1 = Math.tan(lockedAngle);
const b1 = -1;
const c1 = originY - a1 * originX;
// line through cursor, perpendicular to locked angle line
const a2 = -1 / a1;
const b2 = -1;
const c2 = y - a2 * x;
// intersection of the two lines above
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
// delta
width = intersectX - originX;
height = intersectY - originY;
}
return { width, height };
};
export const resizePerfectLineForNWHandler = ( export const resizePerfectLineForNWHandler = (
element: ExcalidrawElement, element: ExcalidrawElement,
x: number, x: number,

View File

@@ -1,15 +1,9 @@
import { import { ExcalidrawElement, PointerType } from "./types";
ExcalidrawElement,
NonDeletedExcalidrawElement,
PointerType,
} from "./types";
import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { AppState, Zoom } from "../types"; import { Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
| "n" | "n"
@@ -65,6 +59,8 @@ const OMIT_SIDES_FOR_LINE_BACKSLASH = {
s: true, s: true,
n: true, n: true,
w: true, w: true,
ne: true,
sw: true,
}; };
const generateTransformHandle = ( const generateTransformHandle = (
@@ -86,7 +82,6 @@ export const getTransformHandlesFromCoords = (
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {}, omitSides: { [T in TransformHandleType]?: boolean } = {},
margin = 4,
): TransformHandles => { ): TransformHandles => {
const size = transformHandleSizes[pointerType]; const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom.value; const handleWidth = size / zoom.value;
@@ -99,7 +94,9 @@ export const getTransformHandlesFromCoords = (
const height = y2 - y1; const height = y2 - y1;
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value;
const dashedLineMargin = 4 / zoom.value;
const centeringOffset = (size - 8) / (2 * zoom.value); const centeringOffset = (size - 8) / (2 * zoom.value);
const transformHandles: TransformHandles = { const transformHandles: TransformHandles = {
@@ -233,7 +230,11 @@ export const getTransformHandles = (
} }
let omitSides: { [T in TransformHandleType]?: boolean } = {}; let omitSides: { [T in TransformHandleType]?: boolean } = {};
if (element.type === "freedraw" || isLinearElement(element)) { if (
element.type === "arrow" ||
element.type === "line" ||
element.type === "freedraw"
) {
if (element.points.length === 2) { if (element.points.length === 2) {
// only check the last point because starting point is always (0,0) // only check the last point because starting point is always (0,0)
const [, p1] = element.points; const [, p1] = element.points;
@@ -252,33 +253,12 @@ export const getTransformHandles = (
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} }
const dashedLineMargin = isLinearElement(element)
? DEFAULT_SPACING * 3
: DEFAULT_SPACING;
return getTransformHandlesFromCoords( return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element), getElementAbsoluteCoords(element),
element.angle, element.angle,
zoom, zoom,
pointerType, pointerType,
omitSides, omitSides,
dashedLineMargin,
); );
}; };
export const shouldShowBoundingBox = (
elements: NonDeletedExcalidrawElement[],
appState: AppState,
) => {
if (appState.editingLinearElement) {
return false;
}
if (elements.length > 1) {
return true;
}
const element = elements[0];
if (!isLinearElement(element)) {
return true;
}
return element.points.length > 2;
};

View File

@@ -56,7 +56,6 @@ type _ExcalidrawElementBase = Readonly<{
updated: number; updated: number;
link: string | null; link: string | null;
locked: boolean; locked: boolean;
customData?: Record<string, any>;
}>; }>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {

View File

@@ -7,8 +7,6 @@ import {
import { DEFAULT_VERSION } from "../constants"; import { DEFAULT_VERSION } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../clipboard";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
const STORAGE_SIZE_TIMEOUT = 500; const STORAGE_SIZE_TIMEOUT = 500;
@@ -21,9 +19,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
}, STORAGE_SIZE_TIMEOUT); }, STORAGE_SIZE_TIMEOUT);
type Props = { type Props = {
setToast: (message: string) => void; setToastMessage: (message: string) => void;
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
}; };
const CustomStats = (props: Props) => { const CustomStats = (props: Props) => {
const [storageSizes, setStorageSizes] = useState<StorageSizes>({ const [storageSizes, setStorageSizes] = useState<StorageSizes>({
@@ -35,7 +31,7 @@ const CustomStats = (props: Props) => {
getStorageSizes((sizes) => { getStorageSizes((sizes) => {
setStorageSizes(sizes); setStorageSizes(sizes);
}); });
}, [props.elements, props.appState]); });
useEffect(() => () => getStorageSizes.cancel(), []); useEffect(() => () => getStorageSizes.cancel(), []);
const version = getVersion(); const version = getVersion();
@@ -72,7 +68,7 @@ const CustomStats = (props: Props) => {
onClick={async () => { onClick={async () => {
try { try {
await copyTextToSystemClipboard(getVersion()); await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard")); props.setToastMessage(t("toast.copyToClipboard"));
} catch {} } catch {}
}} }}
title={t("stats.versionCopy")} title={t("stats.versionCopy")}

View File

@@ -33,6 +33,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_LIBRARY: "excalidraw-library",
VERSION_DATA_STATE: "version-dataState", VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files", VERSION_FILES: "version-files",

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