Compare commits

..

1 Commits

Author SHA1 Message Date
60cf0b92fd Add VS Code to the integration section
Since the obsidian plugin is there, I think it makes sense.
2022-07-21 22:08:31 +02:00
291 changed files with 11431 additions and 25249 deletions

View File

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

View File

@ -10,16 +10,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - uses: actions/checkout@v2
uses: actions/checkout@v3 - uses: docker/build-push-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push repository: excalidraw/excalidraw
uses: docker/build-push-action@v3 tag_with_ref: true
with: tag_with_sha: true
context: .
push: true
tags: excalidraw/excalidraw:latest

2
.gitignore vendored
View File

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

View File

@ -34,7 +34,7 @@ Last but not least, we're thankful to these companies for offering their service
## Who's integrating Excalidraw ## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) • [Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) • [VS Code](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor)
## Documentation ## Documentation
@ -88,7 +88,7 @@ Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/exc
### Code Sandbox ### Code Sandbox
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw - Go to https://codesandbox.io/s/github/excalidraw/excalidraw
- You may need to sign in with GitHub and reload the page - You may need to sign in with GitHub and reload the page
- You can start coding instantly, and even send PRs from there! - You can start coding instantly, and even send PRs from there!

20
dev-docs/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

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

Before

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -26,8 +26,8 @@
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.7.1",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "18.0.15", "@types/react": "17.0.39",
"@types/react-dom": "18.0.6", "@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
@ -41,15 +41,14 @@
"nanoid": "3.3.3", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.2.0", "perfect-freehand": "1.0.16",
"pica": "7.1.1",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0", "points-on-curve": "0.2.0",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "18.2.0", "react": "17.0.2",
"react-dom": "18.2.0", "react-dom": "17.0.2",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
@ -74,6 +73,9 @@
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0" "rewire": "6.0.0"
}, },
"resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2"
},
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
}, },

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -8,76 +8,49 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
/> />
<meta name="referrer" content="origin" /> <meta name="referrer" content="origin" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#121212" />
<!-- Primary Meta Tags --> <meta name="theme-color" content="#000" />
<meta
name="title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta name="image" content="https://excalidraw.com/og-general-v1.png" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://excalidraw.com" />
<meta
property="og:title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta property="og:image:alt" content="Excalidraw logo" />
<meta
property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta property="og:image" content="https://excalidraw.com/og-fb-v1.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:site" content="@excalidraw" />
<meta property="twitter:url" content="https://excalidraw.com" />
<meta
property="twitter:title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
property="twitter:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta
property="twitter:image"
content="https://excalidraw.com/og-twitter-v1.png"
/>
<!-- General tags --> <!-- General tags -->
<meta <meta
name="description" name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta name="image" content="og-image.png" />
<!-------------------------------------------------------------------------> <!-- OpenGraph tags -->
<!-- to minimize white flash on load when user has dark mode enabled --> <meta property="og:url" content="https://excalidraw.com" />
<script> <meta property="og:site_name" content="Excalidraw" />
try { <meta property="og:type" content="website" />
// <meta property="og:title" content="Excalidraw" />
const theme = window.localStorage.getItem("excalidraw-theme"); <meta
if (theme === "dark") { property="og:description"
document.documentElement.classList.add("dark"); content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
} />
} catch {} <!-- OG tags require an absolute url for images -->
</script> <meta
<style> property="og:image"
html.dark { name="twitter:image"
background-color: #121212; content="https://excalidraw.com/og-image.png"
color: #fff; />
} <meta
</style> property="og:image:secure_url"
<!-------------------------------------------------------------------------> name="twitter:image"
content="https://excalidraw.com/og-image.png"
/>
<meta property="og:image:width" content="1280" />
<meta property="og:image:height" content="669" />
<meta property="og:image:alt" content="Excalidraw logo with byline." />
<!-- Twitter Card tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Excalidraw" />
<meta
name="twitter:description"
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<script> <script>
// Redirect Excalidraw+ users which have auto-redirect enabled. // Redirect Excalidraw+ users which have auto-redirect enabled.
@ -125,7 +98,7 @@
/> />
<link rel="stylesheet" href="fonts.css" type="text/css" /> <link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %> <% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
<script> <script>
{ {
const _WebSocket = window.WebSocket; const _WebSocket = window.WebSocket;
@ -166,8 +139,8 @@
body, body,
html { html {
margin: 0; margin: 0;
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Segoe UI, Roboto, Helvetica, Arial, sans-serif; Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font); font-family: var(--ui-font);
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@ -182,7 +155,7 @@
width: 1px; width: 1px;
overflow: hidden; overflow: hidden;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; white-space: nowrap; /* added line */
user-select: none; user-select: none;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

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

View File

@ -1,21 +0,0 @@
const { exec } = require("child_process");
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const docFiles = changedFiles.filter((file) => {
return file.indexOf("docs") >= 0;
});
if (!docFiles.length) {
console.info("Skipping building docs as no valid diff found");
process.exit(0);
}
// Exit code 1 to build the docs in ignoredBuildStep
process.exit(1);
});

View File

@ -15,7 +15,6 @@ const crowdinMap = {
"fa-IR": "en-fa", "fa-IR": "en-fa",
"fi-FI": "en-fi", "fi-FI": "en-fi",
"fr-FR": "en-fr", "fr-FR": "en-fr",
"gl-ES": "en-gl",
"he-IL": "en-he", "he-IL": "en-he",
"hi-IN": "en-hi", "hi-IN": "en-hi",
"hu-HU": "en-hu", "hu-HU": "en-hu",
@ -24,7 +23,6 @@ const crowdinMap = {
"ja-JP": "en-ja", "ja-JP": "en-ja",
"kab-KAB": "en-kab", "kab-KAB": "en-kab",
"ko-KR": "en-ko", "ko-KR": "en-ko",
"ku-TR": "en-ku",
"my-MM": "en-my", "my-MM": "en-my",
"nb-NO": "en-nb", "nb-NO": "en-nb",
"nl-NL": "en-nl", "nl-NL": "en-nl",
@ -67,7 +65,6 @@ const flags = {
"fa-IR": "🇮🇷", "fa-IR": "🇮🇷",
"fi-FI": "🇫🇮", "fi-FI": "🇫🇮",
"fr-FR": "🇫🇷", "fr-FR": "🇫🇷",
"gl-ES": "🇪🇸",
"he-IL": "🇮🇱", "he-IL": "🇮🇱",
"hi-IN": "🇮🇳", "hi-IN": "🇮🇳",
"hu-HU": "🇭🇺", "hu-HU": "🇭🇺",
@ -77,7 +74,6 @@ const flags = {
"kab-KAB": "🏳", "kab-KAB": "🏳",
"kk-KZ": "🇰🇿", "kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷", "ko-KR": "🇰🇷",
"ku-TR": "🏳",
"lt-LT": "🇱🇹", "lt-LT": "🇱🇹",
"lv-LV": "🇱🇻", "lv-LV": "🇱🇻",
"my-MM": "🇲🇲", "my-MM": "🇲🇲",

View File

@ -1,12 +1,11 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path");
// for development purposes we want to have the service-worker.js file // 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 // accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder. // and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => { const moveServiceWorkerScript = () => {
const oldPath = path.resolve(__dirname, "../public/service-worker.js"); const oldPath = "./public/service-worker.js";
const newPath = path.resolve(__dirname, "../src/service-worker.js"); const newPath = "./src/service-worker.js";
fs.rename(oldPath, newPath, (error) => { fs.rename(oldPath, newPath, (error) => {
if (error) { if (error) {

View File

@ -60,7 +60,7 @@ export const actionAlignTop = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignTopIcon} icon={<AlignTopIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignTop")}${getShortcutKey( title={`${t("labels.alignTop")}${getShortcutKey(
"CtrlOrCmd+Shift+Up", "CtrlOrCmd+Shift+Up",
@ -90,7 +90,7 @@ export const actionAlignBottom = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignBottomIcon} icon={<AlignBottomIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignBottom")}${getShortcutKey( title={`${t("labels.alignBottom")}${getShortcutKey(
"CtrlOrCmd+Shift+Down", "CtrlOrCmd+Shift+Down",
@ -120,7 +120,7 @@ export const actionAlignLeft = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignLeftIcon} icon={<AlignLeftIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignLeft")}${getShortcutKey( title={`${t("labels.alignLeft")}${getShortcutKey(
"CtrlOrCmd+Shift+Left", "CtrlOrCmd+Shift+Left",
@ -151,7 +151,7 @@ export const actionAlignRight = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignRightIcon} icon={<AlignRightIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignRight")}${getShortcutKey( title={`${t("labels.alignRight")}${getShortcutKey(
"CtrlOrCmd+Shift+Right", "CtrlOrCmd+Shift+Right",
@ -180,7 +180,7 @@ export const actionAlignVerticallyCentered = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={<CenterVerticallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerVertically")} title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")} aria-label={t("labels.centerVertically")}
@ -206,7 +206,7 @@ export const actionAlignHorizontallyCentered = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={<CenterHorizontallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerHorizontally")} title={t("labels.centerHorizontally")}
aria-label={t("labels.centerHorizontally")} aria-label={t("labels.centerHorizontally")}

View File

@ -1,13 +1,8 @@
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { import { eraser, zoomIn, zoomOut } from "../components/icons";
eraser,
MoonIcon,
SunIcon,
ZoomInIcon,
ZoomOutIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@ -23,8 +18,6 @@ import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState"; import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas"; import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx"; import clsx from "clsx";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -110,13 +103,13 @@ export const actionZoomIn = register({
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
className="zoom-in-button zoom-button" icon={zoomIn}
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`} title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")} aria-label={t("buttons.zoomIn")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@ -146,13 +139,13 @@ export const actionZoomOut = register({
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
className="zoom-out-button zoom-button" icon={zoomOut}
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`} title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")} aria-label={t("buttons.zoomOut")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@ -183,12 +176,13 @@ export const actionResetZoom = register({
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
<ToolButton <ToolButton
type="button" type="button"
className="reset-zoom-button zoom-button" className="reset-zoom-button"
title={t("buttons.resetZoom")} title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")} aria-label={t("buttons.resetZoom")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
> >
{(appState.zoom.value * 100).toFixed(0)}% {(appState.zoom.value * 100).toFixed(0)}%
</ToolButton> </ToolButton>
@ -212,7 +206,7 @@ const zoomValueToFitBoundsOnViewport = (
const zoomAdjustedToSteps = const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min( const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM), Math.max(zoomAdjustedToSteps, ZOOM_STEP),
1, 1,
); );
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
@ -294,19 +288,14 @@ export const actionToggleTheme = register({
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<MenuItem <div style={{ marginInlineStart: "0.25rem" }}>
label={ <DarkModeToggle
appState.theme === "dark" value={appState.theme}
? t("buttons.lightMode") onChange={(theme) => {
: t("buttons.darkMode") updateData(theme);
} }}
onClick={() => { />
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT); </div>
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
dataTestId="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
/>
), ),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
}); });

View File

@ -36,7 +36,7 @@ export const actionCut = register({
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
}, },
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
}); });
export const actionCopyAsSvg = register({ export const actionCopyAsSvg = register({

View File

@ -1,6 +1,7 @@
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { trash } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
@ -12,7 +13,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -72,22 +72,13 @@ export const actionDeleteSelected = register({
if (!element) { if (!element) {
return false; return false;
} }
// case: no point selected → do nothing, as deleting the whole element if (
// is most likely a mistake, where you wanted to delete a specific point // case: no point selected delete whole element
// but failed to select it (or you thought it's selected, while it was selectedPointsIndices == null ||
// only in a hover state) // case: deleting last remaining point
if (selectedPointsIndices == null) { element.points.length < 2
return false; ) {
} const nextElements = elements.filter((el) => el.id !== element.id);
// case: deleting last remaining point
if (element.points.length < 2) {
const nextElements = elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
const nextAppState = handleGroupEditingState(appState, nextElements); const nextAppState = handleGroupEditingState(appState, nextElements);
return { return {
@ -158,7 +149,7 @@ export const actionDeleteSelected = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={TrashIcon} icon={trash}
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}

View File

@ -56,7 +56,7 @@ export const distributeHorizontally = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={DistributeHorizontallyIcon} icon={<DistributeHorizontallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeHorizontally")}${getShortcutKey( title={`${t("labels.distributeHorizontally")}${getShortcutKey(
"Alt+H", "Alt+H",
@ -86,7 +86,7 @@ export const distributeVertically = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={DistributeVerticallyIcon} icon={<DistributeVerticallyIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`} title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`}
aria-label={t("labels.distributeVertically")} aria-label={t("labels.distributeVertically")}

View File

@ -4,6 +4,7 @@ import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element"; import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@ -18,7 +19,6 @@ import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { bindTextToShapeAfterDuplication } from "../element/textElement"; import { bindTextToShapeAfterDuplication } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { DuplicateIcon } from "../components/icons";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -49,7 +49,7 @@ export const actionDuplicateSelection = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={DuplicateIcon} icon={clone}
title={`${t("labels.duplicateSelection")}${getShortcutKey( title={`${t("labels.duplicateSelection")}${getShortcutKey(
"CtrlOrCmd+D", "CtrlOrCmd+D",
)}`} )}`}

View File

@ -1,4 +1,4 @@
import { LoadIcon, questionCircle, saveAs } from "../components/icons"; import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
@ -19,8 +19,6 @@ import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types"; import { Theme } from "../element/types";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
@ -246,13 +244,15 @@ export const actionLoadScene = register({
} }
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData, appState }) => (
<MenuItem <ToolButton
label={t("buttons.load")} type="button"
icon={LoadIcon} icon={load}
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useDevice().isMobile}
onClick={updateData} onClick={updateData}
dataTestId="load-button" data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
/> />
), ),
}); });

View File

@ -13,7 +13,7 @@ import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "../element/binding"; } from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { isBindingElement } from "../element/typeChecks";
import { AppState } from "../types"; import { AppState } from "../types";
export const actionFinalize = register({ export const actionFinalize = register({
@ -181,11 +181,6 @@ export const actionFinalize = register({
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement, scene)
: appState.selectedLinearElement,
pendingImageElementId: null, pendingImageElementId: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.activeTool.type === "freedraw",

View File

@ -6,14 +6,10 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types"; import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles"; import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding"; import { updateBoundElements } from "../element/binding";
import { arrayToMap } from "../utils";
import {
getElementAbsoluteCoords,
getElementPointsCoords,
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
const enableActionFlipHorizontal = ( const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -122,6 +118,13 @@ const flipElement = (
const height = element.height; const height = element.height;
const originalAngle = normalizeAngle(element.angle); const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
}
// Rotate back to zero, if necessary // Rotate back to zero, if necessary
mutateElement(element, { mutateElement(element, {
angle: normalizeAngle(0), angle: normalizeAngle(0),
@ -129,6 +132,7 @@ const flipElement = (
// Flip unrotated by pulling TransformHandle to opposite side // Flip unrotated by pulling TransformHandle to opposite side
const transformHandles = getTransformHandles(element, appState.zoom); const transformHandles = getTransformHandles(element, appState.zoom);
let usingNWHandle = true; let usingNWHandle = true;
let newNCoordsX = 0;
let nHandle = transformHandles.nw; let nHandle = transformHandles.nw;
if (!nHandle) { if (!nHandle) {
// Use ne handle instead // Use ne handle instead
@ -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)) { 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++) { for (let index = 1; index < element.points.length; index++) {
LinearElementEditor.movePoints(element, [ 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); LinearElementEditor.normalizePoints(element);
} else { } else {
const elWidth = initialPointsCoords // calculate new x-coord for transformation
? initialPointsCoords[2] - initialPointsCoords[0] newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
const startPoint = initialPointsCoords
? [initialPointsCoords[0], initialPointsCoords[1]]
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
resizeSingleElement( resizeSingleElement(
new Map().set(element.id, element), new Map().set(element.id, element),
false, true,
element, element,
usingNWHandle ? "nw" : "ne", usingNWHandle ? "nw" : "ne",
true, false,
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth, newNCoordsX,
startPoint[1], nHandle[1],
); );
// fix the size to account for handle sizes
mutateElement(element, {
width,
height,
});
} }
// Rotate by (360 degrees - original angle) // Rotate by (360 degrees - original angle)
@ -203,34 +186,9 @@ const flipElement = (
mutateElement(element, { mutateElement(element, {
x: originalX + finalOffsetX, x: originalX + finalOffsetX,
y: originalY, y: originalY,
width,
height,
}); });
updateBoundElements(element); updateBoundElements(element);
if (initialPointsCoords && isLinearElement(element)) {
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
// There's still room for improvement since when the line roughness is > 1
// we still have a small offset of the origin when fliipping the element.
const finalPointsCoords = getElementPointsCoords(
element,
element.points,
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) => { const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {

View File

@ -1,4 +1,4 @@
import { KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -132,7 +132,7 @@ export const actionGroup = register({
contextItemPredicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState), enableActionGroup(elements, appState),
keyTest: (event) => keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
@ -189,9 +189,7 @@ export const actionUngroup = register({
}; };
}, },
keyTest: (event) => keyTest: (event) =>
event.shiftKey && event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(),
contextItemLabel: "labels.ungroup", contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0, getSelectedGroupIds(appState).length > 0,

View File

@ -1,5 +1,5 @@
import { Action, ActionResult } from "./types"; import { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons"; import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import History, { HistoryEntry } from "../history"; import History, { HistoryEntry } from "../history";
@ -72,7 +72,7 @@ export const createUndoAction: ActionCreator = (history) => ({
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={UndoIcon} icon={undo}
aria-label={t("buttons.undo")} aria-label={t("buttons.undo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
@ -94,7 +94,7 @@ export const createRedoAction: ActionCreator = (history) => ({
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={RedoIcon} icon={redo}
aria-label={t("buttons.redo")} aria-label={t("buttons.redo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}

View File

@ -1,49 +0,0 @@
import { getNonDeletedElements } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
trackEvent: {
category: "element",
},
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";
},
});

View File

@ -1,12 +1,11 @@
import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons"; import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { HelpButton } from "../components/HelpButton"; import { HelpIcon } from "../components/HelpIcon";
import MenuItem from "../components/MenuItem";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@ -21,7 +20,7 @@ export const actionToggleCanvasMenu = register({
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={HamburgerMenuIcon} icon={menu}
aria-label={t("buttons.menu")} aria-label={t("buttons.menu")}
onClick={updateData} onClick={updateData}
selected={appState.openMenu === "canvas"} selected={appState.openMenu === "canvas"}
@ -68,35 +67,26 @@ export const actionFullScreen = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD], keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
}); });
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" }, trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") { if (appState.showHelpDialog) {
focusContainer(); focusContainer();
} }
return { return {
appState: { appState: {
...appState, ...appState,
openDialog: appState.openDialog === "help" ? null : "help", showHelpDialog: !appState.showHelpDialog,
}, },
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData, isInHamburgerMenu }) => PanelComponent: ({ updateData }) => (
isInHamburgerMenu ? ( <HelpIcon title={t("helpDialog.title")} onClick={updateData} />
<MenuItem ),
label={t("helpDialog.title")}
dataTestId="help-menu-item"
icon={HelpIcon}
onClick={updateData}
shortcut="?"
/>
) : (
<HelpButton title={t("helpDialog.title")} onClick={updateData} />
),
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
}); });

View File

@ -2,41 +2,37 @@ import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
import { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadDotIcon,
ArrowheadTriangleIcon, ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
StrokeStyleDashedIcon, EdgeRoundIcon,
StrokeStyleDottedIcon, EdgeSharpIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
FillHachureIcon,
FillCrossHatchIcon, FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon, FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon, SloppinessArchitectIcon,
SloppinessArtistIcon, SloppinessArtistIcon,
SloppinessCartoonistIcon, SloppinessCartoonistIcon,
StrokeWidthBaseIcon, StrokeStyleDashedIcon,
StrokeWidthBoldIcon, StrokeStyleDottedIcon,
StrokeWidthExtraBoldIcon, StrokeStyleSolidIcon,
FontSizeSmallIcon, StrokeWidthIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon, TextAlignRightIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@ -311,17 +307,17 @@ export const actionChangeFillStyle = register({
{ {
value: "hachure", value: "hachure",
text: t("labels.hachure"), text: t("labels.hachure"),
icon: FillHachureIcon, icon: <FillHachureIcon theme={appState.theme} />,
}, },
{ {
value: "cross-hatch", value: "cross-hatch",
text: t("labels.crossHatch"), text: t("labels.crossHatch"),
icon: FillCrossHatchIcon, icon: <FillCrossHatchIcon theme={appState.theme} />,
}, },
{ {
value: "solid", value: "solid",
text: t("labels.solid"), text: t("labels.solid"),
icon: FillSolidIcon, icon: <FillSolidIcon theme={appState.theme} />,
}, },
]} ]}
group="fill" group="fill"
@ -362,17 +358,17 @@ export const actionChangeStrokeWidth = register({
{ {
value: 1, value: 1,
text: t("labels.thin"), text: t("labels.thin"),
icon: StrokeWidthBaseIcon, icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
}, },
{ {
value: 2, value: 2,
text: t("labels.bold"), text: t("labels.bold"),
icon: StrokeWidthBoldIcon, icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
}, },
{ {
value: 4, value: 4,
text: t("labels.extraBold"), text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon, icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -411,17 +407,17 @@ export const actionChangeSloppiness = register({
{ {
value: 0, value: 0,
text: t("labels.architect"), text: t("labels.architect"),
icon: SloppinessArchitectIcon, icon: <SloppinessArchitectIcon theme={appState.theme} />,
}, },
{ {
value: 1, value: 1,
text: t("labels.artist"), text: t("labels.artist"),
icon: SloppinessArtistIcon, icon: <SloppinessArtistIcon theme={appState.theme} />,
}, },
{ {
value: 2, value: 2,
text: t("labels.cartoonist"), text: t("labels.cartoonist"),
icon: SloppinessCartoonistIcon, icon: <SloppinessCartoonistIcon theme={appState.theme} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -459,17 +455,17 @@ export const actionChangeStrokeStyle = register({
{ {
value: "solid", value: "solid",
text: t("labels.strokeStyle_solid"), text: t("labels.strokeStyle_solid"),
icon: StrokeWidthBaseIcon, icon: <StrokeStyleSolidIcon theme={appState.theme} />,
}, },
{ {
value: "dashed", value: "dashed",
text: t("labels.strokeStyle_dashed"), text: t("labels.strokeStyle_dashed"),
icon: StrokeStyleDashedIcon, icon: <StrokeStyleDashedIcon theme={appState.theme} />,
}, },
{ {
value: "dotted", value: "dotted",
text: t("labels.strokeStyle_dotted"), text: t("labels.strokeStyle_dotted"),
icon: StrokeStyleDottedIcon, icon: <StrokeStyleDottedIcon theme={appState.theme} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -539,25 +535,25 @@ export const actionChangeFontSize = register({
{ {
value: 16, value: 16,
text: t("labels.small"), text: t("labels.small"),
icon: FontSizeSmallIcon, icon: <FontSizeSmallIcon theme={appState.theme} />,
testId: "fontSize-small", testId: "fontSize-small",
}, },
{ {
value: 20, value: 20,
text: t("labels.medium"), text: t("labels.medium"),
icon: FontSizeMediumIcon, icon: <FontSizeMediumIcon theme={appState.theme} />,
testId: "fontSize-medium", testId: "fontSize-medium",
}, },
{ {
value: 28, value: 28,
text: t("labels.large"), text: t("labels.large"),
icon: FontSizeLargeIcon, icon: <FontSizeLargeIcon theme={appState.theme} />,
testId: "fontSize-large", testId: "fontSize-large",
}, },
{ {
value: 36, value: 36,
text: t("labels.veryLarge"), text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon, icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
testId: "fontSize-veryLarge", testId: "fontSize-veryLarge",
}, },
]} ]}
@ -662,17 +658,17 @@ export const actionChangeFontFamily = register({
{ {
value: FONT_FAMILY.Virgil, value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
icon: FreedrawIcon, icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
}, },
{ {
value: FONT_FAMILY.Helvetica, value: FONT_FAMILY.Helvetica,
text: t("labels.normal"), text: t("labels.normal"),
icon: FontFamilyNormalIcon, icon: <FontFamilyNormalIcon theme={appState.theme} />,
}, },
{ {
value: FONT_FAMILY.Cascadia, value: FONT_FAMILY.Cascadia,
text: t("labels.code"), text: t("labels.code"),
icon: FontFamilyCodeIcon, icon: <FontFamilyCodeIcon theme={appState.theme} />,
}, },
]; ];
@ -743,17 +739,17 @@ export const actionChangeTextAlign = register({
{ {
value: "left", value: "left",
text: t("labels.left"), text: t("labels.left"),
icon: TextAlignLeftIcon, icon: <TextAlignLeftIcon theme={appState.theme} />,
}, },
{ {
value: "center", value: "center",
text: t("labels.center"), text: t("labels.center"),
icon: TextAlignCenterIcon, icon: <TextAlignCenterIcon theme={appState.theme} />,
}, },
{ {
value: "right", value: "right",
text: t("labels.right"), text: t("labels.right"),
icon: TextAlignRightIcon, icon: <TextAlignRightIcon theme={appState.theme} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -886,12 +882,12 @@ export const actionChangeSharpness = register({
{ {
value: "sharp", value: "sharp",
text: t("labels.sharp"), text: t("labels.sharp"),
icon: EdgeSharpIcon, icon: <EdgeSharpIcon theme={appState.theme} />,
}, },
{ {
value: "round", value: "round",
text: t("labels.round"), text: t("labels.round"),
icon: EdgeRoundIcon, icon: <EdgeRoundIcon theme={appState.theme} />,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -953,38 +949,42 @@ export const actionChangeArrowhead = register({
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList">
<IconPicker <IconPicker
label="arrowhead_start" label="arrowhead_start"
options={[ options={[
{ {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
icon: ArrowheadNoneIcon, icon: <ArrowheadNoneIcon theme={appState.theme} />,
keyBinding: "q", keyBinding: "q",
}, },
{ {
value: "arrow", value: "arrow",
text: t("labels.arrowhead_arrow"), text: t("labels.arrowhead_arrow"),
icon: <ArrowheadArrowIcon flip={!isRTL} />, icon: (
<ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "w", keyBinding: "w",
}, },
{ {
value: "bar", value: "bar",
text: t("labels.arrowhead_bar"), text: t("labels.arrowhead_bar"),
icon: <ArrowheadBarIcon flip={!isRTL} />, icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "e", keyBinding: "e",
}, },
{ {
value: "dot", value: "dot",
text: t("labels.arrowhead_dot"), text: t("labels.arrowhead_dot"),
icon: <ArrowheadDotIcon flip={!isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r", keyBinding: "r",
}, },
{ {
value: "triangle", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={!isRTL} />, icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "t", keyBinding: "t",
}, },
]} ]}
@ -1007,30 +1007,34 @@ export const actionChangeArrowhead = register({
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: ArrowheadNoneIcon, icon: <ArrowheadNoneIcon theme={appState.theme} />,
}, },
{ {
value: "arrow", value: "arrow",
text: t("labels.arrowhead_arrow"), text: t("labels.arrowhead_arrow"),
keyBinding: "w", keyBinding: "w",
icon: <ArrowheadArrowIcon flip={isRTL} />, icon: (
<ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
),
}, },
{ {
value: "bar", value: "bar",
text: t("labels.arrowhead_bar"), text: t("labels.arrowhead_bar"),
keyBinding: "e", keyBinding: "e",
icon: <ArrowheadBarIcon flip={isRTL} />, icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
}, },
{ {
value: "dot", value: "dot",
text: t("labels.arrowhead_dot"), text: t("labels.arrowhead_dot"),
keyBinding: "r", keyBinding: "r",
icon: <ArrowheadDotIcon flip={isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
}, },
{ {
value: "triangle", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={isRTL} />, icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
),
keyBinding: "t", keyBinding: "t",
}, },
]} ]}

View File

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

View File

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

View File

@ -10,10 +10,10 @@ import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { import {
BringForwardIcon,
BringToFrontIcon,
SendBackwardIcon, SendBackwardIcon,
BringToFrontIcon,
SendToBackIcon, SendToBackIcon,
BringForwardIcon,
} from "../components/icons"; } from "../components/icons";
export const actionSendBackward = register({ export const actionSendBackward = register({
@ -39,7 +39,7 @@ export const actionSendBackward = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`} title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
> >
{SendBackwardIcon} <SendBackwardIcon theme={appState.theme} />
</button> </button>
), ),
}); });
@ -67,7 +67,7 @@ export const actionBringForward = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`} title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
> >
{BringForwardIcon} <BringForwardIcon theme={appState.theme} />
</button> </button>
), ),
}); });
@ -102,7 +102,7 @@ export const actionSendToBack = register({
: getShortcutKey("CtrlOrCmd+Shift+[") : getShortcutKey("CtrlOrCmd+Shift+[")
}`} }`}
> >
{SendToBackIcon} <SendToBackIcon theme={appState.theme} />
</button> </button>
), ),
}); });
@ -138,7 +138,7 @@ export const actionBringToFront = register({
: getShortcutKey("CtrlOrCmd+Shift+]") : getShortcutKey("CtrlOrCmd+Shift+]")
}`} }`}
> >
{BringToFrontIcon} <BringToFrontIcon theme={appState.theme} />
</button> </button>
), ),
}); });

View File

@ -85,4 +85,3 @@ export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink"; export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock"; export { actionToggleLock } from "./actionToggleLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";

View File

@ -135,11 +135,7 @@ export class ActionManager {
/** /**
* @param data additional data sent to the PanelComponent * @param data additional data sent to the PanelComponent
*/ */
renderAction = ( renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
name: ActionName,
data?: PanelComponentProps["data"],
isInHamburgerMenu = false,
) => {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
if ( if (
@ -151,7 +147,6 @@ export class ActionManager {
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
PanelComponent.displayName = "PanelComponent";
const elements = this.getElementsIncludingDeleted(); const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const updateData = (formState?: any) => { const updateData = (formState?: any) => {
@ -174,7 +169,6 @@ export class ActionManager {
updateData={updateData} updateData={updateData}
appProps={this.app.props} appProps={this.app.props}
data={data} data={data}
isInHamburgerMenu={isInHamburgerMenu}
/> />
); );
} }

View File

@ -3,45 +3,36 @@ import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types"; import { ActionName } from "./types";
export type ShortcutName = export type ShortcutName = SubtypeOf<
| SubtypeOf< ActionName,
ActionName, | "cut"
| "toggleTheme" | "copy"
| "loadScene" | "paste"
| "cut" | "copyStyles"
| "copy" | "pasteStyles"
| "paste" | "selectAll"
| "copyStyles" | "deleteSelectedElements"
| "pasteStyles" | "duplicateSelection"
| "selectAll" | "sendBackward"
| "deleteSelectedElements" | "bringForward"
| "duplicateSelection" | "sendToBack"
| "sendBackward" | "bringToFront"
| "bringForward" | "copyAsPng"
| "sendToBack" | "copyAsSvg"
| "bringToFront" | "group"
| "copyAsPng" | "ungroup"
| "copyAsSvg" | "gridMode"
| "group" | "zenMode"
| "ungroup" | "stats"
| "gridMode" | "addToLibrary"
| "zenMode" | "viewMode"
| "stats" | "flipHorizontal"
| "addToLibrary" | "flipVertical"
| "viewMode" | "hyperlink"
| "flipHorizontal" | "toggleLock"
| "flipVertical" >;
| "hyperlink"
| "toggleLock"
>
| "saveScene"
| "imageExport";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")],
loadScene: [getShortcutKey("CtrlOrCmd+O")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")], copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")], paste: [getShortcutKey("CtrlOrCmd+V")],

View File

@ -111,8 +111,7 @@ export type ActionName =
| "hyperlink" | "hyperlink"
| "eraser" | "eraser"
| "bindText" | "bindText"
| "toggleLock" | "toggleLock";
| "toggleLinearEditor";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@ -124,9 +123,7 @@ export type PanelComponentProps = {
export interface Action { export interface Action {
name: ActionName; name: ActionName;
PanelComponent?: React.FC< PanelComponent?: React.FC<PanelComponentProps>;
PanelComponentProps & { isInHamburgerMenu: boolean }
>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (

View File

@ -19,7 +19,6 @@ export const getDefaultAppState = (): Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> => { > => {
return { return {
showWelcomeScreen: false,
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
@ -58,7 +57,8 @@ export const getDefaultAppState = (): Omit<
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
isSidebarDocked: false, isLibraryOpen: false,
isLibraryMenuDocked: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@ -67,8 +67,6 @@ export const getDefaultAppState = (): Omit<
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null, openMenu: null,
openPopup: null, openPopup: null,
openSidebar: null,
openDialog: null,
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {}, previousSelectedElementIds: {},
resizingElement: null, resizingElement: null,
@ -79,6 +77,7 @@ export const getDefaultAppState = (): Omit<
selectedGroupIds: {}, selectedGroupIds: {},
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
@ -91,7 +90,6 @@ export const getDefaultAppState = (): Omit<
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null, pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null,
}; };
}; };
@ -111,7 +109,6 @@ const APP_STATE_STORAGE_CONF = (<
T extends Record<keyof AppState, Values>, T extends Record<keyof AppState, Values>,
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({ config)({
showWelcomeScreen: { browser: true, export: false, server: false },
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false, server: false },
@ -150,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { 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 }, isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false },
@ -161,8 +159,6 @@ const APP_STATE_STORAGE_CONF = (<
offsetTop: { browser: false, export: false, server: false }, offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false }, openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false }, openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false },
openDialog: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false }, pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false }, resizingElement: { browser: false, export: false, server: false },
@ -173,6 +169,7 @@ const APP_STATE_STORAGE_CONF = (<
selectedGroupIds: { browser: true, export: false, server: false }, selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
@ -184,7 +181,6 @@ const APP_STATE_STORAGE_CONF = (<
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false }, pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

@ -11,18 +11,27 @@ export const getClientColors = (clientId: string, appState: AppState) => {
// Naive way of getting an integer out of the clientId // Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
// Skip transparent & gray colors // Skip transparent background.
const backgrounds = colors.elementBackground.slice(3); const backgrounds = colors.elementBackground.slice(1);
const strokes = colors.elementStroke.slice(3); const strokes = colors.elementStroke.slice(1);
return { return {
background: backgrounds[sum % backgrounds.length], background: backgrounds[sum % backgrounds.length],
stroke: strokes[sum % strokes.length], stroke: strokes[sum % strokes.length],
}; };
}; };
export const getClientInitials = (userName?: string | null) => { export const getClientInitials = (username?: string | null) => {
if (!userName) { if (!username) {
return "?"; return "?";
} }
return userName.trim()[0].toUpperCase(); const names = username.trim().split(" ");
if (names.length < 2) {
return names[0].substring(0, 2).toUpperCase();
}
const firstName = names[0];
const lastName = names[names.length - 1];
return (firstName[0] + lastName[0]).toUpperCase();
}; };

View File

@ -1,92 +0,0 @@
.zoom-actions,
.undo-redo-buttons {
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
}
.zoom-button,
.undo-redo-buttons button {
border: 1px solid var(--default-border-color) !important;
border-radius: 0 !important;
background-color: transparent !important;
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;
}
.ToolIcon__icon {
width: 100%;
height: 100%;
}
}
.reset-zoom-button {
border-left: 0 !important;
border-right: 0 !important;
padding: 0 0.625rem !important;
width: 3.75rem !important;
justify-content: center;
color: var(--text-primary-color);
}
.zoom-out-button {
border-top-left-radius: var(--border-radius-lg) !important;
border-bottom-left-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
.zoom-in-button {
border-top-right-radius: var(--border-radius-lg) !important;
border-bottom-right-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
.undo-redo-buttons {
.undo-button-container button {
border-top-left-radius: var(--border-radius-lg) !important;
border-bottom-left-radius: var(--border-radius-lg) !important;
border-right: 0 !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
.redo-button-container button {
border-top-right-radius: var(--border-radius-lg) !important;
border-bottom-right-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
}

View File

@ -26,19 +26,17 @@ import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
activeTool,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
activeTool: AppState["activeTool"]["type"];
}) => { }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@ -58,13 +56,13 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(appState.activeTool.type) || hasBackground(activeTool) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showChangeBackgroundIcons = const showChangeBackgroundIcons =
hasBackground(appState.activeTool.type) || hasBackground(activeTool) ||
targetElements.some((element) => hasBackground(element.type)); targetElements.some((element) => hasBackground(element.type));
const showLinkIcon = const showLinkIcon =
@ -81,27 +79,23 @@ export const SelectedShapeActions = ({
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> {((hasStrokeColor(activeTool) &&
{((hasStrokeColor(appState.activeTool.type) && activeTool !== "image" &&
appState.activeTool.type !== "image" && commonSelectedType !== "image") ||
commonSelectedType !== "image") || targetElements.some((element) => hasStrokeColor(element.type))) &&
targetElements.some((element) => hasStrokeColor(element.type))) && renderAction("changeStrokeColor")}
renderAction("changeStrokeColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
</div>
{showChangeBackgroundIcons && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(activeTool) ||
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(appState.activeTool.type === "freedraw" || {(activeTool === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")} renderAction("changeStrokeShape")}
{(hasStrokeStyle(appState.activeTool.type) || {(hasStrokeStyle(activeTool) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
@ -109,12 +103,12 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeSharpness(appState.activeTool.type) || {(canChangeSharpness(activeTool) ||
targetElements.some((element) => canChangeSharpness(element.type))) && ( targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</> <>{renderAction("changeSharpness")}</>
)} )}
{(hasText(appState.activeTool.type) || {(hasText(activeTool) ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some((element) => hasText(element.type))) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
@ -129,7 +123,7 @@ export const SelectedShapeActions = ({
(element) => (element) =>
hasBoundTextElement(element) || isBoundToContainer(element), hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")} ) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) || {(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
@ -169,16 +163,7 @@ export const SelectedShapeActions = ({
)} )}
{targetElements.length > 2 && {targetElements.length > 2 &&
renderAction("distributeHorizontally")} renderAction("distributeHorizontally")}
{/* breaks the row ˇˇ */} <div className="iconRow">
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")} {renderAction("alignTop")}
{renderAction("alignVerticallyCentered")} {renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")} {renderAction("alignBottom")}
@ -218,26 +203,25 @@ export const ShapesSwitcher = ({
appState: AppState; appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key, fillable }, index) => { {SHAPES.map(({ value, icon, key }, index) => {
const numberKey = value === "eraser" ? 0 : index + 1;
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]); const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numberKey}` ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
: `${numberKey}`; : `${index + 1}`;
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className="Shape"
key={value} key={value}
type="radio" type="radio"
icon={icon} icon={icon}
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${numberKey}`} keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={value}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ setAppState({
@ -279,57 +263,11 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
zoom: Zoom; zoom: Zoom;
}) => ( }) => (
<Stack.Col gap={1} className="zoom-actions"> <Stack.Col gap={1}>
<Stack.Row align="center"> <Stack.Row gap={1} align="center">
{renderAction("zoomOut")} {renderAction("zoomOut")}
{renderAction("resetZoom")}
{renderAction("zoomIn")} {renderAction("zoomIn")}
{renderAction("resetZoom")}
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
); );
export const UndoRedoActions = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`undo-redo-buttons ${className}`}>
<div className="undo-button-container">
<Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
</div>
<div className="redo-button-container">
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
</div>
</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>
);

View File

@ -1,11 +1,9 @@
// TODO barnabasmolnar/editor-redesign import Stack from "../components/Stack";
// this icon is not great import { ToolButton } from "../components/ToolButton";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { save, file } from "../components/icons";
import { save } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import "./ActiveFile.scss"; import "./ActiveFile.scss";
import MenuItem from "./MenuItem";
type ActiveFileProps = { type ActiveFileProps = {
fileName?: string; fileName?: string;
@ -13,11 +11,18 @@ type ActiveFileProps = {
}; };
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => ( export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<MenuItem <Stack.Row className="ActiveFile" gap={1} align="center">
label={`${t("buttons.save")}`} <span className="ActiveFile__fileName">
shortcut={getShortcutFromShortcutName("saveScene")} {file}
dataTestId="save-button" <span>{fileName}</span>
onClick={onSave} </span>
icon={save} <ToolButton
/> type="icon"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={onSave}
data-testid="save-button"
/>
</Stack.Row>
); );

File diff suppressed because it is too large Load Diff

View File

@ -2,19 +2,16 @@
.excalidraw { .excalidraw {
.Avatar { .Avatar {
width: 1.25rem; width: 2.5rem;
height: 1.25rem; height: 2.5rem;
border-radius: 100%; border-radius: 1.25rem;
outline: 2px solid var(--avatar-border-color);
outline-offset: 2px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $oc-white; color: $oc-white;
cursor: pointer; cursor: pointer;
font-size: 0.625rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
line-height: 1;
&-img { &-img {
width: 100%; width: 100%;

View File

@ -11,11 +11,13 @@ type AvatarProps = {
src?: string; src?: string;
}; };
export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name); const shortName = getClientInitials(name);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const loadImg = !error && src; const loadImg = !error && src;
const style = loadImg ? undefined : { background: color }; const style = loadImg
? undefined
: { background: color, border: `1px solid ${border}` };
return ( return (
<div className="Avatar" style={style} onClick={onClick}> <div className="Avatar" style={style} onClick={onClick}>
{loadImg ? ( {loadImg ? (

View File

@ -0,0 +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")}
{showThemeBtn && actionManager.renderAction("toggleTheme")}
</div>
);

View File

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

View File

@ -64,8 +64,6 @@
color: #{$oc-blue-7}; color: #{$oc-blue-7};
border: 0;
&:focus { &:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7}; box-shadow: 0 0 0 3px #{$oc-blue-7};
} }

View File

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

View File

@ -1,9 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { TrashIcon } from "./icons"; import { useDevice } from "./App";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import MenuItem from "./MenuItem";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@ -13,11 +14,14 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
return ( return (
<> <>
<MenuItem <ToolButton
label={t("buttons.clearReset")} type="button"
icon={TrashIcon} icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useDevice().isMobile}
onClick={toggleDialog} onClick={toggleDialog}
dataTestId="clear-canvas-button" data-testid="clear-canvas-button"
/> />
{showDialog && ( {showDialog && (

View File

@ -1,51 +1,6 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.collab-button {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
flex-shrink: 0;
&:hover {
background-color: var(--color-primary-darker);
border-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darker);
}
&.active {
background-color: #0fb884;
border-color: #0fb884;
svg {
color: #fff;
}
&:hover,
&:active {
background-color: #0fb884;
border-color: #0fb884;
}
}
}
&.theme--dark {
.collab-button {
color: var(--color-gray-90);
}
}
.CollabButton.is-collaborating { .CollabButton.is-collaborating {
background-color: var(--button-special-active-bg-color); background-color: var(--button-special-active-bg-color);
@ -69,9 +24,9 @@
bottom: -5px; bottom: -5px;
padding: 3px; padding: 3px;
border-radius: 50%; border-radius: 50%;
background-color: $oc-green-2; background-color: $oc-green-6;
color: $oc-green-9; color: $oc-white;
font-size: 0.6rem; font-size: 0.6em;
font-family: "Cascadia"; font-family: "Cascadia";
} }
} }

View File

@ -1,47 +1,37 @@
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { UsersIcon } from "./icons"; import { useDevice } from "../components/App";
import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
import MenuItem from "./MenuItem";
import clsx from "clsx";
const CollabButton = ({ const CollabButton = ({
isCollaborating, isCollaborating,
collaboratorCount, collaboratorCount,
onClick, onClick,
isInHamburgerMenu = true,
}: { }: {
isCollaborating: boolean; isCollaborating: boolean;
collaboratorCount: number; collaboratorCount: number;
onClick: () => void; onClick: () => void;
isInHamburgerMenu?: boolean;
}) => { }) => {
return ( return (
<> <>
{isInHamburgerMenu ? ( <ToolButton
<MenuItem className={clsx("CollabButton", {
label={t("labels.liveCollaboration")} "is-collaborating": isCollaborating,
dataTestId="collab-button" })}
icon={UsersIcon} onClick={onClick}
onClick={onClick} icon={users}
isCollaborating={isCollaborating} type="button"
/> title={t("labels.liveCollaboration")}
) : ( aria-label={t("labels.liveCollaboration")}
<button showAriaLabel={useDevice().isMobile}
className={clsx("collab-button", { active: isCollaborating })} >
type="button" {isCollaborating && (
onClick={onClick} <div className="CollabButton-collaborators">{collaboratorCount}</div>
style={{ position: "relative" }} )}
title={t("labels.liveCollaboration")} </ToolButton>
>
{UsersIcon}
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">
{collaboratorCount}
</div>
)}
</button>
)}
</> </>
); );
}; };

View File

@ -21,23 +21,6 @@
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
column-gap: 0.5rem;
}
.color-picker-control-container + .popover {
position: static;
}
.color-picker-popover-container {
margin-top: -0.25rem;
:root[dir="ltr"] & {
margin-left: 0.5rem;
}
:root[dir="rtl"] & {
margin-left: -3rem;
}
} }
.color-picker-triangle { .color-picker-triangle {
@ -47,29 +30,20 @@
border-width: 0 9px 10px; border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color); border-color: transparent transparent var(--popup-bg-color);
position: absolute; position: absolute;
top: 10px; top: -10px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
transform: rotate(270deg); left: 12px;
left: -14px;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
transform: rotate(90deg); right: 12px;
right: -14px;
} }
} }
.color-picker-triangle-shadow { .color-picker-triangle-shadow {
border-color: transparent transparent transparentize($oc-black, 0.9); border-color: transparent transparent transparentize($oc-black, 0.9);
top: -11px;
:root[dir="ltr"] & {
left: -14px;
}
:root[dir="rtl"] & {
right: -16px;
}
} }
.color-picker-content--default { .color-picker-content--default {
@ -145,21 +119,16 @@
} }
.color-picker-hash { .color-picker-hash {
height: var(--default-button-size); background: var(--input-border-color);
flex-shrink: 0; height: 1.875rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem; width: 1.875rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); border-radius: 4px 0 0 4px;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; border-radius: 0 4px 4px 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
} }
color: var(--input-label-color); color: var(--input-label-color);
@ -169,64 +138,81 @@
position: relative; position: relative;
} }
.color-input-container { .color-input-container:focus-within .color-picker-hash {
display: flex; box-shadow: 0 0 0 2px var(--focus-highlight-color);
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
} }
.color-picker-input { .color-input-container:focus-within .color-picker-hash::before,
box-sizing: border-box; .color-input-container:focus-within .color-picker-hash::after {
width: 100%; content: "";
margin: 0; width: 1px;
font-size: 0.875rem; height: 100%;
background-color: transparent; position: absolute;
color: var(--text-primary-color); top: 0;
border: 0; }
outline: none;
height: var(--default-button-size); .color-input-container:focus-within .color-picker-hash::before {
border: 1px solid var(--default-border-color); background: var(--input-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; right: -1px;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); left: -1px;
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
} }
} }
.color-picker-label-swatch-container { .color-input-container:focus-within .color-picker-hash::after {
border: 1px solid var(--default-border-color); background: var(--input-bg-color);
border-radius: var(--border-radius-lg);
width: var(--default-button-size); :root[dir="ltr"] & {
height: var(--default-button-size); right: -2px;
box-sizing: border-box; }
overflow: hidden;
:root[dir="rtl"] & {
left: -2px;
}
}
.color-input-container {
display: flex;
}
.color-picker-input {
width: 11ch; /* length of `transparent` */
margin: 0;
font-size: 1rem;
background-color: var(--input-bg-color);
color: var(--text-primary-color);
border: 0;
outline: none;
height: 1.75em;
box-shadow: var(--input-border-color) 0 0 0 1px inset;
:root[dir="ltr"] & {
border-radius: 0 4px 4px 0;
}
:root[dir="rtl"] & {
border-radius: 4px 0 0 4px;
}
float: left;
padding: 1px;
padding-inline-start: 0.5em;
appearance: none;
} }
.color-picker-label-swatch { .color-picker-label-swatch {
@include outlineButtonStyles; height: 1.875rem;
background-color: var(--swatch-color) !important; width: 1.875rem;
overflow: hidden; margin-inline-end: 0.25rem;
border: 1px solid $oc-gray-3;
position: relative; position: relative;
overflow: hidden;
background-color: transparent !important;
filter: var(--theme-filter); filter: var(--theme-filter);
border: 0 !important;
&:after { &:after {
content: ""; content: "";

View File

@ -343,8 +343,6 @@ const ColorInput = React.forwardRef(
}, },
); );
ColorInput.displayName = "ColorInput";
export const ColorPicker = ({ export const ColorPicker = ({
type, type,
color, color,
@ -365,20 +363,17 @@ export const ColorPicker = ({
appState: AppState; appState: AppState;
}) => { }) => {
const pickerButton = React.useRef<HTMLButtonElement>(null); const pickerButton = React.useRef<HTMLButtonElement>(null);
const coords = pickerButton.current?.getBoundingClientRect();
return ( return (
<div> <div>
<div className="color-picker-control-container"> <div className="color-picker-control-container">
<div className="color-picker-label-swatch-container"> <button
<button className="color-picker-label-swatch"
className="color-picker-label-swatch" aria-label={label}
aria-label={label} style={color ? { "--swatch-color": color } : undefined}
style={color ? { "--swatch-color": color } : undefined} onClick={() => setActive(!isActive)}
onClick={() => setActive(!isActive)} ref={pickerButton}
ref={pickerButton} />
/>
</div>
<ColorInput <ColorInput
color={color} color={color}
label={label} label={label}
@ -389,37 +384,27 @@ export const ColorPicker = ({
</div> </div>
<React.Suspense fallback=""> <React.Suspense fallback="">
{isActive ? ( {isActive ? (
<div <Popover
className="color-picker-popover-container" onCloseRequest={(event) =>
style={{ event.target !== pickerButton.current && setActive(false)
position: "fixed", }
top: coords?.top,
left: coords?.right,
zIndex: 1,
}}
> >
<Popover <Picker
onCloseRequest={(event) => colors={colors[type]}
event.target !== pickerButton.current && setActive(false) color={color || null}
} onChange={(changedColor) => {
> onChange(changedColor);
<Picker }}
colors={colors[type]} onClose={() => {
color={color || null} setActive(false);
onChange={(changedColor) => { pickerButton.current?.focus();
onChange(changedColor); }}
}} label={label}
onClose={() => { showInput={false}
setActive(false); type={type}
pickerButton.current?.focus(); elements={elements}
}} />
label={label} </Popover>
showInput={false}
type={type}
elements={elements}
/>
</Popover>
</div>
) : null} ) : null}
</React.Suspense> </React.Suspense>
</div> </div>

View File

@ -4,8 +4,34 @@
.confirm-dialog { .confirm-dialog {
&-buttons { &-buttons {
display: flex; display: flex;
column-gap: 0.5rem; padding: 0.2rem 0;
justify-content: flex-end; justify-content: flex-end;
} }
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 0.8rem;
padding: 0 0.5rem;
}
&__content {
font-size: 1rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-8;
}
.ToolIcon__icon {
color: $oc-white;
}
}
} }
} }

View File

@ -1,11 +1,8 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { Dialog, DialogProps } from "./Dialog"; import { Dialog, DialogProps } from "./Dialog";
import { ToolButton } from "./ToolButton";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton";
import { isMenuOpenAtom } from "./App";
import { isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@ -23,10 +20,6 @@ const ConfirmDialog = (props: Props) => {
className = "", className = "",
...rest ...rest
} = props; } = props;
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
return ( return (
<Dialog <Dialog
onCloseRequest={onCancel} onCloseRequest={onCancel}
@ -36,22 +29,21 @@ const ConfirmDialog = (props: Props) => {
> >
{children} {children}
<div className="confirm-dialog-buttons"> <div className="confirm-dialog-buttons">
<DialogActionButton <ToolButton
type="button"
title={cancelText}
aria-label={cancelText}
label={cancelText} label={cancelText}
onClick={() => { onClick={onCancel}
setIsMenuOpen(false); className="confirm-dialog--cancel"
setIsDropdownOpen(false);
onCancel();
}}
/> />
<DialogActionButton <ToolButton
type="button"
title={confirmText}
aria-label={confirmText}
label={confirmText} label={confirmText}
onClick={() => { onClick={onConfirm}
setIsMenuOpen(false); className="confirm-dialog--confirm"
setIsDropdownOpen(false);
onConfirm();
}}
actionType="danger"
/> />
</div> </div>
</Dialog> </Dialog>

View File

@ -7,11 +7,68 @@
} }
.Dialog__title { .Dialog__title {
display: grid;
align-items: center;
margin-top: 0;
grid-template-columns: 1fr calc(var(--space-factor) * 7);
grid-gap: var(--metric);
padding: calc(var(--space-factor) * 2);
text-align: center;
font-variant: small-caps;
font-size: 1.2em;
}
.Dialog__titleContent {
flex: 1;
}
.Dialog .Modal__close {
color: var(--icon-fill-color);
margin: 0; margin: 0;
text-align: left; }
font-size: 1.25rem;
border-bottom: 1px solid var(--dialog-border-color); .Dialog__content {
padding: 0 0 0.75rem; padding: 0 16px 16px;
margin-bottom: 1.5rem; }
@include isMobile {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};
--inset-right: #{"max(var(--metric), var(--sar))"};
}
.Dialog__title {
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
var(--space-factor) * 7
);
position: sticky;
top: 0;
padding: calc(var(--space-factor) * 2);
background: var(--island-bg-color);
font-size: 1.25em;
box-sizing: border-box;
border-bottom: 1px solid var(--button-gray-2);
z-index: 1;
}
.Dialog__titleContent {
text-align: center;
}
.Dialog .Island {
width: 100vw;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
}
.Dialog .Modal__close {
order: -1;
}
} }
} }

View File

@ -5,13 +5,11 @@ import { t } from "../i18n";
import { useExcalidrawContainer, useDevice } from "../components/App"; import { useExcalidrawContainer, useDevice } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, CloseIcon } from "./icons"; import { back, close } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@ -67,12 +65,7 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown); return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
const onClose = () => { const onClose = () => {
setIsMenuOpen(false);
setIsDropdownOpen(false);
(lastActiveElement as HTMLElement).focus(); (lastActiveElement as HTMLElement).focus();
props.onCloseRequest(); props.onCloseRequest();
}; };
@ -92,10 +85,9 @@ export const Dialog = (props: DialogProps) => {
<button <button
className="Modal__close" className="Modal__close"
onClick={onClose} onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDevice().isMobile ? back : CloseIcon} {useDevice().isMobile ? back : close}
</button> </button>
</h2> </h2>
<div className="Dialog__content">{props.children}</div> <div className="Dialog__content">{props.children}</div>

View File

@ -1,47 +0,0 @@
.excalidraw {
.Dialog__action-button {
position: relative;
display: flex;
column-gap: 0.5rem;
align-items: center;
padding: 0.5rem 1.5rem;
border: 1px solid var(--default-border-color);
background-color: transparent;
height: 3rem;
border-radius: var(--border-radius-lg);
letter-spacing: 0.4px;
color: inherit;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
user-select: none;
svg {
display: block;
width: 1rem;
height: 1rem;
}
&--danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: #fff;
}
&--primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
&.theme--dark {
.Dialog__action-button--danger {
color: var(--color-gray-100);
}
.Dialog__action-button--primary {
color: var(--color-gray-100);
}
}
}

View File

@ -1,46 +0,0 @@
import clsx from "clsx";
import { ReactNode } from "react";
import "./DialogActionButton.scss";
import Spinner from "./Spinner";
interface DialogActionButtonProps {
label: string;
children?: ReactNode;
actionType?: "primary" | "danger";
isLoading?: boolean;
}
const DialogActionButton = ({
label,
onClick,
className,
children,
actionType,
type = "button",
isLoading,
...rest
}: DialogActionButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const cs = actionType ? `Dialog__action-button--${actionType}` : "";
return (
<button
className={clsx("Dialog__action-button", cs, className)}
type={type}
aria-label={label}
onClick={onClick}
{...rest}
>
{children && (
<div style={isLoading ? { visibility: "hidden" } : {}}>{children}</div>
)}
<div style={isLoading ? { visibility: "hidden" } : {}}>{label}</div>
{isLoading && (
<div style={{ position: "absolute", inset: 0 }}>
<Spinner />
</div>
)}
</button>
);
};
export default DialogActionButton;

View File

@ -1,19 +0,0 @@
import { t } from "../i18n";
import { shield } from "./icons";
import { Tooltip } from "./Tooltip";
const EncryptedIcon = () => (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>
{shield}
</Tooltip>
</a>
);
export default EncryptedIcon;

View File

@ -91,8 +91,6 @@
} }
button.ExportDialog-imageExportButton { button.ExportDialog-imageExportButton {
border: 0;
width: 5rem; width: 5rem;
height: 5rem; height: 5rem;
margin: 0 0.2em; margin: 0 0.2em;

View File

@ -9,10 +9,9 @@
} }
.FixedSideContainer_side_top { .FixedSideContainer_side_top {
left: 1rem; left: var(--space-factor);
top: 1rem; top: var(--space-factor);
right: 1rem; right: var(--space-factor);
bottom: 1rem;
z-index: 2; z-index: 2;
} }

View File

@ -1,109 +0,0 @@
import clsx from "clsx";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "./Actions";
import { useDevice } from "./App";
import { WelcomeScreenHelpArrow } from "./icons";
import { Section } from "./Section";
import Stack from "./Stack";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
const Footer = ({
appState,
actionManager,
renderCustomFooter,
showExitZenModeBtn,
renderWelcomeScreen,
}: {
appState: AppState;
actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean;
renderWelcomeScreen: 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">
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
{!appState.viewModeEnabled && (
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
/>
)}
{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,
})}
>
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
<div>{t("welcomeScreen.helpHints")}</div>
{WelcomeScreenHelpArrow}
</div>
</WelcomeScreenDecor>
{actionManager.renderAction("toggleShortcuts")}
</div>
</div>
<ExitZenModeAction
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
/>
</footer>
);
};
export default Footer;

View File

@ -1,115 +1,56 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.HelpDialog { .HelpDialog h3 {
.Modal__content { border-bottom: 1px solid var(--button-gray-2);
max-width: 960px; padding-bottom: 4px;
} }
h3 { .HelpDialog--island {
margin: 1.5rem 0; border: 1px solid var(--button-gray-2);
font-weight: bold; margin-bottom: 16px;
font-size: 1.125rem; }
}
&__header { .HelpDialog--island-title {
display: flex; margin: 0;
flex-wrap: wrap; padding: 4px;
gap: 0.75rem; background-color: var(--button-gray-1);
} text-align: center;
}
&__btn { .HelpDialog--shortcut {
display: flex; border-top: 1px solid var(--button-gray-2);
column-gap: 0.5rem; }
align-items: center;
border: 1px solid var(--default-border-color);
padding: 0.625rem 1rem;
border-radius: var(--border-radius-lg);
color: var(--text-primary-color);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.4px;
&:hover { .HelpDialog--key {
text-decoration: none; word-break: keep-all;
} border: 1px solid var(--button-gray-2);
} padding: 2px 8px;
margin: auto 4px;
background-color: var(--button-gray-1);
border-radius: 2px;
font-size: 0.8em;
min-height: 26px;
box-sizing: border-box;
display: flex;
align-items: center;
font-family: inherit;
}
&__link-icon { .HelpDialog--header {
line-height: 0; display: flex;
svg { flex-direction: row;
width: 1rem; justify-content: space-evenly;
height: 1rem; margin-bottom: 32px;
} padding-bottom: 16px;
} }
&__islands-container { .HelpDialog--btn {
display: grid; border: 1px solid var(--link-color);
@media screen and (min-width: 1024px) { padding: 8px 32px;
grid-template-columns: 1fr 1fr; border-radius: 4px;
} }
grid-column-gap: 1.5rem; .HelpDialog--btn:hover {
grid-row-gap: 2rem; text-decoration: none;
}
@media screen and (min-width: 1024px) {
&__island--tools {
grid-area: 1 / 1 / 2 / 2;
}
&__island--view {
grid-area: 2 / 1 / 3 / 2;
}
&__island--editor {
grid-area: 1 / 2 / 3 / 3;
}
}
&__island {
h4 {
font-size: 1rem;
font-weight: bold;
margin: 0;
margin-bottom: 0.625rem;
}
&-content {
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
}
}
&__shortcut {
border-bottom: 1px solid var(--dialog-border-color);
padding: 0.375rem 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
column-gap: 0.5rem;
&:last-child {
border-bottom: none;
}
}
&__key-container {
display: flex;
align-items: center;
column-gap: 0.25rem;
flex-shrink: 0;
}
&__key {
display: flex;
box-sizing: border-box;
font-size: 0.625rem;
background-color: var(--color-primary-light);
border-radius: var(--border-radius-md);
padding: 0.5rem;
word-break: keep-all;
align-items: center;
font-family: inherit;
line-height: 1;
}
} }
} }

View File

@ -4,36 +4,32 @@ import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import "./HelpDialog.scss"; import "./HelpDialog.scss";
import { ExternalLinkIcon } from "./icons";
const Header = () => ( const Header = () => (
<div className="HelpDialog__header"> <div className="HelpDialog--header">
<a <a
className="HelpDialog__btn" className="HelpDialog--btn"
href="https://github.com/excalidraw/excalidraw#documentation" href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("helpDialog.documentation")} {t("helpDialog.documentation")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a> </a>
<a <a
className="HelpDialog__btn" className="HelpDialog--btn"
href="https://blog.excalidraw.com" href="https://blog.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("helpDialog.blog")} {t("helpDialog.blog")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a> </a>
<a <a
className="HelpDialog__btn" className="HelpDialog--btn"
href="https://github.com/excalidraw/excalidraw/issues" href="https://github.com/excalidraw/excalidraw/issues"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("helpDialog.github")} {t("helpDialog.github")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a> </a>
</div> </div>
); );
@ -41,61 +37,88 @@ const Header = () => (
const Section = (props: { title: string; children: React.ReactNode }) => ( const Section = (props: { title: string; children: React.ReactNode }) => (
<> <>
<h3>{props.title}</h3> <h3>{props.title}</h3>
<div className="HelpDialog__islands-container">{props.children}</div> {props.children}
</> </>
); );
const Columns = (props: { children: React.ReactNode }) => (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{props.children}
</div>
);
const Column = (props: { children: React.ReactNode }) => (
<div style={{ width: "49%" }}>{props.children}</div>
);
const ShortcutIsland = (props: { const ShortcutIsland = (props: {
caption: string; caption: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) => ( }) => (
<div className={`HelpDialog__island ${props.className}`}> <div className="HelpDialog--island">
<h4 className="HelpDialog__island-title">{props.caption}</h4> <h3 className="HelpDialog--island-title">{props.caption}</h3>
<div className="HelpDialog__island-content">{props.children}</div> {props.children}
</div> </div>
); );
function* intersperse(as: JSX.Element[][], delim: string | null) { const Shortcut = (props: {
let first = true;
for (const x of as) {
if (!first) {
yield delim;
}
first = false;
yield x;
}
}
const Shortcut = ({
label,
shortcuts,
isOr = true,
}: {
label: string; label: string;
shortcuts: string[]; shortcuts: string[];
isOr?: boolean; isOr: boolean;
}) => { }) => {
const splitShortcutKeys = shortcuts.map((shortcut) => {
const keys = shortcut.endsWith("++")
? [...shortcut.slice(0, -2).split("+"), "+"]
: shortcut.split("+");
return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
});
return ( return (
<div className="HelpDialog__shortcut"> <div className="HelpDialog--shortcut">
<div>{label}</div> <div
<div className="HelpDialog__key-container"> style={{
{[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : null)]} display: "flex",
margin: "0",
padding: "4px 8px",
alignItems: "center",
}}
>
<div
style={{
lineHeight: 1.4,
}}
>
{props.label}
</div>
<div
style={{
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
marginInlineStart: "auto",
minWidth: "30%",
}}
>
{props.shortcuts.map((shortcut, index) => (
<React.Fragment key={index}>
<ShortcutKey>{shortcut}</ShortcutKey>
{props.isOr &&
index !== props.shortcuts.length - 1 &&
t("helpDialog.or")}
</React.Fragment>
))}
</div>
</div> </div>
</div> </div>
); );
}; };
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => ( const ShortcutKey = (props: { children: React.ReactNode }) => (
<kbd className="HelpDialog__key" {...props} /> <kbd className="HelpDialog--key" {...props} />
); );
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
@ -114,276 +137,286 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
> >
<Header /> <Header />
<Section title={t("helpDialog.shortcuts")}> <Section title={t("helpDialog.shortcuts")}>
<ShortcutIsland <Columns>
className="HelpDialog__island--tools" <Column>
caption={t("helpDialog.tools")} <ShortcutIsland caption={t("helpDialog.tools")}>
> <Shortcut
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} /> label={t("toolBar.selection")}
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} /> shortcuts={["V", "1"]}
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} /> <Shortcut
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> label={t("toolBar.rectangle")}
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> shortcuts={["R", "2"]}
<Shortcut />
label={t("toolBar.freedraw")} <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
shortcuts={["Shift + P", "X", "7"]} <Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
/> <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> <Shortcut
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> label={t("toolBar.freedraw")}
<Shortcut shortcuts={["Shift + P", "X", "7"]}
label={t("toolBar.eraser")} />
shortcuts={[getShortcutKey("E")]} <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
/> <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
label={t("helpDialog.editSelectedShape")} <Shortcut
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]} label={t("toolBar.eraser")}
/> shortcuts={[getShortcutKey("E")]}
<Shortcut />
label={t("helpDialog.textNewLine")} <Shortcut
shortcuts={[ label={t("helpDialog.editSelectedShape")}
getShortcutKey("Enter"), shortcuts={[
getShortcutKey("Shift+Enter"), getShortcutKey("Enter"),
]} t("helpDialog.doubleClick"),
/> ]}
<Shortcut />
label={t("helpDialog.textFinish")} <Shortcut
shortcuts={[ label={t("helpDialog.textNewLine")}
getShortcutKey("Esc"), shortcuts={[
getShortcutKey("CtrlOrCmd+Enter"), getShortcutKey("Enter"),
]} getShortcutKey("Shift+Enter"),
/> ]}
<Shortcut />
label={t("helpDialog.curvedArrow")} <Shortcut
shortcuts={[ label={t("helpDialog.textFinish")}
"A", shortcuts={[
t("helpDialog.click"), getShortcutKey("Esc"),
t("helpDialog.click"), getShortcutKey("CtrlOrCmd+Enter"),
t("helpDialog.click"), ]}
]} />
isOr={false} <Shortcut
/> label={t("helpDialog.curvedArrow")}
<Shortcut shortcuts={[
label={t("helpDialog.curvedLine")} "A",
shortcuts={[ t("helpDialog.click"),
"L", t("helpDialog.click"),
t("helpDialog.click"), t("helpDialog.click"),
t("helpDialog.click"), ]}
t("helpDialog.click"), isOr={false}
]} />
isOr={false} <Shortcut
/> label={t("helpDialog.curvedLine")}
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} /> shortcuts={[
<Shortcut "L",
label={t("helpDialog.preventBinding")} t("helpDialog.click"),
shortcuts={[getShortcutKey("CtrlOrCmd")]} t("helpDialog.click"),
/> t("helpDialog.click"),
<Shortcut ]}
label={t("toolBar.link")} isOr={false}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]} />
/> <Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
</ShortcutIsland> <Shortcut
<ShortcutIsland label={t("helpDialog.preventBinding")}
className="HelpDialog__island--view" shortcuts={[getShortcutKey("CtrlOrCmd")]}
caption={t("helpDialog.view")} />
> <Shortcut
<Shortcut label={t("toolBar.link")}
label={t("buttons.zoomIn")} shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
shortcuts={[getShortcutKey("CtrlOrCmd++")]} />
/> </ShortcutIsland>
<Shortcut <ShortcutIsland caption={t("helpDialog.view")}>
label={t("buttons.zoomOut")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+-")]} label={t("buttons.zoomIn")}
/> shortcuts={[getShortcutKey("CtrlOrCmd++")]}
<Shortcut />
label={t("buttons.resetZoom")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+0")]} label={t("buttons.zoomOut")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
<Shortcut />
label={t("helpDialog.zoomToFit")} <Shortcut
shortcuts={["Shift+1"]} label={t("buttons.resetZoom")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
<Shortcut />
label={t("helpDialog.zoomToSelection")} <Shortcut
shortcuts={["Shift+2"]} label={t("helpDialog.zoomToFit")}
/> shortcuts={["Shift+1"]}
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> />
<Shortcut <Shortcut
label={t("buttons.zenMode")} label={t("helpDialog.zoomToSelection")}
shortcuts={[getShortcutKey("Alt+Z")]} shortcuts={["Shift+2"]}
/> />
<Shortcut <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
label={t("labels.showGrid")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+'")]} label={t("buttons.zenMode")}
/> shortcuts={[getShortcutKey("Alt+Z")]}
<Shortcut />
label={t("labels.viewMode")} <Shortcut
shortcuts={[getShortcutKey("Alt+R")]} label={t("labels.showGrid")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
<Shortcut />
label={t("labels.toggleTheme")} <Shortcut
shortcuts={[getShortcutKey("Alt+Shift+D")]} label={t("labels.viewMode")}
/> shortcuts={[getShortcutKey("Alt+R")]}
<Shortcut />
label={t("stats.title")} <Shortcut
shortcuts={[getShortcutKey("Alt+/")]} label={t("labels.toggleTheme")}
/> shortcuts={[getShortcutKey("Alt+Shift+D")]}
</ShortcutIsland> />
<ShortcutIsland <Shortcut
className="HelpDialog__island--editor" label={t("stats.title")}
caption={t("helpDialog.editor")} shortcuts={[getShortcutKey("Alt+/")]}
> />
<Shortcut </ShortcutIsland>
label={t("labels.selectAll")} </Column>
shortcuts={[getShortcutKey("CtrlOrCmd+A")]} <Column>
/> <ShortcutIsland caption={t("helpDialog.editor")}>
<Shortcut <Shortcut
label={t("labels.multiSelect")} label={t("labels.selectAll")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]} shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.deepSelect")} label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]} shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.deepBoxSelect")} label={t("helpDialog.deepSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]} shortcuts={[
/> getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
<Shortcut ]}
label={t("labels.moveCanvas")} />
shortcuts={[ <Shortcut
getShortcutKey(`Space+${t("helpDialog.drag")}`), label={t("helpDialog.deepBoxSelect")}
getShortcutKey(`Wheel+${t("helpDialog.drag")}`), shortcuts={[
]} getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
isOr={true} ]}
/> />
<Shortcut <Shortcut
label={t("labels.cut")} label={t("labels.moveCanvas")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]} shortcuts={[
/> getShortcutKey(`Space+${t("helpDialog.drag")}`),
<Shortcut getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
label={t("labels.copy")} ]}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]} isOr={true}
/> />
<Shortcut <Shortcut
label={t("labels.paste")} label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]} shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/> />
<Shortcut <Shortcut
label={t("labels.copyAsPng")} label={t("labels.copy")}
shortcuts={[getShortcutKey("Shift+Alt+C")]} shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/> />
<Shortcut <Shortcut
label={t("labels.copyStyles")} label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]} shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/> />
<Shortcut <Shortcut
label={t("labels.pasteStyles")} label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]} shortcuts={[getShortcutKey("Shift+Alt+C")]}
/> />
<Shortcut <Shortcut
label={t("labels.delete")} label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("Del")]} shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
/> />
<Shortcut <Shortcut
label={t("labels.sendToBack")} label={t("labels.pasteStyles")}
shortcuts={[ shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
isDarwin />
? getShortcutKey("CtrlOrCmd+Alt+[") <Shortcut
: getShortcutKey("CtrlOrCmd+Shift+["), label={t("labels.delete")}
]} shortcuts={[getShortcutKey("Del")]}
/> />
<Shortcut <Shortcut
label={t("labels.bringToFront")} label={t("labels.sendToBack")}
shortcuts={[ shortcuts={[
isDarwin isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]") ? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+]"), : getShortcutKey("CtrlOrCmd+Shift+["),
]} ]}
/> />
<Shortcut <Shortcut
label={t("labels.sendBackward")} label={t("labels.bringToFront")}
shortcuts={[getShortcutKey("CtrlOrCmd+[")]} shortcuts={[
/> isDarwin
<Shortcut ? getShortcutKey("CtrlOrCmd+Alt+]")
label={t("labels.bringForward")} : getShortcutKey("CtrlOrCmd+Shift+]"),
shortcuts={[getShortcutKey("CtrlOrCmd+]")]} ]}
/> />
<Shortcut <Shortcut
label={t("labels.alignTop")} label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]} shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/> />
<Shortcut <Shortcut
label={t("labels.alignBottom")} label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]} shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/> />
<Shortcut <Shortcut
label={t("labels.alignLeft")} label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/> />
<Shortcut <Shortcut
label={t("labels.alignRight")} label={t("labels.alignBottom")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
/> />
<Shortcut <Shortcut
label={t("labels.duplicateSelection")} label={t("labels.alignLeft")}
shortcuts={[ shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
getShortcutKey("CtrlOrCmd+D"), />
getShortcutKey(`Alt+${t("helpDialog.drag")}`), <Shortcut
]} label={t("labels.alignRight")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
<Shortcut />
label={t("helpDialog.toggleElementLock")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]} label={t("labels.duplicateSelection")}
/> shortcuts={[
<Shortcut getShortcutKey("CtrlOrCmd+D"),
label={t("buttons.undo")} getShortcutKey(`Alt+${t("helpDialog.drag")}`),
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]} ]}
/> />
<Shortcut <Shortcut
label={t("buttons.redo")} label={t("helpDialog.toggleElementLock")}
shortcuts={ shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
isWindows />
? [ <Shortcut
getShortcutKey("CtrlOrCmd+Y"), label={t("buttons.undo")}
getShortcutKey("CtrlOrCmd+Shift+Z"), shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
] />
: [getShortcutKey("CtrlOrCmd+Shift+Z")] <Shortcut
} label={t("buttons.redo")}
/> shortcuts={
<Shortcut isWindows
label={t("labels.group")} ? [
shortcuts={[getShortcutKey("CtrlOrCmd+G")]} getShortcutKey("CtrlOrCmd+Y"),
/> getShortcutKey("CtrlOrCmd+Shift+Z"),
<Shortcut ]
label={t("labels.ungroup")} : [getShortcutKey("CtrlOrCmd+Shift+Z")]
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]} }
/> />
<Shortcut <Shortcut
label={t("labels.flipHorizontal")} label={t("labels.group")}
shortcuts={[getShortcutKey("Shift+H")]} shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/> />
<Shortcut <Shortcut
label={t("labels.flipVertical")} label={t("labels.ungroup")}
shortcuts={[getShortcutKey("Shift+V")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/> />
<Shortcut <Shortcut
label={t("labels.showStroke")} label={t("labels.flipHorizontal")}
shortcuts={[getShortcutKey("S")]} shortcuts={[getShortcutKey("Shift+H")]}
/> />
<Shortcut <Shortcut
label={t("labels.showBackground")} label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("Shift+V")]}
/> />
<Shortcut <Shortcut
label={t("labels.decreaseFontSize")} label={t("labels.showStroke")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} shortcuts={[getShortcutKey("S")]}
/> />
<Shortcut <Shortcut
label={t("labels.increaseFontSize")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]} shortcuts={[getShortcutKey("G")]}
/> />
</ShortcutIsland> <Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland>
</Column>
</Columns>
</Section> </Section>
</Dialog> </Dialog>
</> </>

