mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
1 Commits
v0.13.0
...
export-com
Author | SHA1 | Date | |
---|---|---|---|
f5c44e1f0b |
@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
15
.github/workflows/publish-docker.yml
vendored
15
.github/workflows/publish-docker.yml
vendored
@ -10,16 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/build-push-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
repository: excalidraw/excalidraw
|
||||
tag_with_ref: true
|
||||
tag_with_sha: true
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -19,9 +19,11 @@ logs
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
package-lock.json
|
||||
static
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src/packages/excalidraw/types
|
||||
src/packages/excalidraw/example/public/bundle.js
|
||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||
|
||||
|
@ -88,7 +88,7 @@ Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/exc
|
||||
|
||||
### 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 can start coding instantly, and even send PRs from there!
|
||||
|
||||
|
20
dev-docs/.gitignore
vendored
20
dev-docs/.gitignore
vendored
@ -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*
|
@ -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.
|
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
@ -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).
|
@ -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).
|
@ -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).
|
@ -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;
|
@ -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"
|
||||
}
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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'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>
|
||||
);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 |
@ -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 |
@ -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": "."
|
||||
}
|
||||
}
|
7489
dev-docs/yarn.lock
7489
dev-docs/yarn.lock
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -26,8 +26,8 @@
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "17.0.39",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"clsx": "1.1.1",
|
||||
@ -41,15 +41,14 @@
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.2.0",
|
||||
"pica": "7.1.1",
|
||||
"perfect-freehand": "1.0.16",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
|
@ -52,25 +52,6 @@
|
||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
<script>
|
||||
try {
|
||||
//
|
||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<style>
|
||||
html.dark {
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@ -117,7 +98,7 @@
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
@ -174,7 +155,7 @@
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
});
|
@ -15,7 +15,6 @@ const crowdinMap = {
|
||||
"fa-IR": "en-fa",
|
||||
"fi-FI": "en-fi",
|
||||
"fr-FR": "en-fr",
|
||||
"gl-ES": "en-gl",
|
||||
"he-IL": "en-he",
|
||||
"hi-IN": "en-hi",
|
||||
"hu-HU": "en-hu",
|
||||
@ -24,7 +23,6 @@ const crowdinMap = {
|
||||
"ja-JP": "en-ja",
|
||||
"kab-KAB": "en-kab",
|
||||
"ko-KR": "en-ko",
|
||||
"ku-TR": "en-ku",
|
||||
"my-MM": "en-my",
|
||||
"nb-NO": "en-nb",
|
||||
"nl-NL": "en-nl",
|
||||
@ -67,7 +65,6 @@ const flags = {
|
||||
"fa-IR": "🇮🇷",
|
||||
"fi-FI": "🇫🇮",
|
||||
"fr-FR": "🇫🇷",
|
||||
"gl-ES": "🇪🇸",
|
||||
"he-IL": "🇮🇱",
|
||||
"hi-IN": "🇮🇳",
|
||||
"hu-HU": "🇭🇺",
|
||||
@ -77,7 +74,6 @@ const flags = {
|
||||
"kab-KAB": "🏳",
|
||||
"kk-KZ": "🇰🇿",
|
||||
"ko-KR": "🇰🇷",
|
||||
"ku-TR": "🏳",
|
||||
"lt-LT": "🇱🇹",
|
||||
"lv-LV": "🇱🇻",
|
||||
"my-MM": "🇲🇲",
|
||||
|
@ -1,12 +1,11 @@
|
||||
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");
|
||||
const oldPath = "./public/service-worker.js";
|
||||
const newPath = "./src/service-worker.js";
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
|
@ -36,7 +36,7 @@ export const actionCut = register({
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
|
@ -244,7 +244,7 @@ export const actionLoadScene = register({
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={load}
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
@ -181,11 +181,6 @@ export const actionFinalize = register({
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
|
@ -6,14 +6,10 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getElementPointsCoords,
|
||||
} from "../element/bounds";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -122,6 +118,13 @@ const flipElement = (
|
||||
const height = element.height;
|
||||
const originalAngle = normalizeAngle(element.angle);
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
}
|
||||
|
||||
// Rotate back to zero, if necessary
|
||||
mutateElement(element, {
|
||||
angle: normalizeAngle(0),
|
||||
@ -129,6 +132,7 @@ const flipElement = (
|
||||
// Flip unrotated by pulling TransformHandle to opposite side
|
||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
||||
let usingNWHandle = true;
|
||||
let newNCoordsX = 0;
|
||||
let nHandle = transformHandles.nw;
|
||||
if (!nHandle) {
|
||||
// Use ne handle instead
|
||||
@ -142,51 +146,30 @@ const flipElement = (
|
||||
}
|
||||
}
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element) && element.points.length < 3) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
}
|
||||
|
||||
let initialPointsCoords;
|
||||
if (isLinearElement(element)) {
|
||||
initialPointsCoords = getElementPointsCoords(
|
||||
element,
|
||||
element.points,
|
||||
element.strokeSharpness,
|
||||
);
|
||||
}
|
||||
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
|
||||
|
||||
if (isLinearElement(element) && element.points.length < 3) {
|
||||
for (let index = 1; index < element.points.length; index++) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index,
|
||||
point: [-element.points[index][0], element.points[index][1]],
|
||||
},
|
||||
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||
]);
|
||||
}
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
} else {
|
||||
const elWidth = initialPointsCoords
|
||||
? initialPointsCoords[2] - initialPointsCoords[0]
|
||||
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
|
||||
|
||||
const startPoint = initialPointsCoords
|
||||
? [initialPointsCoords[0], initialPointsCoords[1]]
|
||||
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
|
||||
|
||||
// calculate new x-coord for transformation
|
||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||
resizeSingleElement(
|
||||
new Map().set(element.id, element),
|
||||
false,
|
||||
true,
|
||||
element,
|
||||
usingNWHandle ? "nw" : "ne",
|
||||
true,
|
||||
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
|
||||
startPoint[1],
|
||||
false,
|
||||
newNCoordsX,
|
||||
nHandle[1],
|
||||
);
|
||||
// fix the size to account for handle sizes
|
||||
mutateElement(element, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
// Rotate by (360 degrees - original angle)
|
||||
@ -203,34 +186,9 @@ const flipElement = (
|
||||
mutateElement(element, {
|
||||
x: originalX + finalOffsetX,
|
||||
y: originalY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
updateBoundElements(element);
|
||||
|
||||
if (initialPointsCoords && isLinearElement(element)) {
|
||||
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
|
||||
// There's still room for improvement since when the line roughness is > 1
|
||||
// we still have a small offset of the origin when fliipping the element.
|
||||
const finalPointsCoords = getElementPointsCoords(
|
||||
element,
|
||||
element.points,
|
||||
element.strokeSharpness,
|
||||
);
|
||||
|
||||
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
|
||||
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
|
||||
|
||||
const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
|
||||
|
||||
mutateElement(element, {
|
||||
x: element.x + coordsDiff * 0.5,
|
||||
y: element.y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
@ -132,7 +132,7 @@ export const actionGroup = register({
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
@ -189,9 +189,7 @@ export const actionUngroup = register({
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
|
@ -1,49 +0,0 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, app.scene);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
});
|
@ -4,7 +4,7 @@ import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
@ -67,7 +67,7 @@ export const actionFullScreen = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
|
@ -3,42 +3,32 @@ import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, value, app) => {
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
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 {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
selectedElementIds: elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
),
|
||||
|
@ -17,19 +17,16 @@ export const actionToggleLock = register({
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
const lock = operation === "lock";
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: lock });
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||
},
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
@ -85,4 +85,3 @@ export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
@ -137,6 +137,7 @@ export class ActionManager {
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
this.actions[name] &&
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
@ -146,7 +147,6 @@ export class ActionManager {
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
PanelComponent.displayName = "PanelComponent";
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
|
@ -111,8 +111,7 @@ export type ActionName =
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor";
|
||||
| "toggleLock";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
@ -57,7 +57,8 @@ export const getDefaultAppState = (): Omit<
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isSidebarDocked: false,
|
||||
isLibraryOpen: false,
|
||||
isLibraryMenuDocked: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@ -66,7 +67,6 @@ export const getDefaultAppState = (): Omit<
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
@ -90,7 +90,6 @@ export const getDefaultAppState = (): Omit<
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -148,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isSidebarDocked: { browser: true, 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 },
|
||||
@ -159,7 +159,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, export: false, server: false },
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
openSidebar: { browser: true, 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 },
|
||||
@ -182,7 +181,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
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 = <
|
||||
|
@ -26,17 +26,17 @@ import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
activeTool,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -56,13 +56,13 @@ export const SelectedShapeActions = ({
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
@ -79,23 +79,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(appState.activeTool.type === "freedraw" ||
|
||||
{(activeTool === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@ -103,12 +103,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(appState.activeTool.type) ||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(appState.activeTool.type) ||
|
||||
{(hasText(activeTool) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@ -123,7 +123,7 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@ -271,45 +271,3 @@ export const ZoomActions = ({
|
||||
</Stack.Row>
|
||||
</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
@ -1,12 +1,20 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
setAppState,
|
||||
actionManager,
|
||||
showThemeBtn,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
showThemeBtn: boolean;
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{actionManager.renderAction("toggleTheme")}
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
</div>
|
||||
);
|
||||
|
@ -4,7 +4,6 @@ import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor | "primary";
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
|
@ -8,7 +8,6 @@ export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, checked, onChange, className }) => {
|
||||
return (
|
||||
<div
|
||||
|
@ -343,8 +343,6 @@ const ColorInput = React.forwardRef(
|
||||
},
|
||||
);
|
||||
|
||||
ColorInput.displayName = "ColorInput";
|
||||
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
|
@ -85,7 +85,6 @@ export const Dialog = (props: DialogProps) => {
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useDevice().isMobile ? back : close}
|
||||
|
@ -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;
|
@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { AppState, Device } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
@ -17,19 +17,13 @@ interface HintViewerProps {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
isMobile: boolean;
|
||||
device: Device;
|
||||
}
|
||||
|
||||
const getHints = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -117,13 +111,11 @@ export const HintViewer = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
});
|
||||
if (!hint) {
|
||||
return null;
|
||||
|
@ -58,7 +58,6 @@ const ExportButton: React.FC<{
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
shade?: number;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
||||
return (
|
||||
<button
|
||||
|
@ -2,12 +2,10 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
children: React.ReactElement;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export const InitializeApp = (props: Props) => {
|
||||
@ -23,5 +21,5 @@ export const InitializeApp = (props: Props) => {
|
||||
updateLang();
|
||||
}, [props.langCode]);
|
||||
|
||||
return loading ? <LoadingMessage theme={props.theme} /> : props.children;
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
};
|
||||
|
@ -1,6 +1,48 @@
|
||||
@import "open-color/open-color";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.layer-ui__sidebar {
|
||||
position: absolute;
|
||||
top: var(--sat);
|
||||
bottom: var(--sab);
|
||||
right: var(--sar);
|
||||
z-index: 5;
|
||||
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin: var(--space-factor);
|
||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__wrapper.animate {
|
||||
transition: width 0.1s ease-in-out;
|
||||
|
@ -1,16 +1,16 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
@ -26,7 +26,7 @@ import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
@ -39,10 +39,7 @@ import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./Footer";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -56,12 +53,12 @@ interface LayerUIProps {
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
showThemeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
@ -74,18 +71,18 @@ const LayerUI = ({
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
elements,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
showThemeBtn,
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
@ -212,7 +209,12 @@ const LayerUI = ({
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
@ -242,11 +244,48 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
);
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ isLibraryOpen: false });
|
||||
}, [setAppState]);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
const libraryMenu = appState.isLibraryOpen ? (
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onClose={closeLibrary}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
appState={appState}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderFixedSideContainer = () => {
|
||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||
appState,
|
||||
@ -269,7 +308,7 @@ const LayerUI = ({
|
||||
</Stack.Col>
|
||||
{!appState.viewModeEnabled && (
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
@ -300,7 +339,6 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={device.isMobile}
|
||||
device={device}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
@ -345,24 +383,100 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderSidebars = () => {
|
||||
return appState.openSidebar === "customSidebar" ? (
|
||||
renderCustomSidebar?.() || null
|
||||
) : appState.openSidebar === "library" ? (
|
||||
<LibraryMenu
|
||||
appState={appState}
|
||||
onInsertElements={onInsertElements}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
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":
|
||||
appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{!appState.viewModeEnabled && (
|
||||
<>
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.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":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.multiElement &&
|
||||
device.isTouchScreen && (
|
||||
<div
|
||||
className={clsx("finalize-button zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.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":
|
||||
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>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||
|
||||
return (
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
@ -390,80 +504,86 @@ const LayerUI = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
device={device}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
{!device.isMobile && (
|
||||
<>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement &&
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
((appState.openSidebar === "library" &&
|
||||
appState.isSidebarDocked) ||
|
||||
hostSidebarCounters.docked) &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
const renderStats = () => {
|
||||
if (!appState.showStats) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
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()}
|
||||
<Footer
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
/>
|
||||
{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>
|
||||
{renderSidebars()}
|
||||
</>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{appState.isLibraryOpen && (
|
||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -482,12 +602,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
|
||||
return (
|
||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||
prev.renderTopRightUI === next.renderTopRightUI &&
|
||||
prev.renderCustomStats === next.renderCustomStats &&
|
||||
prev.renderCustomSidebar === next.renderCustomSidebar &&
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
prev.files === next.files &&
|
||||
|
@ -40,10 +40,10 @@ export const LibraryButton: React.FC<{
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const isOpen = event.target.checked;
|
||||
setAppState({ openSidebar: isOpen ? "library" : null });
|
||||
const nextState = event.target.checked;
|
||||
setAppState({ isLibraryOpen: nextState });
|
||||
// track only openings
|
||||
if (isOpen) {
|
||||
if (nextState) {
|
||||
trackEvent(
|
||||
"library",
|
||||
"toggleLibrary (open)",
|
||||
@ -51,7 +51,7 @@ export const LibraryButton: React.FC<{
|
||||
);
|
||||
}
|
||||
}}
|
||||
checked={appState.openSidebar === "library"}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="0"
|
||||
/>
|
||||
|
@ -1,16 +1,10 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layer-ui__library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
@ -29,100 +23,16 @@
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.layer-ui__library {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.library-menu-items-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.library-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
align-items: center;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 1px;
|
||||
font-size: 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--remove {
|
||||
background-color: $oc-red-7;
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-red-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
&--export {
|
||||
background-color: $oc-lime-5;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-lime-7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $oc-lime-8;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-lime-5;
|
||||
}
|
||||
}
|
||||
|
||||
&--publish {
|
||||
background-color: $oc-cyan-6;
|
||||
&:hover {
|
||||
background-color: $oc-cyan-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-cyan-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
label {
|
||||
margin-left: -0.2em;
|
||||
margin-right: 1.1em;
|
||||
color: $oc-white;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-cyan-6;
|
||||
}
|
||||
}
|
||||
|
||||
&--load {
|
||||
background-color: $oc-blue-6;
|
||||
&:hover {
|
||||
background-color: $oc-blue-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-blue-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
|
@ -6,31 +6,30 @@ import {
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../data/library";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
} from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { Island } from "./Island";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT, VERSIONS } from "../constants";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@ -60,45 +59,112 @@ const useOnClickOutside = (
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="layer-ui__library">
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</div>
|
||||
</Island>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenuContent = ({
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
appState,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: AppState;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
@ -124,12 +190,60 @@ export const LibraryMenuContent = ({
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
const renderPublishSuccess = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||
title={t("publishSuccessDialog.title")}
|
||||
className="publish-library-success"
|
||||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
</a>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
label={t("buttons.close")}
|
||||
onClick={() => setPublishLibSuccess(null)}
|
||||
data-testid="publish-library-success-close"
|
||||
className="publish-library-success-close"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
nextLibItems.forEach((libItem) => {
|
||||
if (selectedItems.includes(libItem.id)) {
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.setLibrary(nextLibItems);
|
||||
},
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
if (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
) {
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
<div className="layer-ui__library-message">
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
@ -139,168 +253,51 @@ export const LibraryMenuContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${
|
||||
appState.theme
|
||||
}&version=${VERSIONS.excalidrawLibrary}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryMenu: React.FC<{
|
||||
appState: AppState;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}> = ({
|
||||
appState,
|
||||
onInsertElements,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const device = useDevice();
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ openSidebar: null });
|
||||
}, [setAppState]);
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing so that LibraryButton
|
||||
// can toggle library menu
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
|
||||
closeLibrary();
|
||||
}
|
||||
},
|
||||
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
closeLibrary();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
__isInternal
|
||||
// necessary to remount when switching between internal
|
||||
// and custom (host app) sidebar, so that the `props.onClose`
|
||||
// is colled correctly
|
||||
key="library"
|
||||
className="layer-ui__library-sidebar"
|
||||
onDock={(docked) => {
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<Sidebar.Header className="layer-ui__library-header">
|
||||
<LibraryMenuHeader
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
library={library}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</Sidebar.Header>
|
||||
<LibraryMenuContent
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
id={id}
|
||||
appState={appState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
@ -1,258 +0,0 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
export const LibraryMenuHeader: React.FC<{
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
library: Library;
|
||||
onRemoveFromLibrary: () => void;
|
||||
resetLibrary: () => void;
|
||||
onSelectItems: (items: LibraryItem["id"][]) => void;
|
||||
appState: AppState;
|
||||
}> = ({
|
||||
setAppState,
|
||||
selectedItems,
|
||||
library,
|
||||
onRemoveFromLibrary,
|
||||
resetLibrary,
|
||||
onSelectItems,
|
||||
appState,
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
: t("alerts.resetLibrary");
|
||||
const title = selectedItems.length
|
||||
? t("confirmDialog.removeItemsFromLib")
|
||||
: t("confirmDialog.resetLibrary");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (selectedItems.length) {
|
||||
onRemoveFromLibrary();
|
||||
} else {
|
||||
resetLibrary();
|
||||
}
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
? libraryItemsData.libraryItems.filter((item) =>
|
||||
selectedItems.includes(item.id),
|
||||
)
|
||||
: libraryItemsData.libraryItems;
|
||||
const resetLabel = itemsSelected
|
||||
? t("buttons.remove")
|
||||
: t("buttons.resetLibrary");
|
||||
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const renderPublishSuccess = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||
title={t("publishSuccessDialog.title")}
|
||||
className="publish-library-success"
|
||||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
</a>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
label={t("buttons.close")}
|
||||
onClick={() => setPublishLibSuccess(null)}
|
||||
data-testid="publish-library-success-close"
|
||||
className="publish-library-success-close"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
nextLibItems.forEach((libItem) => {
|
||||
if (selectedItems.includes(libItem.id)) {
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.setLibrary(nextLibItems);
|
||||
},
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
const onLibraryImport = async () => {
|
||||
try {
|
||||
await library.updateLibrary({
|
||||
libraryItems: fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
}),
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
}
|
||||
};
|
||||
|
||||
const onLibraryExport = async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.getLatestLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
{!itemsSelected && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={onLibraryImport}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportToFileIcon}
|
||||
onClick={onLibraryExport}
|
||||
className="library-actions--export"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
icon={trash}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
className="library-actions--remove"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
aria-label={t("buttons.publishLibrary")}
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
className="library-actions--publish"
|
||||
onClick={() => setShowPublishLibraryDialog(true)}
|
||||
>
|
||||
<label>{t("buttons.publishLibrary")}</label>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,7 +5,96 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.library-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
align-items: center;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 1px;
|
||||
font-size: 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--remove {
|
||||
background-color: $oc-red-7;
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-red-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
&--export {
|
||||
background-color: $oc-lime-5;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-lime-7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $oc-lime-8;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-lime-5;
|
||||
}
|
||||
}
|
||||
|
||||
&--publish {
|
||||
background-color: $oc-cyan-6;
|
||||
&:hover {
|
||||
background-color: $oc-cyan-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-cyan-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
label {
|
||||
margin-left: -0.2em;
|
||||
margin-right: 1.1em;
|
||||
color: $oc-white;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-cyan-6;
|
||||
}
|
||||
}
|
||||
|
||||
&--load {
|
||||
background-color: $oc-blue-6;
|
||||
&:hover {
|
||||
background-color: $oc-blue-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-blue-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
@ -1,35 +1,226 @@
|
||||
import React, { useState } from "react";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { chunk } from "lodash";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { LibraryItem, LibraryItems } from "../types";
|
||||
import { arrayToMap, chunk } from "../utils";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { arrayToMap, muteFSAbortError } from "../utils";
|
||||
import { useDevice } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
|
||||
const CELLS_PER_ROW = 4;
|
||||
import { SidebarLockButton } from "./SidebarLockButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
appState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
appState: AppState;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
: t("alerts.resetLibrary");
|
||||
const title = selectedItems.length
|
||||
? t("confirmDialog.removeItemsFromLib")
|
||||
: t("confirmDialog.resetLibrary");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (selectedItems.length) {
|
||||
onRemoveFromLibrary();
|
||||
} else {
|
||||
resetLibrary();
|
||||
}
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
const device = useDevice();
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||
: libraryItems;
|
||||
const resetLabel = itemsSelected
|
||||
? t("buttons.remove")
|
||||
: t("buttons.resetLibrary");
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{!itemsSelected && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await library.updateLibrary({
|
||||
libraryItems: fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
}),
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
}
|
||||
}}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportToFileIcon}
|
||||
onClick={async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.getLatestLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
className="library-actions--export"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
icon={trash}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
className="library-actions--remove"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
aria-label={t("buttons.publishLibrary")}
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
className="library-actions--publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<div className="library-menu-browse-button--mobile">
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
@ -106,6 +297,7 @@ const LibraryMenuItems = ({
|
||||
<Stack.Col key={params.key}>
|
||||
<LibraryUnit
|
||||
elements={params.item?.elements}
|
||||
files={files}
|
||||
isPending={!params.item?.id && !!params.item?.elements}
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
@ -181,21 +373,56 @@ const LibraryMenuItems = ({
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
publishedItems.length || unpublishedItems.length
|
||||
? {
|
||||
flex: "1 1 0",
|
||||
overflowY: "auto",
|
||||
}
|
||||
: {
|
||||
marginBottom: "2rem",
|
||||
flex: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
const renderLibraryHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
{device.canDeviceFitSidebar && (
|
||||
<>
|
||||
<div className="layer-ui__sidebar-lock-button">
|
||||
<SidebarLockButton
|
||||
checked={appState.isLibraryMenuDocked}
|
||||
onChange={() => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.add("animate");
|
||||
const nextState = !appState.isLibraryMenuDocked;
|
||||
setAppState({
|
||||
isLibraryMenuDocked: nextState,
|
||||
});
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!device.isMobile && (
|
||||
<div className="ToolIcon__icon__close">
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={() =>
|
||||
setAppState({
|
||||
isLibraryOpen: false,
|
||||
})
|
||||
}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryMenuItems = () => {
|
||||
return (
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
@ -267,8 +494,8 @@ const LibraryMenuItems = ({
|
||||
|
||||
<>
|
||||
{(publishedItems.length > 0 ||
|
||||
pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0) && (
|
||||
(!device.isMobile &&
|
||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||
)}
|
||||
{publishedItems.length > 0 ? (
|
||||
@ -290,6 +517,41 @@ const LibraryMenuItems = ({
|
||||
) : null}
|
||||
</>
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryFooter = () => {
|
||||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
device.isMobile
|
||||
? {
|
||||
minHeight: "200px",
|
||||
maxHeight: "70vh",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{renderLibraryHeader()}
|
||||
{renderLibraryMenuItems()}
|
||||
{!device.isMobile && renderLibraryFooter()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
@ -23,6 +23,7 @@ const PLUS_ICON = (
|
||||
export const LibraryUnit = ({
|
||||
id,
|
||||
elements,
|
||||
files,
|
||||
isPending,
|
||||
onClick,
|
||||
selected,
|
||||
@ -31,6 +32,7 @@ export const LibraryUnit = ({
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
files: BinaryFiles;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
@ -54,7 +56,7 @@ export const LibraryUnit = ({
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null,
|
||||
files,
|
||||
);
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
@ -62,7 +64,7 @@ export const LibraryUnit = ({
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [elements]);
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().isMobile;
|
||||
|
@ -1,14 +1,8 @@
|
||||
import { t } from "../i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
import Spinner from "./Spinner";
|
||||
import clsx from "clsx";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
delay,
|
||||
theme,
|
||||
}) => {
|
||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||
|
||||
useEffect(() => {
|
||||
@ -26,11 +20,7 @@ export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("LoadingMessage", {
|
||||
"LoadingMessage--dark": theme === THEME.DARK,
|
||||
})}
|
||||
>
|
||||
<div className="LoadingMessage">
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
@ -18,8 +18,6 @@ import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@ -28,6 +26,7 @@ type MobileMenuProps = {
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
@ -37,19 +36,19 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderStats: () => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
appState,
|
||||
elements,
|
||||
libraryMenu,
|
||||
actionManager,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
@ -60,17 +59,16 @@ export const MobileMenu = ({
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
renderStats,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1} className="App-toolbar-container">
|
||||
<Island padding={1} className="App-toolbar">
|
||||
@ -109,15 +107,11 @@ export const MobileMenu = ({
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={true}
|
||||
device={device}
|
||||
/>
|
||||
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
@ -125,6 +119,7 @@ export const MobileMenu = ({
|
||||
const renderAppToolbar = () => {
|
||||
// Render eraser conditionally in mobile
|
||||
const showEraser =
|
||||
!appState.viewModeEnabled &&
|
||||
!appState.editingElement &&
|
||||
getSelectedElements(elements, appState).length === 0;
|
||||
|
||||
@ -143,11 +138,11 @@ export const MobileMenu = ({
|
||||
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{showEraser
|
||||
? actionManager.renderAction("eraser")
|
||||
: actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{showEraser && actionManager.renderAction("eraser")}
|
||||
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
);
|
||||
@ -175,25 +170,21 @@ export const MobileMenu = ({
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />}
|
||||
{
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
{!appState.viewModeEnabled && renderToolbar()}
|
||||
{!appState.openMenu && appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
{renderStats()}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
@ -230,6 +221,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
@ -237,7 +229,7 @@ export const MobileMenu = ({
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
appState.openSidebar !== "library" && (
|
||||
!appState.isLibraryOpen && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
|
@ -8,7 +8,7 @@ import { useExcalidrawContainer, useDevice } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
export const Modal: React.FC<{
|
||||
export const Modal = (props: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
@ -16,7 +16,7 @@ export const Modal: React.FC<{
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
closeOnClickOutside?: boolean;
|
||||
}> = (props) => {
|
||||
}) => {
|
||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
|
@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
previewNode.replaceChildren();
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.replaceChildren();
|
||||
previewNode.removeChild(svg);
|
||||
};
|
||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||
|
||||
|
@ -69,6 +69,7 @@ export const Popover = ({
|
||||
if (fitInViewport && popoverRef.current) {
|
||||
const element = popoverRef.current;
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
|
||||
|
||||
//Position correctly when clicked on rightmost part or the bottom part of viewport
|
||||
if (x + width - offsetLeft > viewportWidth) {
|
||||
|
@ -2,11 +2,12 @@ import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const Section: React.FC<{
|
||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
heading: string;
|
||||
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
|
||||
className?: string;
|
||||
}> = ({ heading, children, ...props }) => {
|
||||
children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode);
|
||||
}
|
||||
|
||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const header = (
|
||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
||||
|
@ -1,89 +0,0 @@
|
||||
@import "open-color/open-color";
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__sidebar {
|
||||
position: absolute;
|
||||
top: var(--sat);
|
||||
bottom: var(--sab);
|
||||
right: var(--sar);
|
||||
z-index: 5;
|
||||
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin: var(--space-factor);
|
||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0 15px 0;
|
||||
&:empty {
|
||||
margin: 0;
|
||||
}
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.layer-ui__sidebar-dock-button {
|
||||
@include toolbarButtonColorStates;
|
||||
margin-right: 0.2rem;
|
||||
|
||||
.ToolIcon_type_floating .ToolIcon__icon {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
svg {
|
||||
// mirror
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_type_checkbox {
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,355 +0,0 @@
|
||||
import React from "react";
|
||||
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
queryAllByTestId,
|
||||
queryByTestId,
|
||||
render,
|
||||
waitFor,
|
||||
withExcalidrawDimensions,
|
||||
} from "../../tests/test-utils";
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("should render custom sidebar", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should render custom sidebar header", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<Sidebar.Header>
|
||||
<div id="test-sidebar-header-content">42</div>
|
||||
</Sidebar.Header>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-header-content");
|
||||
expect(node).not.toBe(null);
|
||||
// make sure we don't render the default fallback header,
|
||||
// just the custom one
|
||||
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||
});
|
||||
|
||||
it("should render only one sidebar and prefer the custom one", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// make sure the custom sidebar is rendered
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should always render custom sidebar with close button & close on click", async () => {
|
||||
const onClose = jest.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" onClose={onClose}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-close");
|
||||
expect(closeButton).not.toBe(null);
|
||||
|
||||
fireEvent.click(closeButton!.querySelector("button")!);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar">hello</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
// should show dock button when the sidebar fits to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
|
||||
// should not show dock button when the sidebar does not fit to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDockable: (dockable: boolean) => void = null!;
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [dockable, setDockable] = React.useState(false);
|
||||
_setDockable = setDockable;
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar
|
||||
className="test-sidebar"
|
||||
docked={false}
|
||||
dockable={dockable}
|
||||
>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDockable(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
|
||||
// should show dock button when `dockable` is `true`, even if `docked`
|
||||
// prop is set
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDockable(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDocked: (docked?: boolean) => void = null!;
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [docked, setDocked] = React.useState<boolean | undefined>();
|
||||
_setDocked = setDocked;
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" docked={docked}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const { h } = window;
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
const dockButton = await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(dockBotton).not.toBe(null);
|
||||
return dockBotton!;
|
||||
});
|
||||
|
||||
const dockButtonInput = dockButton.querySelector("input")!;
|
||||
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
});
|
||||
|
||||
// shouldn't update `appState.isSidebarDocked` when the sidebar
|
||||
// is controlled (`docked` prop is set), as host apps should handle
|
||||
// the state themselves
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDocked(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
// the `appState.isSidebarDocked` should remain untouched when
|
||||
// `props.docked` is set to `false`, and user toggles
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDocked(false);
|
||||
h.setState({ isSidebarDocked: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle sidebar using props.toggleMenu()", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// sidebar isn't rendered initially
|
||||
// -------------------------------------------------------------------------
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
});
|
||||
|
||||
// toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
// toggle sidebar off
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
});
|
||||
|
||||
// force-toggle sidebar off (=> still hidden)
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
});
|
||||
|
||||
// force-toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
// toggle library (= hide custom sidebar)
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("library")).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,139 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { Island } from ".././Island";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import {
|
||||
SidebarPropsContext,
|
||||
SidebarProps,
|
||||
SidebarPropsContextValue,
|
||||
} from "./common";
|
||||
|
||||
import { SidebarHeaderComponents } from "./SidebarHeader";
|
||||
|
||||
import "./Sidebar.scss";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { updateObject } from "../../utils";
|
||||
|
||||
/** using a counter instead of boolean to handle race conditions where
|
||||
* the host app may render (mount/unmount) multiple different sidebar */
|
||||
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
||||
|
||||
export const Sidebar = Object.assign(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onClose,
|
||||
onDock,
|
||||
docked,
|
||||
dockable = true,
|
||||
className,
|
||||
__isInternal,
|
||||
}: SidebarProps<{
|
||||
// NOTE sidebars we use internally inside the editor must have this flag set.
|
||||
// It indicates that this sidebar should have lower precedence over host
|
||||
// sidebars, if both are open.
|
||||
/** @private internal */
|
||||
__isInternal?: boolean;
|
||||
}>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
|
||||
hostSidebarCountersAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (docked === undefined) {
|
||||
// ugly hack to get initial state out of AppState without subscribing
|
||||
// to it as a whole (once we have granular subscriptions, we'll move
|
||||
// to that)
|
||||
//
|
||||
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
||||
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
||||
setAppState((state) => {
|
||||
setIsDockedFallback(state.isSidebarDocked);
|
||||
// bail from update
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}, [setAppState, docked]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!__isInternal) {
|
||||
setHostSidebarCounters((s) => ({
|
||||
rendered: s.rendered + 1,
|
||||
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
||||
}));
|
||||
return () => {
|
||||
setHostSidebarCounters((s) => ({
|
||||
rendered: s.rendered - 1,
|
||||
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
||||
}));
|
||||
};
|
||||
}
|
||||
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
||||
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onCloseRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
||||
headerPropsRef.current.onClose = () => {
|
||||
setAppState({ openSidebar: null });
|
||||
};
|
||||
headerPropsRef.current.onDock = (isDocked) => {
|
||||
if (docked === undefined) {
|
||||
setAppState({ isSidebarDocked: isDocked });
|
||||
setIsDockedFallback(isDocked);
|
||||
}
|
||||
onDock?.(isDocked);
|
||||
};
|
||||
// renew the ref object if the following props change since we want to
|
||||
// rerender. We can't pass down as component props manually because
|
||||
// the <Sidebar.Header/> can be rendered upsream.
|
||||
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
||||
docked: docked ?? isDockedFallback,
|
||||
dockable,
|
||||
});
|
||||
|
||||
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Island
|
||||
padding={2}
|
||||
className={clsx("layer-ui__sidebar", className)}
|
||||
ref={ref}
|
||||
>
|
||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||
<SidebarHeaderComponents.Context>
|
||||
<SidebarHeaderComponents.Component __isFallback />
|
||||
{children}
|
||||
</SidebarHeaderComponents.Context>
|
||||
</SidebarPropsContext.Provider>
|
||||
</Island>
|
||||
);
|
||||
},
|
||||
),
|
||||
{
|
||||
Header: SidebarHeaderComponents.Component,
|
||||
},
|
||||
);
|
@ -1,95 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useContext } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { useDevice } from "../App";
|
||||
import { SidebarPropsContext } from "./common";
|
||||
import { close } from "../icons";
|
||||
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarDockButton = (props: {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_medium`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div className="ToolIcon__icon" tabIndex={0}>
|
||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const _SidebarHeader: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
const device = useDevice();
|
||||
const props = useContext(SidebarPropsContext);
|
||||
|
||||
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
|
||||
const renderCloseButton = !!props.onClose;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("layer-ui__sidebar__header", className)}
|
||||
data-testid="sidebar-header"
|
||||
>
|
||||
{children}
|
||||
{(renderDockButton || renderCloseButton) && (
|
||||
<div className="layer-ui__sidebar__header__buttons">
|
||||
{renderDockButton && (
|
||||
<SidebarDockButton
|
||||
checked={!!props.docked}
|
||||
onChange={() => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.add("animate");
|
||||
|
||||
props.onDock?.(!props.docked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renderCloseButton && (
|
||||
<div className="ToolIcon__icon__close" data-testid="sidebar-close">
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={props.onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
|
||||
|
||||
/** @private */
|
||||
export const SidebarHeaderComponents = { Context, Component };
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export type SidebarProps<P = {}> = {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Called on sidebar close (either by user action or by the editor).
|
||||
*/
|
||||
onClose?: () => void | boolean;
|
||||
/** if not supplied, sidebar won't be dockable */
|
||||
onDock?: (docked: boolean) => void;
|
||||
docked?: boolean;
|
||||
dockable?: boolean;
|
||||
className?: string;
|
||||
} & P;
|
||||
|
||||
export type SidebarPropsContextValue = Pick<
|
||||
SidebarProps,
|
||||
"onClose" | "onDock" | "docked" | "dockable"
|
||||
>;
|
||||
|
||||
export const SidebarPropsContext =
|
||||
React.createContext<SidebarPropsContextValue>({});
|
22
src/components/SidebarLockButton.scss
Normal file
22
src/components/SidebarLockButton.scss
Normal file
@ -0,0 +1,22 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__sidebar-lock-button {
|
||||
@include toolbarButtonColorStates;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
.ToolIcon_type_floating .side_lock_icon {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
svg {
|
||||
// mirror
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_type_checkbox {
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
46
src/components/SidebarLockButton.tsx
Normal file
46
src/components/SidebarLockButton.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./SidebarLockButton.scss";
|
||||
|
||||
type SidebarLockIconProps = {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
|
||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
||||
return (
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
@ -2,6 +2,7 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@ -15,10 +16,13 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
|
||||
if (device.isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
|
@ -187,5 +187,3 @@ ToolButton.defaultProps = {
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
ToolButton.displayName = "ToolButton";
|
||||
|
@ -1,63 +0,0 @@
|
||||
import React, {
|
||||
useMemo,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
createContext,
|
||||
} from "react";
|
||||
|
||||
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
|
||||
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
|
||||
|
||||
const DefaultComponentContext = createContext<ContextValue>([
|
||||
false,
|
||||
() => {},
|
||||
]);
|
||||
|
||||
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
|
||||
const contextValue: ContextValue = useMemo(
|
||||
() => [isRenderedUpstream, setIsRenderedUpstream],
|
||||
[isRenderedUpstream],
|
||||
);
|
||||
|
||||
return (
|
||||
<DefaultComponentContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DefaultComponentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultComponent = (
|
||||
props: P & {
|
||||
// indicates whether component should render when not rendered upstream
|
||||
/** @private internal */
|
||||
__isFallback?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
|
||||
DefaultComponentContext,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.__isFallback) {
|
||||
setIsRenderedUpstream(true);
|
||||
return () => setIsRenderedUpstream(false);
|
||||
}
|
||||
}, [props.__isFallback, setIsRenderedUpstream]);
|
||||
|
||||
if (props.__isFallback && isRenderedUpstream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
if (Component.name) {
|
||||
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
|
||||
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
|
||||
}
|
||||
|
||||
return [ComponentContext, DefaultComponent] as const;
|
||||
};
|
@ -99,9 +99,6 @@ export const MIME_TYPES = {
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
binary: "application/octet-stream",
|
||||
} as const;
|
||||
|
||||
@ -152,7 +149,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
export: { saveFileToDisk: true },
|
||||
loadScene: true,
|
||||
saveToActiveFile: true,
|
||||
toggleTheme: null,
|
||||
theme: true,
|
||||
saveAsImage: true,
|
||||
},
|
||||
};
|
||||
@ -183,9 +180,6 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
MIME_TYPES.jpg,
|
||||
MIME_TYPES.svg,
|
||||
MIME_TYPES.gif,
|
||||
MIME_TYPES.webp,
|
||||
MIME_TYPES.bmp,
|
||||
MIME_TYPES.ico,
|
||||
] as const;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
@ -207,18 +201,8 @@ export const VERTICAL_ALIGN = {
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const TEXT_ALIGN = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
@ -1,5 +1,3 @@
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
@ -32,8 +30,3 @@
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingMessage--dark {
|
||||
background-color: #121212;
|
||||
color: #ced4da;
|
||||
}
|
||||
|
@ -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) => {
|
||||
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.
|
||||
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
||||
export const normalizeFile = async (file: File) => {
|
||||
|
@ -148,7 +148,7 @@ class Library {
|
||||
defaultStatus?: "unpublished" | "published";
|
||||
}): Promise<LibraryItems> => {
|
||||
if (openLibraryMenu) {
|
||||
this.app.setState({ openSidebar: "library" });
|
||||
this.app.setState({ isLibraryOpen: true });
|
||||
}
|
||||
|
||||
return this.setLibrary(() => {
|
||||
@ -365,56 +365,38 @@ export const useHandleLibrary = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const importLibraryFromURL = async ({
|
||||
const importLibraryFromURL = ({
|
||||
libraryUrl,
|
||||
idToken,
|
||||
}: {
|
||||
libraryUrl: string;
|
||||
idToken: string | null;
|
||||
}) => {
|
||||
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||
try {
|
||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
||||
const blob = await request.blob();
|
||||
resolve(blob);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const shouldPrompt = idToken !== excalidrawAPI.id;
|
||||
|
||||
// wait for the tab to be focused before continuing in case we'll prompt
|
||||
// for confirmation
|
||||
await (shouldPrompt && document.hidden
|
||||
? new Promise<void>((resolve) => {
|
||||
window.addEventListener("focus", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
: null);
|
||||
|
||||
try {
|
||||
await excalidrawAPI.updateLibrary({
|
||||
libraryItems: libraryPromise,
|
||||
prompt: shouldPrompt,
|
||||
merge: true,
|
||||
defaultStatus: "published",
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
hash.delete(URL_HASH_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
||||
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
query.delete(URL_QUERY_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
||||
}
|
||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
hash.delete(URL_HASH_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
||||
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
query.delete(URL_QUERY_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
||||
}
|
||||
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: new Promise<Blob>(async (resolve, reject) => {
|
||||
try {
|
||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
||||
const blob = await request.blob();
|
||||
resolve(blob);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
}),
|
||||
prompt: idToken !== excalidrawAPI.id,
|
||||
merge: true,
|
||||
defaultStatus: "published",
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
};
|
||||
const onHashChange = (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
LibraryItem,
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState, LegacyAppState } from "./types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
@ -21,7 +21,6 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
PRECEDING_ELEMENT_KEY,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
@ -68,16 +67,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
/** metadata that may be present in elements during collaboration */
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
},
|
||||
T extends ExcalidrawElement,
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
element: T,
|
||||
element: Required<T> & {
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
},
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
@ -86,9 +82,7 @@ const restoreElementWithProperties = <
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
} = {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: extra.type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
@ -121,14 +115,6 @@ const restoreElementWithProperties = <
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
if ("customData" in element) {
|
||||
base.customData = element.customData;
|
||||
}
|
||||
|
||||
if (PRECEDING_ELEMENT_KEY in element) {
|
||||
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...getNormalizedDimensions(base),
|
||||
@ -251,43 +237,6 @@ export const restoreElements = (
|
||||
}, [] as ExcalidrawElement[]);
|
||||
};
|
||||
|
||||
const coalesceAppStateValue = <
|
||||
T extends keyof ReturnType<typeof getDefaultAppState>,
|
||||
>(
|
||||
key: T,
|
||||
appState: Exclude<ImportedDataState["appState"], null | undefined>,
|
||||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||
) => {
|
||||
const value = appState[key];
|
||||
// NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
|
||||
return value !== undefined ? value! : defaultAppState[key];
|
||||
};
|
||||
|
||||
const LegacyAppStateMigrations: {
|
||||
[K in keyof LegacyAppState]: (
|
||||
ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
|
||||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
||||
} = {
|
||||
isLibraryOpen: (appState, defaultAppState) => {
|
||||
return [
|
||||
"openSidebar",
|
||||
"isLibraryOpen" in appState
|
||||
? appState.isLibraryOpen
|
||||
? "library"
|
||||
: null
|
||||
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
|
||||
];
|
||||
},
|
||||
isLibraryMenuDocked: (appState, defaultAppState) => {
|
||||
return [
|
||||
"isSidebarDocked",
|
||||
appState.isLibraryMenuDocked ??
|
||||
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const restoreAppState = (
|
||||
appState: ImportedDataState["appState"],
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
@ -295,30 +244,11 @@ export const restoreAppState = (
|
||||
appState = appState || {};
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const nextAppState = {} as typeof defaultAppState;
|
||||
|
||||
// first, migrate all legacy AppState properties to new ones. We do it
|
||||
// in one go before migrate the rest of the properties in case the new ones
|
||||
// depend on checking any other key (i.e. they are coupled)
|
||||
for (const legacyKey of Object.keys(
|
||||
LegacyAppStateMigrations,
|
||||
) as (keyof typeof LegacyAppStateMigrations)[]) {
|
||||
if (legacyKey in appState) {
|
||||
const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
|
||||
appState,
|
||||
defaultAppState,
|
||||
);
|
||||
(nextAppState as any)[nextKey] = nextValue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||
keyof typeof defaultAppState,
|
||||
any,
|
||||
][]) {
|
||||
// if AppState contains a legacy key, prefer that one and migrate its
|
||||
// value to the new one
|
||||
const suppliedValue = appState[key];
|
||||
|
||||
const localValue = localAppState ? localAppState[key] : undefined;
|
||||
(nextAppState as any)[key] =
|
||||
suppliedValue !== undefined
|
||||
@ -355,12 +285,9 @@ export const restoreAppState = (
|
||||
: appState.zoom || defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
openSidebar:
|
||||
nextAppState.openSidebar === "library"
|
||||
? nextAppState.isSidebarDocked
|
||||
? "library"
|
||||
: null
|
||||
: nextAppState.openSidebar,
|
||||
isLibraryOpen: nextAppState.isLibraryMenuDocked
|
||||
? nextAppState.isLibraryOpen
|
||||
: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -17,32 +17,12 @@ export interface ExportedDataState {
|
||||
files: BinaryFiles | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of legacy AppState keys, with values of:
|
||||
* [<legacy type>, <new AppState proeprty>]
|
||||
*
|
||||
* This is a helper type used in downstream abstractions.
|
||||
* Don't consume on its own.
|
||||
*/
|
||||
export type LegacyAppState = {
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryOpen: [boolean, "openSidebar"];
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
|
||||
};
|
||||
|
||||
export interface ImportedDataState {
|
||||
type?: string;
|
||||
version?: number;
|
||||
source?: string;
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<
|
||||
Partial<
|
||||
AppState & {
|
||||
[T in keyof LegacyAppState]: LegacyAppState[T][0];
|
||||
}
|
||||
>
|
||||
> | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems_anyVersion;
|
||||
files?: BinaryFiles;
|
||||
|
@ -32,7 +32,6 @@ import { getElementAbsoluteCoords } from "./";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@ -49,15 +48,15 @@ let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||
|
||||
export const Hyperlink = ({
|
||||
element,
|
||||
appState,
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
const linkVal = element.link || "";
|
||||
|
||||
const [inputVal, setInputVal] = useState(linkVal);
|
||||
|
@ -18,7 +18,6 @@ import { rescalePoints } from "../points";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
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
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 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 = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
): [number, number, number, number] => {
|
||||
let currentP: Point = [0, 0];
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
// There are only four operation types:
|
||||
@ -175,29 +83,38 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
const _p1 = [data[0], data[1]] as Point;
|
||||
const _p2 = [data[2], data[3]] as Point;
|
||||
const _p3 = [data[4], data[5]] as Point;
|
||||
// create points from bezier curve
|
||||
// bezier curve stores data as a flattened array of three positions
|
||||
// [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 p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||
const p0 = currentP;
|
||||
currentP = p3;
|
||||
|
||||
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||
currentP = _p3;
|
||||
const equation = (t: number, idx: number) =>
|
||||
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(
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
);
|
||||
let t = 0;
|
||||
while (t <= 1.0) {
|
||||
let x = equation(t, 0);
|
||||
let y = equation(t, 1);
|
||||
if (transformXY) {
|
||||
[x, y] = transformXY(x, y);
|
||||
}
|
||||
|
||||
limits.minX = Math.min(limits.minX, minX);
|
||||
limits.minY = Math.min(limits.minY, minY);
|
||||
limits.minY = Math.min(limits.minY, y);
|
||||
limits.minX = Math.min(limits.minX, x);
|
||||
|
||||
limits.maxX = Math.max(limits.maxX, maxX);
|
||||
limits.maxY = Math.max(limits.maxY, maxY);
|
||||
limits.maxX = Math.max(limits.maxX, x);
|
||||
limits.maxY = Math.max(limits.maxY, y);
|
||||
|
||||
t += 0.1;
|
||||
}
|
||||
} else if (op === "lineTo") {
|
||||
// TODO: Implement this
|
||||
} else if (op === "qcurveTo") {
|
||||
@ -207,6 +124,7 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
@ -502,7 +420,6 @@ export const getResizedElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
normalizePoints: boolean,
|
||||
): [number, number, number, number] => {
|
||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||
return [
|
||||
@ -516,8 +433,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
const points = rescalePoints(
|
||||
0,
|
||||
nextWidth,
|
||||
rescalePoints(1, nextHeight, element.points, normalizePoints),
|
||||
normalizePoints,
|
||||
rescalePoints(1, nextHeight, element.points),
|
||||
);
|
||||
|
||||
let bounds: [number, number, number, number];
|
||||
|
@ -35,7 +35,6 @@ import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@ -65,10 +64,7 @@ export const hitTest = (
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
const point: Point = [x, y];
|
||||
|
||||
if (
|
||||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
if (isElementSelected(appState, element)) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
}
|
||||
|
||||
|
@ -105,26 +105,15 @@ export const dragNewElement = (
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
) => {
|
||||
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||
// (originX, originY), we use ONLY width or height to control size increase.
|
||||
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||
if (Math.abs(y - originY) > Math.abs(x - originX)) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
height,
|
||||
x < originX ? -width : width,
|
||||
));
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
}
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
|
@ -53,7 +53,6 @@ export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
isInvisiblySmallElement,
|
||||
resizePerfectLineForNWHandler,
|
||||
getNormalizedDimensions,
|
||||
|
@ -5,20 +5,8 @@ import {
|
||||
PointBinding,
|
||||
ExcalidrawBindableElement,
|
||||
} from "./types";
|
||||
import {
|
||||
distance2d,
|
||||
rotate,
|
||||
isPathALoop,
|
||||
getGridPoint,
|
||||
rotatePoint,
|
||||
centerPoint,
|
||||
getControlPointsForBezierCurve,
|
||||
getBezierXY,
|
||||
getBezierCurveLength,
|
||||
mapIntervalToBezierT,
|
||||
arePointsEqual,
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { getElementPointsCoords } from "./bounds";
|
||||
import { Point, AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@ -32,38 +20,26 @@ import {
|
||||
} from "./binding";
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
points: (Point | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
public elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
/** indices */
|
||||
public readonly selectedPointsIndices: readonly number[] | null;
|
||||
public selectedPointsIndices: readonly number[] | null;
|
||||
|
||||
public readonly pointerDownState: Readonly<{
|
||||
public pointerDownState: Readonly<{
|
||||
prevSelectedPointsIndices: readonly number[] | null;
|
||||
/** index */
|
||||
lastClickedPoint: number;
|
||||
}>;
|
||||
|
||||
/** whether you're dragging a point */
|
||||
public readonly isDragging: boolean;
|
||||
public readonly lastUncommittedPoint: Point | null;
|
||||
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public readonly startBindingElement:
|
||||
| ExcalidrawBindableElement
|
||||
| null
|
||||
| "keep";
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||
public isDragging: boolean;
|
||||
public lastUncommittedPoint: Point | null;
|
||||
public pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||
this.elementId = element.id as string & {
|
||||
@ -82,15 +58,14 @@ export class LinearElementEditor {
|
||||
prevSelectedPointsIndices: null,
|
||||
lastClickedPoint: -1,
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
@ -157,20 +132,22 @@ export class LinearElementEditor {
|
||||
|
||||
/** @returns whether point was dragged */
|
||||
static handlePointDragging(
|
||||
event: PointerEvent,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): boolean {
|
||||
if (!linearElementEditor) {
|
||||
if (!appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const { selectedPointsIndices, elementId } = linearElementEditor;
|
||||
const { editingLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId, isDragging } =
|
||||
editingLinearElement;
|
||||
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
@ -178,72 +155,55 @@ export class LinearElementEditor {
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[
|
||||
linearElementEditor.pointerDownState.lastClickedPoint
|
||||
editingLinearElement.pointerDownState.lastClickedPoint
|
||||
] as [number, number] | undefined;
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
if (
|
||||
shouldRotateWithDiscreteAngle(event) &&
|
||||
selectedPointsIndices.length === 1 &&
|
||||
element.points.length > 1
|
||||
) {
|
||||
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,
|
||||
if (isDragging === false) {
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...editingLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
]);
|
||||
} 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
|
||||
if (isBindingElement(element, false)) {
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
@ -296,12 +256,10 @@ export class LinearElementEditor {
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
const bindings: Mutable<
|
||||
Partial<
|
||||
Pick<
|
||||
InstanceType<typeof LinearElementEditor>,
|
||||
"startBindingElement" | "endBindingElement"
|
||||
>
|
||||
const bindings: Partial<
|
||||
Pick<
|
||||
InstanceType<typeof LinearElementEditor>,
|
||||
"startBindingElement" | "endBindingElement"
|
||||
>
|
||||
> = {};
|
||||
|
||||
@ -369,266 +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(
|
||||
element,
|
||||
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 handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
isMidPoint: boolean;
|
||||
} {
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
hitElement: null,
|
||||
linearElementEditor: null,
|
||||
isMidPoint: false,
|
||||
};
|
||||
|
||||
if (!linearElementEditor) {
|
||||
if (!appState.editingLinearElement) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const { elementId } = linearElementEditor;
|
||||
const { elementId } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (!element) {
|
||||
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;
|
||||
ret.isMidPoint = true;
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: element.points[1],
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
if (event.altKey) {
|
||||
if (appState.editingLinearElement.lastUncommittedPoint == null) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
@ -640,23 +366,24 @@ export class LinearElementEditor {
|
||||
),
|
||||
],
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
history.resumeRecording();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
pointerDownState: {
|
||||
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;
|
||||
return ret;
|
||||
}
|
||||
@ -670,7 +397,7 @@ export class LinearElementEditor {
|
||||
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || segmentMidPoint) {
|
||||
if (clickedPointIndex > -1) {
|
||||
ret.hitElement = element;
|
||||
} else {
|
||||
// You might be wandering why we are storing the binding elements on
|
||||
@ -678,7 +405,8 @@ export class LinearElementEditor {
|
||||
// from the end points of the `linearElement` - this is to allow disabling
|
||||
// binding (which needs to happen at the point the user finishes moving
|
||||
// the point).
|
||||
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||
const { startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
@ -704,54 +432,47 @@ export class LinearElementEditor {
|
||||
const nextSelectedPointsIndices =
|
||||
clickedPointIndex > -1 || event.shiftKey
|
||||
? event.shiftKey ||
|
||||
linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
|
||||
appState.editingLinearElement.selectedPointsIndices?.includes(
|
||||
clickedPointIndex,
|
||||
)
|
||||
? normalizeSelectedPoints([
|
||||
...(linearElementEditor.selectedPointsIndices || []),
|
||||
...(appState.editingLinearElement.selectedPointsIndices || []),
|
||||
clickedPointIndex,
|
||||
])
|
||||
: [clickedPointIndex]
|
||||
: null;
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: clickedPointIndex,
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices:
|
||||
appState.editingLinearElement.selectedPointsIndices,
|
||||
lastClickedPoint: clickedPointIndex,
|
||||
},
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
? {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
},
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
? {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
static arePointsEqual(point1: Point | null, point2: Point | null) {
|
||||
if (!point1 && !point2) {
|
||||
return true;
|
||||
}
|
||||
if (!point1 || !point2) {
|
||||
return false;
|
||||
}
|
||||
return arePointsEqual(point1, point2);
|
||||
}
|
||||
|
||||
static handlePointerMove(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
appState: AppState,
|
||||
): LinearElementEditor | null {
|
||||
if (!appState.editingLinearElement) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
editingLinearElement: LinearElementEditor,
|
||||
gridSize: number | null,
|
||||
): LinearElementEditor {
|
||||
const { elementId, lastUncommittedPoint } = editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@ -761,36 +482,15 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
||||
}
|
||||
|
||||
let newPoint: Point;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||
const lastCommittedPoint = points[points.length - 2];
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
const newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
@ -800,10 +500,11 @@ export class LinearElementEditor {
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
}
|
||||
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
...editingLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@ -825,14 +526,14 @@ export class LinearElementEditor {
|
||||
/** scene coords */
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
): Point[] {
|
||||
) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
return element.points.map((point) => {
|
||||
let { x, y } = element;
|
||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
return [x, y] as const;
|
||||
return [x, y];
|
||||
});
|
||||
}
|
||||
|
||||
@ -850,9 +551,7 @@ export class LinearElementEditor {
|
||||
|
||||
const point = element.points[index];
|
||||
const { x, y } = element;
|
||||
return point
|
||||
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
|
||||
: rotate(x, y, cx, cy, element.angle);
|
||||
return rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
@ -878,8 +577,7 @@ export class LinearElementEditor {
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const pointHandles =
|
||||
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
const pointHandles = this.getPointsGlobalCoordinates(element);
|
||||
let idx = pointHandles.length;
|
||||
// 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
|
||||
@ -889,7 +587,7 @@ export class LinearElementEditor {
|
||||
if (
|
||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
||||
// +1px to account for outline stroke
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
this.POINT_HANDLE_SIZE / 2 + 1
|
||||
) {
|
||||
return idx;
|
||||
}
|
||||
@ -1048,7 +746,6 @@ export class LinearElementEditor {
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
targetPoints: { point: Point }[],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
@ -1078,9 +775,9 @@ export class LinearElementEditor {
|
||||
|
||||
if (selectedOriginPoint) {
|
||||
offsetX =
|
||||
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
|
||||
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
||||
offsetY =
|
||||
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
||||
}
|
||||
|
||||
const nextPoints = points.map((point, idx) => {
|
||||
@ -1143,33 +840,6 @@ export class LinearElementEditor {
|
||||
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 = (
|
||||
|
@ -21,12 +21,7 @@ import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
import { getContainerElement, measureText, wrapText } from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
@ -169,8 +164,7 @@ const getAdjustedDimensions = (
|
||||
let maxWidth = null;
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
const {
|
||||
width: nextWidth,
|
||||
@ -204,7 +198,6 @@ const getAdjustedDimensions = (
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
@ -230,16 +223,15 @@ const getAdjustedDimensions = (
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
let height = container.height;
|
||||
let width = container.width;
|
||||
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
||||
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
||||
width = nextWidth + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (height !== containerDims.height || width !== containerDims.width) {
|
||||
if (height !== container.height || width !== container.width) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
@ -252,16 +244,8 @@ const getAdjustedDimensions = (
|
||||
};
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
element: ExcalidrawTextElement,
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
@ -272,19 +256,15 @@ export const updateTextElement = (
|
||||
originalText: string;
|
||||
},
|
||||
): ExcalidrawTextElement => {
|
||||
const container = getContainerElement(textElement);
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
text = wrapText(
|
||||
originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
);
|
||||
text = wrapText(text, getFontString(element), container.width);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
return newElementWith(textElement, {
|
||||
const dimensions = getAdjustedDimensions(element, text);
|
||||
return newElementWith(element, {
|
||||
text,
|
||||
originalText,
|
||||
isDeleted: isDeleted ?? textElement.isDeleted,
|
||||
isDeleted: isDeleted ?? element.isDeleted,
|
||||
...dimensions,
|
||||
});
|
||||
};
|
||||
@ -327,9 +307,6 @@ export const newLinearElement = (
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
status?: ExcalidrawImageElement["status"];
|
||||
fileId?: ExcalidrawImageElement["fileId"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
@ -337,9 +314,9 @@ export const newImageElement = (
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: opts.status ?? "pending",
|
||||
fileId: opts.fileId ?? null,
|
||||
scale: opts.scale ?? [1, 1],
|
||||
status: "pending",
|
||||
fileId: null,
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -18,7 +18,6 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
getCommonBoundingBox,
|
||||
} from "./bounds";
|
||||
import {
|
||||
isFreeDrawElement,
|
||||
@ -138,10 +137,8 @@ export const transformElements = (
|
||||
transformHandleType === "se"
|
||||
) {
|
||||
resizeMultipleElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@ -264,15 +261,13 @@ const rescalePointsInElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
normalizePoints: boolean,
|
||||
) =>
|
||||
isLinearElement(element) || isFreeDrawElement(element)
|
||||
? {
|
||||
points: rescalePoints(
|
||||
0,
|
||||
width,
|
||||
rescalePoints(1, height, element.points, normalizePoints),
|
||||
normalizePoints,
|
||||
rescalePoints(1, height, element.points),
|
||||
),
|
||||
}
|
||||
: {};
|
||||
@ -376,7 +371,6 @@ const resizeSingleTextElement = (
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
@ -418,7 +412,6 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
@ -436,7 +429,6 @@ export const resizeSingleElement = (
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
true,
|
||||
);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
@ -530,7 +522,6 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
@ -601,7 +592,6 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
// 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
|
||||
@ -647,147 +637,146 @@ export const resizeSingleElement = (
|
||||
};
|
||||
|
||||
const resizeMultipleElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
// map selected elements to the original elements. While it never should
|
||||
// happen that pointerDownState.originalElements won't contain the selected
|
||||
// elements during resize, this coupling isn't guaranteed, so to ensure
|
||||
// type safety we need to transform only those elements we filter.
|
||||
const targetElements = selectedElements.reduce(
|
||||
(
|
||||
acc: {
|
||||
/** element at resize start */
|
||||
orig: NonDeletedExcalidrawElement;
|
||||
/** latest element */
|
||||
latest: NonDeletedExcalidrawElement;
|
||||
}[],
|
||||
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,
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||
let scale: number;
|
||||
let getNextXY: (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
origCoords: readonly [number, number, number, number],
|
||||
finalCoords: readonly [number, number, number, number],
|
||||
) => { x: number; y: number };
|
||||
switch (transformHandleType) {
|
||||
case "se":
|
||||
scale = Math.max(
|
||||
(pointerX - x1) / (x2 - x1),
|
||||
(pointerY - y1) / (y2 - y1),
|
||||
);
|
||||
if (textMeasurements) {
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
|
||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
||||
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) {
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
boundTextElement,
|
||||
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) {
|
||||
mutateElement(boundTextElement, boundTextUpdates);
|
||||
handleBindTextResize(element.latest, transformHandleType);
|
||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
||||
|
||||
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 = (
|
||||
|
@ -1,51 +1,49 @@
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(width).toEqual(149);
|
||||
expect(height).toEqual(0);
|
||||
});
|
||||
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);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(140);
|
||||
});
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(width).toEqual(200);
|
||||
expect(height).toEqual(0);
|
||||
});
|
||||
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);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(100);
|
||||
});
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
expect(width).toEqual(120);
|
||||
expect(height).toEqual(208);
|
||||
});
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(width).toEqual(135);
|
||||
expect(height).toEqual(135);
|
||||
});
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(0);
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(width).toEqual(120);
|
||||
expect(height).toEqual(120);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -37,7 +37,9 @@ export const getPerfectElementSize = (
|
||||
} else if (lockedAngle === Math.PI / 2) {
|
||||
width = 0;
|
||||
} 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") {
|
||||
height = absWidth * Math.sign(height);
|
||||
@ -45,46 +47,6 @@ export const getPerfectElementSize = (
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user