View File

@ -1,13 +1,13 @@
import { HelpIcon } from "./icons"; import { questionCircle } from "../components/icons";
type HelpButtonProps = { type HelpIconProps = {
title?: string; title?: string;
name?: string; name?: string;
id?: string; id?: string;
onClick?(): void; onClick?(): void;
}; };
export const HelpButton = (props: HelpButtonProps) => ( export const HelpIcon = (props: HelpIconProps) => (
<button <button
className="help-icon" className="help-icon"
onClick={props.onClick} onClick={props.onClick}
@ -15,6 +15,6 @@ export const HelpButton = (props: HelpButtonProps) => (
title={`${props.title} — ?`} title={`${props.title} — ?`}
aria-label={props.title} aria-label={props.title}
> >
{HelpIcon} {questionCircle}
</button> </button>
); );

View File

@ -14,24 +14,20 @@ $wide-viewport-width: 1000px;
top: 100%; top: 100%;
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
margin-top: 0.5rem; margin-top: 6px;
text-align: center; text-align: center;
color: var(--color-gray-40); color: $oc-gray-6;
font-size: 0.75rem; font-size: 0.8rem;
@include isMobile { @include isMobile {
position: static; position: static;
padding-right: 2rem; padding-right: 2em;
} }
> span { > span {
padding: 0.25rem; padding: 0.2rem 0.4rem;
} background-color: var(--overlay-bg-color);
} border-radius: 4px;
&.theme--dark {
.HintViewer {
color: var(--color-gray-60);
} }
} }
} }

View File

@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { AppState, Device } from "../types"; import { AppState } from "../types";
import { import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@ -17,19 +17,13 @@ interface HintViewerProps {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean; isMobile: boolean;
device: Device;
} }
const getHints = ({ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) { if (appState.isLibraryOpen) {
return null; return null;
} }
@ -117,13 +111,11 @@ export const HintViewer = ({
appState, appState,
elements, elements,
isMobile, isMobile,
device,
}: HintViewerProps) => { }: HintViewerProps) => {
let hint = getHints({ let hint = getHints({
appState, appState,
elements, elements,
isMobile, isMobile,
device,
}); });
if (!hint) { if (!hint) {
return null; return null;

View File

@ -10,8 +10,7 @@
.picker { .picker {
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
// ˇˇ yeah, i dunno, open to suggestions here :D box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
} }
@ -47,6 +46,7 @@
margin: 0; margin: 0;
width: 36px; width: 36px;
height: 18px; height: 18px;
opacity: 0.6;
pointer-events: none; pointer-events: none;
} }
} }

View File

@ -4,7 +4,6 @@ import { Popover } from "./Popover";
import "./IconPicker.scss"; import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n"; import { getLanguage } from "../i18n";
import clsx from "clsx";
function Picker<T>({ function Picker<T>({
options, options,
@ -103,9 +102,7 @@ function Picker<T>({
<div className="picker-content" ref={rGallery}> <div className="picker-content" ref={rGallery}>
{options.map((option, i) => ( {options.map((option, i) => (
<button <button
className={clsx("picker-option", { className="picker-option"
active: value === option.value,
})}
onClick={(event) => { onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus(); (event.currentTarget as HTMLButtonElement).focus();
onChange(option.value); onChange(option.value);
@ -153,7 +150,7 @@ export function IconPicker<T>({
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
return ( return (
<div> <label className={"picker-container"}>
<button <button
name={group} name={group}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
@ -187,6 +184,6 @@ export function IconPicker<T>({
</> </>
) : null} ) : null}
</React.Suspense> </React.Suspense>
</div> </label>
); );
} }

View File

@ -5,12 +5,14 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard } from "./icons"; import { clipboard, exportImage } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
@ -56,7 +58,6 @@ const ExportButton: React.FC<{
onClick: () => void; onClick: () => void;
title: string; title: string;
shade?: number; shade?: number;
children?: React.ReactNode;
}> = ({ children, title, onClick, color, shade = 6 }) => { }> = ({ children, title, onClick, color, shade = 6 }) => {
return ( return (
<button <button
@ -219,7 +220,6 @@ const ImageExportModal = ({
export const ImageExportDialog = ({ export const ImageExportDialog = ({
elements, elements,
appState, appState,
setAppState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
@ -228,7 +228,6 @@ export const ImageExportDialog = ({
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: AppState; appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
@ -237,13 +236,26 @@ export const ImageExportDialog = ({
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setAppState({ openDialog: null }); setModalIsShown(false);
}, [setAppState]); }, []);
return ( return (
<> <>
{appState.openDialog === "imageExport" && ( <ToolButton
onClick={() => {
setModalIsShown(true);
}}
data-testid="image-export-button"
icon={exportImage}
type="button"
aria-label={t("buttons.exportImage")}
showAriaLabel={useDevice().isMobile}
title={t("buttons.exportImage")}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}> <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
<ImageExportModal <ImageExportModal
elements={elements} elements={elements}

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