Compare commits
	
		
			30 Commits
		
	
	
		
			fix-5855
			...
			zsviczian-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					13309a66c5 | ||
| 
						 | 
					531829d95e | ||
| 
						 | 
					d3cbceb7fa | ||
| 
						 | 
					73111500d3 | ||
| 
						 | 
					9e17b64e5e | ||
| 
						 | 
					326da61573 | ||
| 
						 | 
					994f2a3f1e | ||
| 
						 | 
					5dbcf64353 | ||
| 
						 | 
					eda2320dae | ||
| 
						 | 
					b610c04481 | ||
| 
						 | 
					d969849357 | ||
| 
						 | 
					9a66fc6c05 | ||
| 
						 | 
					158f169c43 | ||
| 
						 | 
					ce27cb6159 | ||
| 
						 | 
					2e04bcd485 | ||
| 
						 | 
					7436f3926b | ||
| 
						 | 
					e429b7048d | ||
| 
						 | 
					e61b447413 | ||
| 
						 | 
					73f0d854bf | ||
| 
						 | 
					cec3cf8334 | ||
| 
						 | 
					8640e75ccf | ||
| 
						 | 
					ca7ce64fea | ||
| 
						 | 
					e3a78fe5df | ||
| 
						 | 
					554985f749 | ||
| 
						 | 
					d3857fbb35 | ||
| 
						 | 
					93c72cbb32 | ||
| 
						 | 
					aeb4d39387 | ||
| 
						 | 
					a0259360d6 | ||
| 
						 | 
					243d8de7a8 | ||
| 
						 | 
					81c927bab6 | 
@@ -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"] }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
*
 | 
			
		||||
!.env.development
 | 
			
		||||
!.env.production
 | 
			
		||||
!.env
 | 
			
		||||
!.eslintrc.json
 | 
			
		||||
!.npmrc
 | 
			
		||||
!.prettierrc
 | 
			
		||||
 
 | 
			
		||||
@@ -4,21 +4,9 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
 | 
			
		||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
 | 
			
		||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 | 
			
		||||
 | 
			
		||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
 | 
			
		||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
 | 
			
		||||
 | 
			
		||||
# set this only if using the collaboration workflow we use on excalidraw.com
 | 
			
		||||
REACT_APP_PORTAL_URL=
 | 
			
		||||
REACT_APP_PORTAL_URL=http://localhost:3002
 | 
			
		||||
# Fill to set socket server URL used for collaboration.
 | 
			
		||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
 | 
			
		||||
REACT_APP_WS_SERVER_URL=
 | 
			
		||||
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
 | 
			
		||||
 | 
			
		||||
# put these in your .env.local, or make sure you don't commit!
 | 
			
		||||
# must be lowercase `true` when turned on
 | 
			
		||||
#
 | 
			
		||||
# whether to enable Service Workers in development
 | 
			
		||||
REACT_APP_DEV_ENABLE_SW=
 | 
			
		||||
# whether to disable live reload / HMR. Usuaully what you want to do when
 | 
			
		||||
# debugging Service Workers.
 | 
			
		||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
 | 
			
		||||
 | 
			
		||||
FAST_REFRESH=false
 | 
			
		||||
 
 | 
			
		||||
@@ -13,5 +13,3 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
 | 
			
		||||
 | 
			
		||||
# production-only vars
 | 
			
		||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
			
		||||
 | 
			
		||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,37 @@
 | 
			
		||||
version: 2
 | 
			
		||||
updates:
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: weekly
 | 
			
		||||
      day: sunday
 | 
			
		||||
      time: "01:00"
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - lipis
 | 
			
		||||
    assignees:
 | 
			
		||||
      - lipis
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/excalidraw/
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: weekly
 | 
			
		||||
      day: sunday
 | 
			
		||||
      time: "01:00"
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/utils/
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: weekly
 | 
			
		||||
      day: sunday
 | 
			
		||||
      time: "01:00"
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,4 +1,4 @@
 | 
			
		||||
name: Auto release excalidraw next
 | 
			
		||||
name: Auto release @excalidraw/excalidraw-next
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,4 +1,4 @@
 | 
			
		||||
name: Auto release excalidraw preview
 | 
			
		||||
name: Auto release preview @excalidraw/excalidraw-preview
 | 
			
		||||
on:
 | 
			
		||||
  issue_comment:
 | 
			
		||||
    types: [created, edited]
 | 
			
		||||
@@ -6,7 +6,7 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  Auto-release-excalidraw-preview:
 | 
			
		||||
    name: Auto release preview
 | 
			
		||||
    if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
 | 
			
		||||
    if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: React to release comment
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -10,16 +10,11 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
      - name: Login to DockerHub
 | 
			
		||||
        uses: docker/login-action@v2
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: docker/build-push-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: Build and push
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          context: .
 | 
			
		||||
          push: true
 | 
			
		||||
          tags: excalidraw/excalidraw:latest
 | 
			
		||||
          repository: excalidraw/excalidraw
 | 
			
		||||
          tag_with_ref: true
 | 
			
		||||
          tag_with_sha: true
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -19,9 +19,11 @@ logs
 | 
			
		||||
node_modules
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
package-lock.json
 | 
			
		||||
static
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
src/packages/excalidraw/example/public/bundle.js
 | 
			
		||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
 | 
			
		||||
src/packages/excalidraw/example/public/excalidraw.development.js
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										45
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@@ -88,7 +88,7 @@ Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/exc
 | 
			
		||||
 | 
			
		||||
### Code Sandbox
 | 
			
		||||
 | 
			
		||||
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw
 | 
			
		||||
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
 | 
			
		||||
  - You may need to sign in with GitHub and reload the page
 | 
			
		||||
- You can start coding instantly, and even send PRs from there!
 | 
			
		||||
 | 
			
		||||
@@ -128,41 +128,14 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
 | 
			
		||||
 | 
			
		||||
#### Commands
 | 
			
		||||
 | 
			
		||||
##### Install the dependencies
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Run the project
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn start
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Reformat all files with Prettier
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn fix
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Run tests
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn test
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Update test snapshots
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn test:update
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### Test for formatting with Prettier
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
yarn test:code
 | 
			
		||||
```
 | 
			
		||||
| Command            | Description                       |
 | 
			
		||||
| ------------------ | --------------------------------- |
 | 
			
		||||
| `yarn`             | Install the dependencies          |
 | 
			
		||||
| `yarn start`       | Run the project                   |
 | 
			
		||||
| `yarn fix`         | Reformat all files with Prettier  |
 | 
			
		||||
| `yarn test`        | Run tests                         |
 | 
			
		||||
| `yarn test:update` | Update test snapshots             |
 | 
			
		||||
| `yarn test:code`   | Test for formatting with Prettier |
 | 
			
		||||
 | 
			
		||||
#### Docker Compose
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								dev-docs/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,20 +0,0 @@
 | 
			
		||||
# Dependencies
 | 
			
		||||
/node_modules
 | 
			
		||||
 | 
			
		||||
# Production
 | 
			
		||||
/build
 | 
			
		||||
 | 
			
		||||
# Generated files
 | 
			
		||||
.docusaurus
 | 
			
		||||
.cache-loader
 | 
			
		||||
 | 
			
		||||
# Misc
 | 
			
		||||
.DS_Store
 | 
			
		||||
.env.local
 | 
			
		||||
.env.development.local
 | 
			
		||||
.env.test.local
 | 
			
		||||
.env.production.local
 | 
			
		||||
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
@@ -1,41 +0,0 @@
 | 
			
		||||
# Website
 | 
			
		||||
 | 
			
		||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
 | 
			
		||||
 | 
			
		||||
### Installation
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ yarn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Local Development
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ yarn start
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
 | 
			
		||||
 | 
			
		||||
### Build
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ yarn build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
 | 
			
		||||
 | 
			
		||||
### Deployment
 | 
			
		||||
 | 
			
		||||
Using SSH:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ USE_SSH=true yarn deploy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Not using SSH:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ GIT_USER=<Your GitHub username> yarn deploy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
  presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
 | 
			
		||||
};
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
sidebar_position: 1
 | 
			
		||||
title: Overview
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
sidebar_position: 1
 | 
			
		||||
title: Introduction
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
 | 
			
		||||
 | 
			
		||||
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
sidebar_position: 1
 | 
			
		||||
title: Overview
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).
 | 
			
		||||
@@ -1,121 +0,0 @@
 | 
			
		||||
// @ts-check
 | 
			
		||||
// Note: type annotations allow type checking and IDEs autocompletion
 | 
			
		||||
 | 
			
		||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
 | 
			
		||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
 | 
			
		||||
 | 
			
		||||
/** @type {import('@docusaurus/types').Config} */
 | 
			
		||||
const config = {
 | 
			
		||||
  title: "Excalidraw developer docs",
 | 
			
		||||
  tagline:
 | 
			
		||||
    "For Excalidraw contributors or those integrating the Excalidraw editor",
 | 
			
		||||
  url: "https://docs.excalidraw.com.com",
 | 
			
		||||
  baseUrl: "/",
 | 
			
		||||
  onBrokenLinks: "throw",
 | 
			
		||||
  onBrokenMarkdownLinks: "warn",
 | 
			
		||||
  favicon: "img/favicon.ico",
 | 
			
		||||
  organizationName: "Excalidraw", // Usually your GitHub org/user name.
 | 
			
		||||
  projectName: "excalidraw", // Usually your repo name.
 | 
			
		||||
 | 
			
		||||
  // Even if you don't use internalization, you can use this field to set useful
 | 
			
		||||
  // metadata like html lang. For example, if your site is Chinese, you may want
 | 
			
		||||
  // to replace "en" with "zh-Hans".
 | 
			
		||||
  i18n: {
 | 
			
		||||
    defaultLocale: "en",
 | 
			
		||||
    locales: ["en"],
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  presets: [
 | 
			
		||||
    [
 | 
			
		||||
      "classic",
 | 
			
		||||
      /** @type {import('@docusaurus/preset-classic').Options} */
 | 
			
		||||
      ({
 | 
			
		||||
        docs: {
 | 
			
		||||
          sidebarPath: require.resolve("./sidebars.js"),
 | 
			
		||||
          // Please change this to your repo.
 | 
			
		||||
          editUrl: "https://github.com/excalidraw/docs/tree/master/",
 | 
			
		||||
        },
 | 
			
		||||
        theme: {
 | 
			
		||||
          customCss: require.resolve("./src/css/custom.css"),
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    ],
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  themeConfig:
 | 
			
		||||
    /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
 | 
			
		||||
    ({
 | 
			
		||||
      navbar: {
 | 
			
		||||
        title: "Excalidraw Docs",
 | 
			
		||||
        logo: {
 | 
			
		||||
          alt: "Excalidraw Logo",
 | 
			
		||||
          src: "img/logo.svg",
 | 
			
		||||
        },
 | 
			
		||||
        items: [
 | 
			
		||||
          {
 | 
			
		||||
            type: "doc",
 | 
			
		||||
            docId: "get-started",
 | 
			
		||||
            position: "left",
 | 
			
		||||
            label: "Get started",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            to: "https://blog.excalidraw.com",
 | 
			
		||||
            label: "Blog",
 | 
			
		||||
            position: "left",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            to: "https://github.com/excalidraw/excalidraw",
 | 
			
		||||
            label: "GitHub",
 | 
			
		||||
            position: "right",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      },
 | 
			
		||||
      footer: {
 | 
			
		||||
        style: "dark",
 | 
			
		||||
        links: [
 | 
			
		||||
          {
 | 
			
		||||
            title: "Docs",
 | 
			
		||||
            items: [
 | 
			
		||||
              {
 | 
			
		||||
                label: "Get Started",
 | 
			
		||||
                to: "/docs/get-started",
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            title: "Community",
 | 
			
		||||
            items: [
 | 
			
		||||
              {
 | 
			
		||||
                label: "Discord",
 | 
			
		||||
                href: "https://discord.gg/UexuTaE",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                label: "Twitter",
 | 
			
		||||
                href: "https://twitter.com/excalidraw",
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            title: "More",
 | 
			
		||||
            items: [
 | 
			
		||||
              {
 | 
			
		||||
                label: "Blog",
 | 
			
		||||
                to: "https://blog.excalidraw.com",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                label: "GitHub",
 | 
			
		||||
                to: "https://github.com/excalidraw/excalidraw",
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        copyright: `Made with ❤️ Built with Docusaurus`,
 | 
			
		||||
      },
 | 
			
		||||
      prism: {
 | 
			
		||||
        theme: lightCodeTheme,
 | 
			
		||||
        darkTheme: darkCodeTheme,
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = config;
 | 
			
		||||
@@ -1,46 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "docs",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "docusaurus": "docusaurus",
 | 
			
		||||
    "start": "docusaurus start --port 3003",
 | 
			
		||||
    "build": "docusaurus build",
 | 
			
		||||
    "swizzle": "docusaurus swizzle",
 | 
			
		||||
    "deploy": "docusaurus deploy",
 | 
			
		||||
    "clear": "docusaurus clear",
 | 
			
		||||
    "serve": "docusaurus serve",
 | 
			
		||||
    "write-translations": "docusaurus write-translations",
 | 
			
		||||
    "write-heading-ids": "docusaurus write-heading-ids",
 | 
			
		||||
    "typecheck": "tsc"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@docusaurus/core": "2.0.0-rc.1",
 | 
			
		||||
    "@docusaurus/preset-classic": "2.0.0-rc.1",
 | 
			
		||||
    "@mdx-js/react": "^1.6.22",
 | 
			
		||||
    "clsx": "^1.2.1",
 | 
			
		||||
    "prism-react-renderer": "^1.3.5",
 | 
			
		||||
    "react": "^17.0.2",
 | 
			
		||||
    "react-dom": "^17.0.2"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@docusaurus/module-type-aliases": "2.0.0-rc.1",
 | 
			
		||||
    "@tsconfig/docusaurus": "^1.0.5",
 | 
			
		||||
    "typescript": "^4.7.4"
 | 
			
		||||
  },
 | 
			
		||||
  "browserslist": {
 | 
			
		||||
    "production": [
 | 
			
		||||
      ">0.5%",
 | 
			
		||||
      "not dead",
 | 
			
		||||
      "not op_mini all"
 | 
			
		||||
    ],
 | 
			
		||||
    "development": [
 | 
			
		||||
      "last 1 chrome version",
 | 
			
		||||
      "last 1 firefox version",
 | 
			
		||||
      "last 1 safari version"
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=16.14"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,31 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Creating a sidebar enables you to:
 | 
			
		||||
 - create an ordered group of docs
 | 
			
		||||
 - render a sidebar for each doc of that group
 | 
			
		||||
 - provide next/previous navigation
 | 
			
		||||
 | 
			
		||||
 The sidebars can be generated from the filesystem, or explicitly defined here.
 | 
			
		||||
 | 
			
		||||
 Create as many sidebars as you want.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// @ts-check
 | 
			
		||||
 | 
			
		||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
 | 
			
		||||
const sidebars = {
 | 
			
		||||
  // By default, Docusaurus generates a sidebar from the docs folder structure
 | 
			
		||||
  tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
 | 
			
		||||
 | 
			
		||||
  // But you can create a sidebar manually
 | 
			
		||||
  /*
 | 
			
		||||
  tutorialSidebar: [
 | 
			
		||||
    {
 | 
			
		||||
      type: 'category',
 | 
			
		||||
      label: 'Tutorial',
 | 
			
		||||
      items: ['hello'],
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
   */
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = sidebars;
 | 
			
		||||
@@ -1,62 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import styles from "./styles.module.css";
 | 
			
		||||
 | 
			
		||||
const FeatureList = [
 | 
			
		||||
  {
 | 
			
		||||
    title: "Learn how Excalidraw works",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_innovative.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>Want to contribute to Excalidraw but got lost in the codebase?</>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Integrate Excalidraw",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Want to build your own app powered by Excalidraw by don't know where to
 | 
			
		||||
        start?
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Help us improve",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_add_files.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Are the docs missing something? Anything you had trouble understanding
 | 
			
		||||
        or needs an explanation? Come contribute to the docs to make them even
 | 
			
		||||
        better!
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
function Feature({ Svg, title, description }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx("col col--4")}>
 | 
			
		||||
      <div className="text--center">
 | 
			
		||||
        <Svg className={styles.featureSvg} role="img" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="text--center padding-horiz--md">
 | 
			
		||||
        <h3>{title}</h3>
 | 
			
		||||
        <p>{description}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function HomepageFeatures() {
 | 
			
		||||
  return (
 | 
			
		||||
    <section className={styles.features}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="row">
 | 
			
		||||
          {FeatureList.map((props, idx) => (
 | 
			
		||||
            <Feature key={idx} {...props} />
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,70 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import styles from "./styles.module.css";
 | 
			
		||||
 | 
			
		||||
type FeatureItem = {
 | 
			
		||||
  title: string;
 | 
			
		||||
  Svg: React.ComponentType<React.ComponentProps<"svg">>;
 | 
			
		||||
  description: JSX.Element;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const FeatureList: FeatureItem[] = [
 | 
			
		||||
  {
 | 
			
		||||
    title: "Easy to Use",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Docusaurus was designed from the ground up to be easily installed and
 | 
			
		||||
        used to get your website up and running quickly.
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Focus on What Matters",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Docusaurus lets you focus on your docs, and we'll do the chores. Go
 | 
			
		||||
        ahead and move your docs into the <code>docs</code> directory.
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Powered by React",
 | 
			
		||||
    Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
 | 
			
		||||
    description: (
 | 
			
		||||
      <>
 | 
			
		||||
        Extend or customize your website layout by reusing React. Docusaurus can
 | 
			
		||||
        be extended while reusing the same header and footer.
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
function Feature({ title, Svg, description }: FeatureItem) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx("col col--4")}>
 | 
			
		||||
      <div className="text--center">
 | 
			
		||||
        <Svg className={styles.featureSvg} role="img" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="text--center padding-horiz--md">
 | 
			
		||||
        <h3>{title}</h3>
 | 
			
		||||
        <p>{description}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function HomepageFeatures(): JSX.Element {
 | 
			
		||||
  return (
 | 
			
		||||
    <section className={styles.features}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="row">
 | 
			
		||||
          {FeatureList.map((props, idx) => (
 | 
			
		||||
            <Feature key={idx} {...props} />
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,11 +0,0 @@
 | 
			
		||||
.features {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  padding: 2rem 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.featureSvg {
 | 
			
		||||
  height: 200px;
 | 
			
		||||
  width: 200px;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,43 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Any CSS included here will be global. The classic template
 | 
			
		||||
 * bundles Infima by default. Infima is a CSS framework designed to
 | 
			
		||||
 * work well for content-centric websites.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* You can override the default Infima variables here. */
 | 
			
		||||
:root {
 | 
			
		||||
  --ifm-color-primary: #6965db;
 | 
			
		||||
  --ifm-color-primary-dark: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-darker: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-darkest: #4a47b1;
 | 
			
		||||
  --ifm-color-primary-light: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-lighter: #5b57d1;
 | 
			
		||||
  --ifm-color-primary-lightest: #5b57d1;
 | 
			
		||||
  --ifm-code-font-size: 95%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
 | 
			
		||||
[data-theme="dark"] {
 | 
			
		||||
  --ifm-color-primary: #5650f0;
 | 
			
		||||
  --ifm-color-primary-dark: #4b46d8;
 | 
			
		||||
  --ifm-color-primary-darker: #4b46d8;
 | 
			
		||||
  --ifm-color-primary-darkest: #3e39be;
 | 
			
		||||
  --ifm-color-primary-light: #3f3d64;
 | 
			
		||||
  --ifm-color-primary-lighter: #3f3d64;
 | 
			
		||||
  --ifm-color-primary-lightest: #3f3d64;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.docusaurus-highlight-code-line {
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.1);
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin: 0 calc(-1 * var(--ifm-pre-padding));
 | 
			
		||||
  padding: 0 var(--ifm-pre-padding);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .docusaurus-highlight-code-line {
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.3);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .navbar__logo {
 | 
			
		||||
  filter: invert(93%) hue-rotate(180deg);
 | 
			
		||||
}
 | 
			
		||||
@@ -1,42 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import Layout from "@theme/Layout";
 | 
			
		||||
import Link from "@docusaurus/Link";
 | 
			
		||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
 | 
			
		||||
import styles from "./index.module.css";
 | 
			
		||||
import HomepageFeatures from "@site/src/components/Homepage";
 | 
			
		||||
 | 
			
		||||
function HomepageHeader() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <header className={clsx("hero hero--primary", styles.heroBanner)}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <h1 className="hero__title">{siteConfig.title}</h1>
 | 
			
		||||
        <p className="hero__subtitle">{siteConfig.tagline}</p>
 | 
			
		||||
        <div className={styles.buttons}>
 | 
			
		||||
          <Link
 | 
			
		||||
            className="button button--secondary button--lg"
 | 
			
		||||
            to="/docs/get-started"
 | 
			
		||||
          >
 | 
			
		||||
            Get started
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </header>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <Layout
 | 
			
		||||
      title={`Hello from ${siteConfig.title}`}
 | 
			
		||||
      description="Description will go into a meta tag in <head />"
 | 
			
		||||
    >
 | 
			
		||||
      <HomepageHeader />
 | 
			
		||||
      <main>
 | 
			
		||||
        <HomepageFeatures />
 | 
			
		||||
      </main>
 | 
			
		||||
    </Layout>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,27 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * CSS files with the .module.css suffix will be treated as CSS modules
 | 
			
		||||
 * and scoped locally.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
.heroBanner {
 | 
			
		||||
  padding: 4rem 0;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .heroBanner {
 | 
			
		||||
  color: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: 996px) {
 | 
			
		||||
  .heroBanner {
 | 
			
		||||
    padding: 2rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.buttons {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,42 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import Layout from "@theme/Layout";
 | 
			
		||||
import Link from "@docusaurus/Link";
 | 
			
		||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
 | 
			
		||||
import styles from "./index.module.css";
 | 
			
		||||
import HomepageFeatures from "@site/src/components/Homepage";
 | 
			
		||||
 | 
			
		||||
function HomepageHeader() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <header className={clsx("hero hero--primary", styles.heroBanner)}>
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <h1 className="hero__title">{siteConfig.title}</h1>
 | 
			
		||||
        <p className="hero__subtitle">{siteConfig.tagline}</p>
 | 
			
		||||
        <div className={styles.buttons}>
 | 
			
		||||
          <Link
 | 
			
		||||
            className="button button--secondary button--lg"
 | 
			
		||||
            to="/docs/get-started"
 | 
			
		||||
          >
 | 
			
		||||
            Get started
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </header>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
  const { siteConfig } = useDocusaurusContext();
 | 
			
		||||
  return (
 | 
			
		||||
    <Layout
 | 
			
		||||
      title={`Hello from ${siteConfig.title}`}
 | 
			
		||||
      description="Description will go into a meta tag in <head />"
 | 
			
		||||
    >
 | 
			
		||||
      <HomepageHeader />
 | 
			
		||||
      <main>
 | 
			
		||||
        <HomepageFeatures />
 | 
			
		||||
      </main>
 | 
			
		||||
    </Layout>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
title: Markdown page example
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
# Markdown page example
 | 
			
		||||
 | 
			
		||||
You don't need React to write simple standalone pages.
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 5.0 KiB  | 
| 
		 Before Width: | Height: | Size: 26 KiB  | 
@@ -1,4 +0,0 @@
 | 
			
		||||
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
 | 
			
		||||
	<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
 | 
			
		||||
	<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 3.4 KiB  | 
| 
		 Before Width: | Height: | Size: 5.7 KiB  | 
| 
		 Before Width: | Height: | Size: 12 KiB  | 
| 
		 Before Width: | Height: | Size: 5.4 KiB  | 
@@ -1,7 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  // This file is not used in compilation. It is here just for a nice editor experience.
 | 
			
		||||
  "extends": "@tsconfig/docusaurus/tsconfig.json",
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "baseUrl": "."
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7489
									
								
								dev-docs/yarn.lock
									
									
									
									
									
								
							
							
						
						@@ -1,11 +1,12 @@
 | 
			
		||||
rules_version = '2';
 | 
			
		||||
service firebase.storage {
 | 
			
		||||
  match /b/{bucket}/o {
 | 
			
		||||
    match /{files}/rooms/{room}/{file} {
 | 
			
		||||
    	allow get, write: if true;
 | 
			
		||||
    }
 | 
			
		||||
    match /{files}/shareLinks/{shareLink}/{file} {
 | 
			
		||||
    	allow get, write: if true;
 | 
			
		||||
    match /{migrations} {
 | 
			
		||||
      match /{scenes}/{scene} {
 | 
			
		||||
      	allow get, write: if true;
 | 
			
		||||
        // redundant, but let's be explicit'
 | 
			
		||||
        allow list: if false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										50
									
								
								package.json
									
									
									
									
									
								
							
							
						
						@@ -22,37 +22,35 @@
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "@testing-library/jest-dom": "5.16.2",
 | 
			
		||||
    "@testing-library/react": "12.1.5",
 | 
			
		||||
    "@tldraw/vec": "1.7.1",
 | 
			
		||||
    "@testing-library/react": "12.1.2",
 | 
			
		||||
    "@tldraw/vec": "1.4.3",
 | 
			
		||||
    "@types/jest": "27.4.0",
 | 
			
		||||
    "@types/pica": "5.1.3",
 | 
			
		||||
    "@types/react": "18.0.15",
 | 
			
		||||
    "@types/react-dom": "18.0.6",
 | 
			
		||||
    "@types/react": "17.0.39",
 | 
			
		||||
    "@types/react-dom": "17.0.11",
 | 
			
		||||
    "@types/socket.io-client": "1.4.36",
 | 
			
		||||
    "browser-fs-access": "0.29.1",
 | 
			
		||||
    "browser-fs-access": "0.24.1",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "fake-indexeddb": "3.1.7",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.4",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.2",
 | 
			
		||||
    "idb-keyval": "6.0.3",
 | 
			
		||||
    "image-blob-reduce": "3.0.1",
 | 
			
		||||
    "jotai": "1.6.4",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "3.3.3",
 | 
			
		||||
    "nanoid": "3.1.32",
 | 
			
		||||
    "open-color": "1.9.1",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "perfect-freehand": "1.2.0",
 | 
			
		||||
    "pica": "7.1.1",
 | 
			
		||||
    "perfect-freehand": "1.0.16",
 | 
			
		||||
    "png-chunk-text": "1.0.0",
 | 
			
		||||
    "png-chunks-encode": "1.0.0",
 | 
			
		||||
    "png-chunks-extract": "1.0.0",
 | 
			
		||||
    "points-on-curve": "0.2.0",
 | 
			
		||||
    "pwacompat": "2.0.17",
 | 
			
		||||
    "react": "18.2.0",
 | 
			
		||||
    "react-dom": "18.2.0",
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react-scripts": "4.0.3",
 | 
			
		||||
    "roughjs": "4.5.2",
 | 
			
		||||
    "sass": "1.51.0",
 | 
			
		||||
    "sass": "1.49.7",
 | 
			
		||||
    "socket.io-client": "2.3.1",
 | 
			
		||||
    "typescript": "4.5.5"
 | 
			
		||||
  },
 | 
			
		||||
@@ -60,19 +58,22 @@
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
			
		||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
			
		||||
    "@types/chai": "4.3.0",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.7",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/pako": "1.0.3",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.7",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.6",
 | 
			
		||||
    "chai": "4.3.6",
 | 
			
		||||
    "dotenv": "16.0.1",
 | 
			
		||||
    "eslint-config-prettier": "8.5.0",
 | 
			
		||||
    "dotenv": "10.0.0",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "husky": "7.0.4",
 | 
			
		||||
    "jest-canvas-mock": "2.4.0",
 | 
			
		||||
    "lint-staged": "12.3.7",
 | 
			
		||||
    "jest-canvas-mock": "2.3.1",
 | 
			
		||||
    "lint-staged": "12.3.3",
 | 
			
		||||
    "pepjs": "0.5.3",
 | 
			
		||||
    "prettier": "2.6.2",
 | 
			
		||||
    "rewire": "6.0.0"
 | 
			
		||||
    "prettier": "2.5.1",
 | 
			
		||||
    "rewire": "5.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "resolutions": {
 | 
			
		||||
    "@typescript-eslint/typescript-estree": "5.10.2"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=14.0.0"
 | 
			
		||||
@@ -92,8 +93,7 @@
 | 
			
		||||
    "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
 | 
			
		||||
    "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
 | 
			
		||||
    "build:version": "node ./scripts/build-version.js",
 | 
			
		||||
    "build:prebuild": "node ./scripts/prebuild.js",
 | 
			
		||||
    "build": "yarn build:prebuild && yarn build:app && yarn build:version",
 | 
			
		||||
    "build": "yarn build:app && yarn build:version",
 | 
			
		||||
    "eject": "react-scripts eject",
 | 
			
		||||
    "fix:code": "yarn test:code --fix",
 | 
			
		||||
    "fix:other": "yarn prettier --write",
 | 
			
		||||
@@ -111,8 +111,6 @@
 | 
			
		||||
    "test:typecheck": "tsc",
 | 
			
		||||
    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
 | 
			
		||||
    "test": "yarn test:app",
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js",
 | 
			
		||||
    "prerelease": "node scripts/prerelease.js",
 | 
			
		||||
    "release": "node scripts/release.js"
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,28 +11,3 @@
 | 
			
		||||
  src: url("Cascadia.woff2");
 | 
			
		||||
  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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,95 +8,49 @@
 | 
			
		||||
      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="mobile-web-app-capable" content="yes" />
 | 
			
		||||
    <meta name="theme-color" content="#121212" />
 | 
			
		||||
 | 
			
		||||
    <!-- Primary Meta Tags -->
 | 
			
		||||
    <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"
 | 
			
		||||
    />
 | 
			
		||||
    <meta name="theme-color" content="#000" />
 | 
			
		||||
 | 
			
		||||
    <!-- General tags -->
 | 
			
		||||
    <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="og-image.png" />
 | 
			
		||||
 | 
			
		||||
    <!------------------------------------------------------------------------->
 | 
			
		||||
    <!--   to minimize white flash on load when user has dark mode enabled   -->
 | 
			
		||||
    <script>
 | 
			
		||||
      try {
 | 
			
		||||
        //
 | 
			
		||||
        const theme = window.localStorage.getItem("excalidraw-theme");
 | 
			
		||||
        if (theme === "dark") {
 | 
			
		||||
          document.documentElement.classList.add("dark");
 | 
			
		||||
        }
 | 
			
		||||
      } catch {}
 | 
			
		||||
    </script>
 | 
			
		||||
    <style>
 | 
			
		||||
      html.dark {
 | 
			
		||||
        background-color: #121212;
 | 
			
		||||
        color: #fff;
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
    <!------------------------------------------------------------------------->
 | 
			
		||||
    <!-- OpenGraph tags -->
 | 
			
		||||
    <meta property="og:url" content="https://excalidraw.com" />
 | 
			
		||||
    <meta property="og:site_name" content="Excalidraw" />
 | 
			
		||||
    <meta property="og:type" content="website" />
 | 
			
		||||
    <meta property="og:title" content="Excalidraw" />
 | 
			
		||||
    <meta
 | 
			
		||||
      property="og:description"
 | 
			
		||||
      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
			
		||||
    />
 | 
			
		||||
    <!-- OG tags require an absolute url for images -->
 | 
			
		||||
    <meta
 | 
			
		||||
      property="og:image"
 | 
			
		||||
      name="twitter:image"
 | 
			
		||||
      content="https://excalidraw.com/og-image.png"
 | 
			
		||||
    />
 | 
			
		||||
    <meta
 | 
			
		||||
      property="og:image:secure_url"
 | 
			
		||||
      name="twitter:image"
 | 
			
		||||
      content="https://excalidraw.com/og-image.png"
 | 
			
		||||
    />
 | 
			
		||||
    <meta property="og:image:width" content="1280" />
 | 
			
		||||
    <meta property="og:image:height" content="669" />
 | 
			
		||||
    <meta property="og:image:alt" content="Excalidraw logo with byline." />
 | 
			
		||||
 | 
			
		||||
    <script>
 | 
			
		||||
      // Redirect Excalidraw+ users which have auto-redirect enabled.
 | 
			
		||||
      //
 | 
			
		||||
      // Redirect only the bare root path, so link/room/library urls are not
 | 
			
		||||
      // redirected.
 | 
			
		||||
      //
 | 
			
		||||
      // Putting into index.html for best performance (can't redirect on server
 | 
			
		||||
      // due to location.hash checks).
 | 
			
		||||
      if (
 | 
			
		||||
        window.location.pathname === "/" &&
 | 
			
		||||
        !window.location.hash &&
 | 
			
		||||
        !window.location.search &&
 | 
			
		||||
        // if its present redirect
 | 
			
		||||
        document.cookie.includes("excplus-autoredirect=true")
 | 
			
		||||
      ) {
 | 
			
		||||
        window.location.href = "https://app.excalidraw.com";
 | 
			
		||||
      }
 | 
			
		||||
    </script>
 | 
			
		||||
    <!-- 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."
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
 | 
			
		||||
 | 
			
		||||
@@ -125,22 +79,6 @@
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link rel="stylesheet" href="fonts.css" type="text/css" />
 | 
			
		||||
    <% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
 | 
			
		||||
    <script>
 | 
			
		||||
      {
 | 
			
		||||
        const _WebSocket = window.WebSocket;
 | 
			
		||||
        window.WebSocket = function (url) {
 | 
			
		||||
          if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
 | 
			
		||||
            console.info(
 | 
			
		||||
              "[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
 | 
			
		||||
            );
 | 
			
		||||
          } else {
 | 
			
		||||
            return new _WebSocket(url);
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    </script>
 | 
			
		||||
    <% } %>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.EXCALIDRAW_ASSET_PATH = "/";
 | 
			
		||||
      // setting this so that libraries installation reuses this window tab.
 | 
			
		||||
@@ -166,8 +104,8 @@
 | 
			
		||||
      body,
 | 
			
		||||
      html {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system,
 | 
			
		||||
          Segoe UI, Roboto, Helvetica, Arial, sans-serif;
 | 
			
		||||
        --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
 | 
			
		||||
          Roboto, Helvetica, Arial, sans-serif;
 | 
			
		||||
        font-family: var(--ui-font);
 | 
			
		||||
        -webkit-text-size-adjust: 100%;
 | 
			
		||||
 | 
			
		||||
@@ -182,10 +120,30 @@
 | 
			
		||||
        width: 1px;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        clip: rect(1px, 1px, 1px, 1px);
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
        white-space: nowrap; /* added line */
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        z-index: 999;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        pointer-events: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage span {
 | 
			
		||||
        background-color: var(--button-gray-1);
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        padding: 0.8em 1.2em;
 | 
			
		||||
        color: var(--popup-text-color);
 | 
			
		||||
        font-size: 1.3em;
 | 
			
		||||
      }
 | 
			
		||||
      #root {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        -webkit-touch-callout: none;
 | 
			
		||||
@@ -194,10 +152,8 @@
 | 
			
		||||
        -moz-user-select: none;
 | 
			
		||||
        -ms-user-select: none;
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      @media screen and (min-width: 1200px) {
 | 
			
		||||
        #root {
 | 
			
		||||
        @media screen and (min-width: 1200px) {
 | 
			
		||||
          -webkit-touch-callout: default;
 | 
			
		||||
          -webkit-user-select: auto;
 | 
			
		||||
          -khtml-user-select: auto;
 | 
			
		||||
@@ -214,6 +170,10 @@
 | 
			
		||||
    <header>
 | 
			
		||||
      <h1 class="visually-hidden">Excalidraw</h1>
 | 
			
		||||
    </header>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
    <div id="root">
 | 
			
		||||
      <div class="LoadingMessage">
 | 
			
		||||
        <span>Loading scene...</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 26 KiB  | 
| 
		 Before Width: | Height: | Size: 26 KiB  | 
| 
		 Before Width: | Height: | Size: 27 KiB  | 
@@ -1,9 +1,3 @@
 | 
			
		||||
User-agent: Twitterbot
 | 
			
		||||
Disallow:
 | 
			
		||||
 | 
			
		||||
User-agent: facebookexternalhit
 | 
			
		||||
Disallow:
 | 
			
		||||
 | 
			
		||||
user-agent: *
 | 
			
		||||
Allow: /$
 | 
			
		||||
Disallow: /
 | 
			
		||||
 
 | 
			
		||||
@@ -5,25 +5,22 @@ const core = require("@actions/core");
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
const pkg = require(excalidrawPackage);
 | 
			
		||||
const isPreview = process.argv.slice(2)[0] === "preview";
 | 
			
		||||
 | 
			
		||||
const getShortCommitHash = () => {
 | 
			
		||||
  return execSync("git rev-parse --short HEAD").toString().trim();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const publish = () => {
 | 
			
		||||
  const tag = isPreview ? "preview" : "next";
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    execSync(`yarn  --frozen-lockfile`);
 | 
			
		||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
 | 
			
		||||
    console.info(`Published ${pkg.name}@${tag}🎉`);
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
			
		||||
    console.info("Published 🎉");
 | 
			
		||||
    core.setOutput(
 | 
			
		||||
      "result",
 | 
			
		||||
      `**Preview version has been shipped** :rocket:
 | 
			
		||||
    You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
 | 
			
		||||
    You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
 | 
			
		||||
    );
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    core.setOutput("result", "package couldn't be published :warning:!");
 | 
			
		||||
@@ -54,19 +51,27 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // update package.json
 | 
			
		||||
  pkg.name = "@excalidraw/excalidraw-next";
 | 
			
		||||
  let version = `${pkg.version}-${getShortCommitHash()}`;
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
  const isPreview = process.argv.slice(2)[0] === "preview";
 | 
			
		||||
  if (isPreview) {
 | 
			
		||||
    // use pullNumber-commithash as the version for preview
 | 
			
		||||
    const pullRequestNumber = process.argv.slice(3)[0];
 | 
			
		||||
    version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
 | 
			
		||||
    // replace "excalidraw-next" with "excalidraw-preview"
 | 
			
		||||
    pkg.name = "@excalidraw/excalidraw-preview";
 | 
			
		||||
    data = data.replace(/excalidraw-next/g, "excalidraw-preview");
 | 
			
		||||
    data = data.trim();
 | 
			
		||||
  }
 | 
			
		||||
  pkg.version = version;
 | 
			
		||||
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
			
		||||
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
  console.info("Publish in progress...");
 | 
			
		||||
  publish();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +0,0 @@
 | 
			
		||||
const { exec } = require("child_process");
 | 
			
		||||
 | 
			
		||||
// get files changed between prev and head commit
 | 
			
		||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
			
		||||
  if (error || stderr) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
  const changedFiles = stdout.trim().split("\n");
 | 
			
		||||
 | 
			
		||||
  const docFiles = changedFiles.filter((file) => {
 | 
			
		||||
    return file.indexOf("docs") >= 0;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!docFiles.length) {
 | 
			
		||||
    console.info("Skipping building docs as no valid diff found");
 | 
			
		||||
    process.exit(0);
 | 
			
		||||
  }
 | 
			
		||||
  // Exit code 1 to build the docs in ignoredBuildStep
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
});
 | 
			
		||||
@@ -15,7 +15,6 @@ const crowdinMap = {
 | 
			
		||||
  "fa-IR": "en-fa",
 | 
			
		||||
  "fi-FI": "en-fi",
 | 
			
		||||
  "fr-FR": "en-fr",
 | 
			
		||||
  "gl-ES": "en-gl",
 | 
			
		||||
  "he-IL": "en-he",
 | 
			
		||||
  "hi-IN": "en-hi",
 | 
			
		||||
  "hu-HU": "en-hu",
 | 
			
		||||
@@ -24,7 +23,6 @@ const crowdinMap = {
 | 
			
		||||
  "ja-JP": "en-ja",
 | 
			
		||||
  "kab-KAB": "en-kab",
 | 
			
		||||
  "ko-KR": "en-ko",
 | 
			
		||||
  "ku-TR": "en-ku",
 | 
			
		||||
  "my-MM": "en-my",
 | 
			
		||||
  "nb-NO": "en-nb",
 | 
			
		||||
  "nl-NL": "en-nl",
 | 
			
		||||
@@ -38,7 +36,6 @@ const crowdinMap = {
 | 
			
		||||
  "ru-RU": "en-ru",
 | 
			
		||||
  "si-LK": "en-silk",
 | 
			
		||||
  "sk-SK": "en-sk",
 | 
			
		||||
  "sl-SI": "en-sl",
 | 
			
		||||
  "sv-SE": "en-sv",
 | 
			
		||||
  "ta-IN": "en-ta",
 | 
			
		||||
  "tr-TR": "en-tr",
 | 
			
		||||
@@ -50,8 +47,6 @@ const crowdinMap = {
 | 
			
		||||
  "lv-LV": "en-lv",
 | 
			
		||||
  "cs-CZ": "en-cs",
 | 
			
		||||
  "kk-KZ": "en-kk",
 | 
			
		||||
  "vi-vn": "en-vi",
 | 
			
		||||
  "mr-in": "en-mr",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flags = {
 | 
			
		||||
@@ -67,7 +62,6 @@ const flags = {
 | 
			
		||||
  "fa-IR": "🇮🇷",
 | 
			
		||||
  "fi-FI": "🇫🇮",
 | 
			
		||||
  "fr-FR": "🇫🇷",
 | 
			
		||||
  "gl-ES": "🇪🇸",
 | 
			
		||||
  "he-IL": "🇮🇱",
 | 
			
		||||
  "hi-IN": "🇮🇳",
 | 
			
		||||
  "hu-HU": "🇭🇺",
 | 
			
		||||
@@ -77,7 +71,6 @@ const flags = {
 | 
			
		||||
  "kab-KAB": "🏳",
 | 
			
		||||
  "kk-KZ": "🇰🇿",
 | 
			
		||||
  "ko-KR": "🇰🇷",
 | 
			
		||||
  "ku-TR": "🏳",
 | 
			
		||||
  "lt-LT": "🇱🇹",
 | 
			
		||||
  "lv-LV": "🇱🇻",
 | 
			
		||||
  "my-MM": "🇲🇲",
 | 
			
		||||
@@ -93,7 +86,6 @@ const flags = {
 | 
			
		||||
  "ru-RU": "🇷🇺",
 | 
			
		||||
  "si-LK": "🇱🇰",
 | 
			
		||||
  "sk-SK": "🇸🇰",
 | 
			
		||||
  "sl-SI": "🇸🇮",
 | 
			
		||||
  "sv-SE": "🇸🇪",
 | 
			
		||||
  "ta-IN": "🇮🇳",
 | 
			
		||||
  "tr-TR": "🇹🇷",
 | 
			
		||||
@@ -101,9 +93,6 @@ const flags = {
 | 
			
		||||
  "zh-CN": "🇨🇳",
 | 
			
		||||
  "zh-HK": "🇭🇰",
 | 
			
		||||
  "zh-TW": "🇹🇼",
 | 
			
		||||
  "eu-ES": "🇪🇦",
 | 
			
		||||
  "vi-VN": "🇻🇳",
 | 
			
		||||
  "mr-IN": "🇮🇳",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const languages = {
 | 
			
		||||
@@ -144,7 +133,6 @@ const languages = {
 | 
			
		||||
  "ru-RU": "Русский",
 | 
			
		||||
  "si-LK": "සිංහල",
 | 
			
		||||
  "sk-SK": "Slovenčina",
 | 
			
		||||
  "sl-SI": "Slovenščina",
 | 
			
		||||
  "sv-SE": "Svenska",
 | 
			
		||||
  "ta-IN": "Tamil",
 | 
			
		||||
  "tr-TR": "Türkçe",
 | 
			
		||||
@@ -152,8 +140,6 @@ const languages = {
 | 
			
		||||
  "zh-CN": "简体中文",
 | 
			
		||||
  "zh-HK": "繁體中文 (香港)",
 | 
			
		||||
  "zh-TW": "繁體中文",
 | 
			
		||||
  "vi-VN": "Tiếng Việt",
 | 
			
		||||
  "mr-IN": "मराठी",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const percentages = fs.readFileSync(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +0,0 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const path = require("path");
 | 
			
		||||
 | 
			
		||||
// for development purposes we want to have the service-worker.js file
 | 
			
		||||
// accessible from the public folder. On build though, we need to compile it
 | 
			
		||||
// and CRA expects that file to be in src/ folder.
 | 
			
		||||
const moveServiceWorkerScript = () => {
 | 
			
		||||
  const oldPath = path.resolve(__dirname, "../public/service-worker.js");
 | 
			
		||||
  const newPath = path.resolve(__dirname, "../src/service-worker.js");
 | 
			
		||||
 | 
			
		||||
  fs.rename(oldPath, newPath, (error) => {
 | 
			
		||||
    if (error) {
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
    console.info("public/service-worker.js moved to src/");
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
moveServiceWorkerScript();
 | 
			
		||||
@@ -1,37 +0,0 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const util = require("util");
 | 
			
		||||
const exec = util.promisify(require("child_process").exec);
 | 
			
		||||
const updateChangelog = require("./updateChangelog");
 | 
			
		||||
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
 | 
			
		||||
const updatePackageVersion = (nextVersion) => {
 | 
			
		||||
  const pkg = require(excalidrawPackage);
 | 
			
		||||
  pkg.version = nextVersion;
 | 
			
		||||
  const content = `${JSON.stringify(pkg, null, 2)}\n`;
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, content, "utf-8");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const prerelease = async (nextVersion) => {
 | 
			
		||||
  try {
 | 
			
		||||
    await updateChangelog(nextVersion);
 | 
			
		||||
    updatePackageVersion(nextVersion);
 | 
			
		||||
    await exec(`git add -u`);
 | 
			
		||||
    await exec(
 | 
			
		||||
      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    console.info("Done!");
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const nextVersion = process.argv.slice(2)[0];
 | 
			
		||||
if (!nextVersion) {
 | 
			
		||||
  console.error("Pass the next version to release!");
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
}
 | 
			
		||||
prerelease(nextVersion);
 | 
			
		||||
@@ -1,44 +1,39 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const { execSync } = require("child_process");
 | 
			
		||||
const util = require("util");
 | 
			
		||||
const exec = util.promisify(require("child_process").exec);
 | 
			
		||||
const updateReadme = require("./updateReadme");
 | 
			
		||||
const updateChangelog = require("./updateChangelog");
 | 
			
		||||
 | 
			
		||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
			
		||||
const pkg = require(excalidrawPackage);
 | 
			
		||||
 | 
			
		||||
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
const updateReadme = () => {
 | 
			
		||||
  const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
 | 
			
		||||
 | 
			
		||||
  // remove note for stable readme
 | 
			
		||||
  const data = originalReadMe.slice(excalidrawIndex);
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
const updatePackageVersion = (nextVersion) => {
 | 
			
		||||
  const pkg = require(excalidrawPackage);
 | 
			
		||||
  pkg.version = nextVersion;
 | 
			
		||||
  const content = `${JSON.stringify(pkg, null, 2)}\n`;
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, content, "utf-8");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const publish = () => {
 | 
			
		||||
const release = async (nextVersion) => {
 | 
			
		||||
  try {
 | 
			
		||||
    execSync(`yarn  --frozen-lockfile`);
 | 
			
		||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
			
		||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
			
		||||
    updateReadme();
 | 
			
		||||
    await updateChangelog(nextVersion);
 | 
			
		||||
    updatePackageVersion(nextVersion);
 | 
			
		||||
    await exec(`git add -u`);
 | 
			
		||||
    await exec(
 | 
			
		||||
      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
 | 
			
		||||
    );
 | 
			
		||||
    /* eslint-disable no-console */
 | 
			
		||||
    console.log("Done!");
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const release = () => {
 | 
			
		||||
  updateReadme();
 | 
			
		||||
  console.info("Note for stable readme removed");
 | 
			
		||||
 | 
			
		||||
  publish();
 | 
			
		||||
  console.info(`Published ${pkg.version}!`);
 | 
			
		||||
 | 
			
		||||
  // revert readme after release
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
 | 
			
		||||
  console.info("Readme reverted");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
release();
 | 
			
		||||
const nextVersion = process.argv.slice(2)[0];
 | 
			
		||||
if (!nextVersion) {
 | 
			
		||||
  console.error("Pass the next version to release!");
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
}
 | 
			
		||||
release(nextVersion);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								scripts/updateReadme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,27 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
 | 
			
		||||
const updateReadme = () => {
 | 
			
		||||
  const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
			
		||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
 | 
			
		||||
  // remove note for unstable release
 | 
			
		||||
  data = data.replace(
 | 
			
		||||
    /<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
 | 
			
		||||
    "",
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // replace "excalidraw-next" with "excalidraw"
 | 
			
		||||
  data = data.replace(/excalidraw-next/g, "excalidraw");
 | 
			
		||||
  data = data.trim();
 | 
			
		||||
 | 
			
		||||
  const demoIndex = data.indexOf("### Demo");
 | 
			
		||||
  const excalidrawNextNote =
 | 
			
		||||
    "#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
 | 
			
		||||
  // Add excalidraw next note to try out for unreleased changes
 | 
			
		||||
  data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = updateReadme;
 | 
			
		||||
@@ -7,7 +7,6 @@ import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -25,9 +24,9 @@ export const actionAddToLibrary = register({
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return app.library
 | 
			
		||||
      .getLatestLibrary()
 | 
			
		||||
      .loadLibrary()
 | 
			
		||||
      .then((items) => {
 | 
			
		||||
        return app.library.setLibrary([
 | 
			
		||||
        return app.library.saveLibrary([
 | 
			
		||||
          {
 | 
			
		||||
            id: randomId(),
 | 
			
		||||
            status: "unpublished",
 | 
			
		||||
@@ -42,7 +41,7 @@ export const actionAddToLibrary = register({
 | 
			
		||||
          commitToHistory: false,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            toast: { message: t("toast.addedToLibrary") },
 | 
			
		||||
            toastMessage: t("toast.addedToLibrary"),
 | 
			
		||||
          },
 | 
			
		||||
        };
 | 
			
		||||
      })
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,6 @@ const alignSelectedElements = (
 | 
			
		||||
 | 
			
		||||
export const actionAlignTop = register({
 | 
			
		||||
  name: "alignTop",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -60,7 +59,7 @@ export const actionAlignTop = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={AlignTopIcon}
 | 
			
		||||
      icon={<AlignTopIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.alignTop")} — ${getShortcutKey(
 | 
			
		||||
        "CtrlOrCmd+Shift+Up",
 | 
			
		||||
@@ -73,7 +72,6 @@ export const actionAlignTop = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignBottom = register({
 | 
			
		||||
  name: "alignBottom",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -90,7 +88,7 @@ export const actionAlignBottom = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={AlignBottomIcon}
 | 
			
		||||
      icon={<AlignBottomIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.alignBottom")} — ${getShortcutKey(
 | 
			
		||||
        "CtrlOrCmd+Shift+Down",
 | 
			
		||||
@@ -103,7 +101,6 @@ export const actionAlignBottom = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignLeft = register({
 | 
			
		||||
  name: "alignLeft",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -120,7 +117,7 @@ export const actionAlignLeft = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={AlignLeftIcon}
 | 
			
		||||
      icon={<AlignLeftIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.alignLeft")} — ${getShortcutKey(
 | 
			
		||||
        "CtrlOrCmd+Shift+Left",
 | 
			
		||||
@@ -133,8 +130,6 @@ export const actionAlignLeft = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignRight = register({
 | 
			
		||||
  name: "alignRight",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -151,7 +146,7 @@ export const actionAlignRight = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={AlignRightIcon}
 | 
			
		||||
      icon={<AlignRightIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.alignRight")} — ${getShortcutKey(
 | 
			
		||||
        "CtrlOrCmd+Shift+Right",
 | 
			
		||||
@@ -164,8 +159,6 @@ export const actionAlignRight = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignVerticallyCentered = register({
 | 
			
		||||
  name: "alignVerticallyCentered",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -180,7 +173,7 @@ export const actionAlignVerticallyCentered = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={CenterVerticallyIcon}
 | 
			
		||||
      icon={<CenterVerticallyIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={t("labels.centerVertically")}
 | 
			
		||||
      aria-label={t("labels.centerVertically")}
 | 
			
		||||
@@ -191,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignHorizontallyCentered = register({
 | 
			
		||||
  name: "alignHorizontallyCentered",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -206,7 +198,7 @@ export const actionAlignHorizontallyCentered = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={CenterHorizontallyIcon}
 | 
			
		||||
      icon={<CenterHorizontallyIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={t("labels.centerHorizontally")}
 | 
			
		||||
      aria-label={t("labels.centerHorizontally")}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,136 +0,0 @@
 | 
			
		||||
import { VERTICAL_ALIGN } from "../constants";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  measureText,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  hasBoundTextElement,
 | 
			
		||||
  isTextBindableContainer,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawTextContainer,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
    return selectedElements.some((element) => hasBoundTextElement(element));
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionBindText = register({
 | 
			
		||||
  name: "bindText",
 | 
			
		||||
  contextItemLabel: "labels.bindText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
 | 
			
		||||
    if (selectedElements.length === 2) {
 | 
			
		||||
      const textElement =
 | 
			
		||||
        isTextElement(selectedElements[0]) ||
 | 
			
		||||
        isTextElement(selectedElements[1]);
 | 
			
		||||
 | 
			
		||||
      let bindingContainer;
 | 
			
		||||
      if (isTextBindableContainer(selectedElements[0])) {
 | 
			
		||||
        bindingContainer = selectedElements[0];
 | 
			
		||||
      } else if (isTextBindableContainer(selectedElements[1])) {
 | 
			
		||||
        bindingContainer = selectedElements[1];
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        textElement &&
 | 
			
		||||
        bindingContainer &&
 | 
			
		||||
        getBoundTextElement(bindingContainer) === null
 | 
			
		||||
      ) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    let textElement: ExcalidrawTextElement;
 | 
			
		||||
    let container: ExcalidrawTextContainer;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      isTextElement(selectedElements[0]) &&
 | 
			
		||||
      isTextBindableContainer(selectedElements[1])
 | 
			
		||||
    ) {
 | 
			
		||||
      textElement = selectedElements[0];
 | 
			
		||||
      container = selectedElements[1];
 | 
			
		||||
    } else {
 | 
			
		||||
      textElement = selectedElements[1] as ExcalidrawTextElement;
 | 
			
		||||
      container = selectedElements[0] as ExcalidrawTextContainer;
 | 
			
		||||
    }
 | 
			
		||||
    mutateElement(textElement, {
 | 
			
		||||
      containerId: container.id,
 | 
			
		||||
      verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
    });
 | 
			
		||||
    mutateElement(container, {
 | 
			
		||||
      boundElements: (container.boundElements || []).concat({
 | 
			
		||||
        type: "text",
 | 
			
		||||
        id: textElement.id,
 | 
			
		||||
      }),
 | 
			
		||||
    });
 | 
			
		||||
    redrawTextBoundingBox(textElement, container);
 | 
			
		||||
    const updatedElements = elements.slice();
 | 
			
		||||
    const textElementIndex = updatedElements.findIndex(
 | 
			
		||||
      (ele) => ele.id === textElement.id,
 | 
			
		||||
    );
 | 
			
		||||
    updatedElements.splice(textElementIndex, 1);
 | 
			
		||||
    const containerIndex = updatedElements.findIndex(
 | 
			
		||||
      (ele) => ele.id === container.id,
 | 
			
		||||
    );
 | 
			
		||||
    updatedElements.splice(containerIndex + 1, 0, textElement);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: updatedElements,
 | 
			
		||||
      appState: { ...appState, selectedElementIds: { [container.id]: true } },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,13 +1,8 @@
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import {
 | 
			
		||||
  eraser,
 | 
			
		||||
  MoonIcon,
 | 
			
		||||
  SunIcon,
 | 
			
		||||
  ZoomInIcon,
 | 
			
		||||
  ZoomOutIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
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 { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -16,19 +11,15 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { getStateForZoom } from "../scene/zoom";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getShortcutKey, updateActiveTool } from "../utils";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getDefaultAppState, isEraserActive } from "../appState";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import ClearCanvas from "../components/ClearCanvas";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import MenuItem from "../components/MenuItem";
 | 
			
		||||
import { getShortcutFromShortcutName } from "./shortcuts";
 | 
			
		||||
 | 
			
		||||
export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
  name: "changeViewBackgroundColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, ...value },
 | 
			
		||||
@@ -58,7 +49,6 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionClearCanvas = register({
 | 
			
		||||
  name: "clearCanvas",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    app.imageCache.clear();
 | 
			
		||||
    return {
 | 
			
		||||
@@ -69,6 +59,7 @@ export const actionClearCanvas = register({
 | 
			
		||||
        ...getDefaultAppState(),
 | 
			
		||||
        files: {},
 | 
			
		||||
        theme: appState.theme,
 | 
			
		||||
        elementLocked: appState.elementLocked,
 | 
			
		||||
        penMode: appState.penMode,
 | 
			
		||||
        penDetected: appState.penDetected,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
@@ -76,10 +67,8 @@ export const actionClearCanvas = register({
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
        activeTool:
 | 
			
		||||
          appState.activeTool.type === "image"
 | 
			
		||||
            ? { ...appState.activeTool, type: "selection" }
 | 
			
		||||
            : appState.activeTool,
 | 
			
		||||
        elementType:
 | 
			
		||||
          appState.elementType === "image" ? "selection" : appState.elementType,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
@@ -90,7 +79,6 @@ export const actionClearCanvas = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomIn = register({
 | 
			
		||||
  name: "zoomIn",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -110,13 +98,13 @@ export const actionZoomIn = register({
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      className="zoom-in-button zoom-button"
 | 
			
		||||
      icon={ZoomInIcon}
 | 
			
		||||
      icon={zoomIn}
 | 
			
		||||
      title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
 | 
			
		||||
      aria-label={t("buttons.zoomIn")}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size="small"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -126,7 +114,6 @@ export const actionZoomIn = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomOut = register({
 | 
			
		||||
  name: "zoomOut",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -146,13 +133,13 @@ export const actionZoomOut = register({
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      className="zoom-out-button zoom-button"
 | 
			
		||||
      icon={ZoomOutIcon}
 | 
			
		||||
      icon={zoomOut}
 | 
			
		||||
      title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
 | 
			
		||||
      aria-label={t("buttons.zoomOut")}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size="small"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -162,7 +149,6 @@ export const actionZoomOut = register({
 | 
			
		||||
 | 
			
		||||
export const actionResetZoom = register({
 | 
			
		||||
  name: "resetZoom",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -183,12 +169,13 @@ export const actionResetZoom = register({
 | 
			
		||||
    <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        type="button"
 | 
			
		||||
        className="reset-zoom-button zoom-button"
 | 
			
		||||
        className="reset-zoom-button"
 | 
			
		||||
        title={t("buttons.resetZoom")}
 | 
			
		||||
        aria-label={t("buttons.resetZoom")}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          updateData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        size="small"
 | 
			
		||||
      >
 | 
			
		||||
        {(appState.zoom.value * 100).toFixed(0)}%
 | 
			
		||||
      </ToolButton>
 | 
			
		||||
@@ -212,7 +199,7 @@ const zoomValueToFitBoundsOnViewport = (
 | 
			
		||||
  const zoomAdjustedToSteps =
 | 
			
		||||
    Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
 | 
			
		||||
  const clampedZoomValueToFitElements = Math.min(
 | 
			
		||||
    Math.max(zoomAdjustedToSteps, MIN_ZOOM),
 | 
			
		||||
    Math.max(zoomAdjustedToSteps, ZOOM_STEP),
 | 
			
		||||
    1,
 | 
			
		||||
  );
 | 
			
		||||
  return clampedZoomValueToFitElements as NormalizedZoomValue;
 | 
			
		||||
@@ -260,7 +247,6 @@ const zoomToFitElements = (
 | 
			
		||||
 | 
			
		||||
export const actionZoomToSelected = register({
 | 
			
		||||
  name: "zoomToSelection",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.code === CODES.TWO &&
 | 
			
		||||
@@ -271,7 +257,6 @@ export const actionZoomToSelected = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomToFit = register({
 | 
			
		||||
  name: "zoomToFit",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.code === CODES.ONE &&
 | 
			
		||||
@@ -282,7 +267,6 @@ export const actionZoomToFit = register({
 | 
			
		||||
 | 
			
		||||
export const actionToggleTheme = register({
 | 
			
		||||
  name: "toggleTheme",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -294,65 +278,14 @@ export const actionToggleTheme = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <MenuItem
 | 
			
		||||
      label={
 | 
			
		||||
        appState.theme === "dark"
 | 
			
		||||
          ? t("buttons.lightMode")
 | 
			
		||||
          : t("buttons.darkMode")
 | 
			
		||||
      }
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
 | 
			
		||||
      }}
 | 
			
		||||
      icon={appState.theme === "dark" ? SunIcon : MoonIcon}
 | 
			
		||||
      dataTestId="toggle-dark-mode"
 | 
			
		||||
      shortcut={getShortcutFromShortcutName("toggleTheme")}
 | 
			
		||||
    />
 | 
			
		||||
    <div style={{ marginInlineStart: "0.25rem" }}>
 | 
			
		||||
      <DarkModeToggle
 | 
			
		||||
        value={appState.theme}
 | 
			
		||||
        onChange={(theme) => {
 | 
			
		||||
          updateData(theme);
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionErase = register({
 | 
			
		||||
  name: "eraser",
 | 
			
		||||
  trackEvent: { category: "toolbar" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    let activeTool: AppState["activeTool"];
 | 
			
		||||
 | 
			
		||||
    if (isEraserActive(appState)) {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        ...(appState.activeTool.lastActiveToolBeforeEraser || {
 | 
			
		||||
          type: "selection",
 | 
			
		||||
        }),
 | 
			
		||||
        lastActiveToolBeforeEraser: null,
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        type: "eraser",
 | 
			
		||||
        lastActiveToolBeforeEraser: appState.activeTool,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedElementIds: {},
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        activeTool,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.key === KEYS.E,
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={eraser}
 | 
			
		||||
      className={clsx("eraser", { active: isEraserActive(appState) })}
 | 
			
		||||
      title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
 | 
			
		||||
      aria-label={t("toolBar.eraser")}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    ></ToolButton>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,16 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import {
 | 
			
		||||
  copyTextToSystemClipboard,
 | 
			
		||||
  copyToClipboard,
 | 
			
		||||
  probablySupportsClipboardWriteText,
 | 
			
		||||
} from "../clipboard";
 | 
			
		||||
import { copyToClipboard } from "../clipboard";
 | 
			
		||||
import { actionDeleteSelected } from "./actionDeleteSelected";
 | 
			
		||||
import { getSelectedElements } from "../scene/selection";
 | 
			
		||||
import { exportCanvas } from "../data/index";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionCopy = register({
 | 
			
		||||
  name: "copy",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
 | 
			
		||||
    copyToClipboard(selectedElements, appState, app.files);
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState, app.files);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
@@ -30,18 +23,16 @@ export const actionCopy = register({
 | 
			
		||||
 | 
			
		||||
export const actionCut = register({
 | 
			
		||||
  name: "cut",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, data, app) => {
 | 
			
		||||
    actionCopy.perform(elements, appState, data, app);
 | 
			
		||||
    return actionDeleteSelected.perform(elements, appState);
 | 
			
		||||
  },
 | 
			
		||||
  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({
 | 
			
		||||
  name: "copyAsSvg",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: async (elements, appState, _data, app) => {
 | 
			
		||||
    if (!app.canvas) {
 | 
			
		||||
      return {
 | 
			
		||||
@@ -82,7 +73,6 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
 | 
			
		||||
export const actionCopyAsPng = register({
 | 
			
		||||
  name: "copyAsPng",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: async (elements, appState, _data, app) => {
 | 
			
		||||
    if (!app.canvas) {
 | 
			
		||||
      return {
 | 
			
		||||
@@ -107,16 +97,14 @@ export const actionCopyAsPng = register({
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          toast: {
 | 
			
		||||
            message: t("toast.copyToClipboardAsPng", {
 | 
			
		||||
              exportSelection: selectedElements.length
 | 
			
		||||
                ? t("toast.selection")
 | 
			
		||||
                : t("toast.canvas"),
 | 
			
		||||
              exportColorScheme: appState.exportWithDarkMode
 | 
			
		||||
                ? t("buttons.darkMode")
 | 
			
		||||
                : t("buttons.lightMode"),
 | 
			
		||||
            }),
 | 
			
		||||
          },
 | 
			
		||||
          toastMessage: t("toast.copyToClipboardAsPng", {
 | 
			
		||||
            exportSelection: selectedElements.length
 | 
			
		||||
              ? t("toast.selection")
 | 
			
		||||
              : t("toast.canvas"),
 | 
			
		||||
            exportColorScheme: appState.exportWithDarkMode
 | 
			
		||||
              ? t("buttons.darkMode")
 | 
			
		||||
              : t("buttons.lightMode"),
 | 
			
		||||
          }),
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
@@ -134,35 +122,3 @@ export const actionCopyAsPng = register({
 | 
			
		||||
  contextItemLabel: "labels.copyAsPng",
 | 
			
		||||
  keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const copyText = register({
 | 
			
		||||
  name: "copyText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const text = selectedElements
 | 
			
		||||
      .reduce((acc: string[], element) => {
 | 
			
		||||
        if (isTextElement(element)) {
 | 
			
		||||
          acc.push(element.text);
 | 
			
		||||
        }
 | 
			
		||||
        return acc;
 | 
			
		||||
      }, [])
 | 
			
		||||
      .join("\n\n");
 | 
			
		||||
    copyTextToSystemClipboard(text);
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    return (
 | 
			
		||||
      probablySupportsClipboardWriteText &&
 | 
			
		||||
      getSelectedElements(elements, appState, true).some(isTextElement)
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.copyText",
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { trash } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
@@ -11,8 +12,6 @@ import { getElementsInGroup } from "../groups";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
import { updateActiveTool } from "../utils";
 | 
			
		||||
import { TrashIcon } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
const deleteSelectedElements = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -59,7 +58,6 @@ const handleGroupEditingState = (
 | 
			
		||||
 | 
			
		||||
export const actionDeleteSelected = register({
 | 
			
		||||
  name: "deleteSelectedElements",
 | 
			
		||||
  trackEvent: { category: "element", action: "delete" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
@@ -72,22 +70,13 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      if (!element) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      // case: no point selected → do nothing, as deleting the whole element
 | 
			
		||||
      // is most likely a mistake, where you wanted to delete a specific point
 | 
			
		||||
      // but failed to select it (or you thought it's selected, while it was
 | 
			
		||||
      // only in a hover state)
 | 
			
		||||
      if (selectedPointsIndices == null) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 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;
 | 
			
		||||
        });
 | 
			
		||||
      if (
 | 
			
		||||
        // case: no point selected → delete whole element
 | 
			
		||||
        selectedPointsIndices == null ||
 | 
			
		||||
        // case: deleting last remaining point
 | 
			
		||||
        element.points.length < 2
 | 
			
		||||
      ) {
 | 
			
		||||
        const nextElements = elements.filter((el) => el.id !== element.id);
 | 
			
		||||
        const nextAppState = handleGroupEditingState(appState, nextElements);
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
@@ -144,7 +133,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...nextAppState,
 | 
			
		||||
        activeTool: updateActiveTool(appState, { type: "selection" }),
 | 
			
		||||
        elementType: "selection",
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: isSomeElementSelected(
 | 
			
		||||
@@ -158,7 +147,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={TrashIcon}
 | 
			
		||||
      icon={trash}
 | 
			
		||||
      title={t("labels.delete")}
 | 
			
		||||
      aria-label={t("labels.delete")}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import {
 | 
			
		||||
  DistributeVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { distributeElements, Distribution } from "../distribute";
 | 
			
		||||
import { distributeElements, Distribution } from "../disitrubte";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -39,7 +39,6 @@ const distributeSelectedElements = (
 | 
			
		||||
 | 
			
		||||
export const distributeHorizontally = register({
 | 
			
		||||
  name: "distributeHorizontally",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -56,7 +55,7 @@ export const distributeHorizontally = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={DistributeHorizontallyIcon}
 | 
			
		||||
      icon={<DistributeHorizontallyIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
 | 
			
		||||
        "Alt+H",
 | 
			
		||||
@@ -69,7 +68,6 @@ export const distributeHorizontally = register({
 | 
			
		||||
 | 
			
		||||
export const distributeVertically = register({
 | 
			
		||||
  name: "distributeVertically",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -86,7 +84,7 @@ export const distributeVertically = register({
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={DistributeVerticallyIcon}
 | 
			
		||||
      icon={<DistributeVerticallyIcon theme={appState.theme} />}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
 | 
			
		||||
      aria-label={t("labels.distributeVertically")}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { duplicateElement, getNonDeletedElements } from "../element";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { clone } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
@@ -18,11 +19,9 @@ import { ActionResult } from "./types";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { bindTextToShapeAfterDuplication } from "../element/textElement";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
import { DuplicateIcon } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
export const actionDuplicateSelection = register({
 | 
			
		||||
  name: "duplicateSelection",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    // duplicate selected point(s) if editing a line
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
@@ -49,7 +48,7 @@ export const actionDuplicateSelection = register({
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={DuplicateIcon}
 | 
			
		||||
      icon={clone}
 | 
			
		||||
      title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
 | 
			
		||||
        "CtrlOrCmd+D",
 | 
			
		||||
      )}`}
 | 
			
		||||
@@ -128,15 +127,12 @@ const duplicateElements = (
 | 
			
		||||
      {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        selectedElementIds: newElements.reduce(
 | 
			
		||||
          (acc: Record<ExcalidrawElement["id"], true>, element) => {
 | 
			
		||||
            if (!isBoundToContainer(element)) {
 | 
			
		||||
              acc[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
            return acc;
 | 
			
		||||
          },
 | 
			
		||||
          {},
 | 
			
		||||
        ),
 | 
			
		||||
        selectedElementIds: newElements.reduce((acc, element) => {
 | 
			
		||||
          if (!isBoundToContainer(element)) {
 | 
			
		||||
            acc[element.id] = true;
 | 
			
		||||
          }
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {} as any),
 | 
			
		||||
      },
 | 
			
		||||
      getNonDeletedElements(finalElements),
 | 
			
		||||
    ),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import { LoadIcon, questionCircle, saveAs } from "../components/icons";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { load, questionCircle, saveAs } from "../components/icons";
 | 
			
		||||
import { ProjectName } from "../components/ProjectName";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import "../components/ToolIcon.scss";
 | 
			
		||||
@@ -7,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
			
		||||
import { resaveAsImageWithScene } from "../data/resave";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useDevice } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { CheckboxItem } from "../components/CheckboxItem";
 | 
			
		||||
@@ -19,13 +20,11 @@ import { ActiveFile } from "../components/ActiveFile";
 | 
			
		||||
import { isImageFileHandle } from "../data/blob";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
import { Theme } from "../element/types";
 | 
			
		||||
import MenuItem from "../components/MenuItem";
 | 
			
		||||
import { getShortcutFromShortcutName } from "./shortcuts";
 | 
			
		||||
 | 
			
		||||
export const actionChangeProjectName = register({
 | 
			
		||||
  name: "changeProjectName",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    trackEvent("change", "title");
 | 
			
		||||
    return { appState: { ...appState, name: value }, commitToHistory: false };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, appProps }) => (
 | 
			
		||||
@@ -42,7 +41,6 @@ export const actionChangeProjectName = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportScale = register({
 | 
			
		||||
  name: "changeExportScale",
 | 
			
		||||
  trackEvent: { category: "export", action: "scale" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportScale: value },
 | 
			
		||||
@@ -91,7 +89,6 @@ export const actionChangeExportScale = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportBackground = register({
 | 
			
		||||
  name: "changeExportBackground",
 | 
			
		||||
  trackEvent: { category: "export", action: "toggleBackground" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportBackground: value },
 | 
			
		||||
@@ -110,7 +107,6 @@ export const actionChangeExportBackground = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportEmbedScene = register({
 | 
			
		||||
  name: "changeExportEmbedScene",
 | 
			
		||||
  trackEvent: { category: "export", action: "embedScene" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportEmbedScene: value },
 | 
			
		||||
@@ -132,7 +128,6 @@ export const actionChangeExportEmbedScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionSaveToActiveFile = register({
 | 
			
		||||
  name: "saveToActiveFile",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    const fileHandleExists = !!appState.fileHandle;
 | 
			
		||||
 | 
			
		||||
@@ -146,15 +141,13 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          fileHandle,
 | 
			
		||||
          toast: fileHandleExists
 | 
			
		||||
            ? {
 | 
			
		||||
                message: fileHandle?.name
 | 
			
		||||
                  ? t("toast.fileSavedToFilename").replace(
 | 
			
		||||
                      "{filename}",
 | 
			
		||||
                      `"${fileHandle.name}"`,
 | 
			
		||||
                    )
 | 
			
		||||
                  : t("toast.fileSaved"),
 | 
			
		||||
              }
 | 
			
		||||
          toastMessage: fileHandleExists
 | 
			
		||||
            ? fileHandle?.name
 | 
			
		||||
              ? t("toast.fileSavedToFilename").replace(
 | 
			
		||||
                  "{filename}",
 | 
			
		||||
                  `"${fileHandle.name}"`,
 | 
			
		||||
                )
 | 
			
		||||
              : t("toast.fileSaved")
 | 
			
		||||
            : null,
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
@@ -179,7 +172,6 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
 | 
			
		||||
export const actionSaveFileToDisk = register({
 | 
			
		||||
  name: "saveFileToDisk",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(
 | 
			
		||||
@@ -208,7 +200,7 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
      icon={saveAs}
 | 
			
		||||
      title={t("buttons.saveAs")}
 | 
			
		||||
      aria-label={t("buttons.saveAs")}
 | 
			
		||||
      showAriaLabel={useDevice().isMobile}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      hidden={!nativeFileSystemSupported}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-as-button"
 | 
			
		||||
@@ -218,7 +210,6 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, _, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
@@ -246,20 +237,21 @@ export const actionLoadScene = register({
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <MenuItem
 | 
			
		||||
      label={t("buttons.load")}
 | 
			
		||||
      icon={LoadIcon}
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={load}
 | 
			
		||||
      title={t("buttons.load")}
 | 
			
		||||
      aria-label={t("buttons.load")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      dataTestId="load-button"
 | 
			
		||||
      shortcut={getShortcutFromShortcutName("loadScene")}
 | 
			
		||||
      data-testid="load-button"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionExportWithDarkMode = register({
 | 
			
		||||
  name: "exportWithDarkMode",
 | 
			
		||||
  trackEvent: { category: "export", action: "toggleTheme" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportWithDarkMode: value },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { isInvisiblySmallElement } from "../element";
 | 
			
		||||
import { updateActiveTool, resetCursor } from "../utils";
 | 
			
		||||
import { resetCursor } from "../utils";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { done } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -13,13 +13,11 @@ import {
 | 
			
		||||
  maybeBindLinearElement,
 | 
			
		||||
  bindOrUnbindLinearElement,
 | 
			
		||||
} from "../element/binding";
 | 
			
		||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isBindingElement } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionFinalize = register({
 | 
			
		||||
  name: "finalize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const { elementId, startBindingElement, endBindingElement } =
 | 
			
		||||
        appState.editingLinearElement;
 | 
			
		||||
@@ -40,7 +38,6 @@ export const actionFinalize = register({
 | 
			
		||||
              : undefined,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            cursorButton: "up",
 | 
			
		||||
            editingLinearElement: null,
 | 
			
		||||
          },
 | 
			
		||||
          commitToHistory: true,
 | 
			
		||||
@@ -50,12 +47,8 @@ export const actionFinalize = register({
 | 
			
		||||
 | 
			
		||||
    let newElements = elements;
 | 
			
		||||
 | 
			
		||||
    const pendingImageElement =
 | 
			
		||||
      appState.pendingImageElementId &&
 | 
			
		||||
      scene.getElement(appState.pendingImageElementId);
 | 
			
		||||
 | 
			
		||||
    if (pendingImageElement) {
 | 
			
		||||
      mutateElement(pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    if (appState.pendingImageElement) {
 | 
			
		||||
      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (window.document.activeElement instanceof HTMLElement) {
 | 
			
		||||
@@ -126,47 +119,27 @@ export const actionFinalize = register({
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        !appState.activeTool.locked &&
 | 
			
		||||
        appState.activeTool.type !== "freedraw"
 | 
			
		||||
      ) {
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "freedraw") {
 | 
			
		||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (!appState.activeTool.locked &&
 | 
			
		||||
        appState.activeTool.type !== "freedraw") ||
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "freedraw") ||
 | 
			
		||||
      !multiPointElement
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor(canvas);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let activeTool: AppState["activeTool"];
 | 
			
		||||
    if (appState.activeTool.type === "eraser") {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        ...(appState.activeTool.lastActiveToolBeforeEraser || {
 | 
			
		||||
          type: "selection",
 | 
			
		||||
        }),
 | 
			
		||||
        lastActiveToolBeforeEraser: null,
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool = updateActiveTool(appState, {
 | 
			
		||||
        type: "selection",
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      elements: newElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        cursorButton: "up",
 | 
			
		||||
        activeTool:
 | 
			
		||||
          (appState.activeTool.locked ||
 | 
			
		||||
            appState.activeTool.type === "freedraw") &&
 | 
			
		||||
        elementType:
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "freedraw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.activeTool
 | 
			
		||||
            : activeTool,
 | 
			
		||||
            ? appState.elementType
 | 
			
		||||
            : "selection",
 | 
			
		||||
        draggingElement: null,
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
        editingElement: null,
 | 
			
		||||
@@ -174,21 +147,16 @@ export const actionFinalize = register({
 | 
			
		||||
        suggestedBindings: [],
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.activeTool.locked &&
 | 
			
		||||
          appState.activeTool.type !== "freedraw"
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "freedraw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
              }
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
        // To select the linear element when user has finished mutipoint editing
 | 
			
		||||
        selectedLinearElement:
 | 
			
		||||
          multiPointElement && isLinearElement(multiPointElement)
 | 
			
		||||
            ? new LinearElementEditor(multiPointElement, scene)
 | 
			
		||||
            : appState.selectedLinearElement,
 | 
			
		||||
        pendingImageElementId: null,
 | 
			
		||||
        pendingImageElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.activeTool.type === "freedraw",
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
@@ -197,7 +165,7 @@ export const actionFinalize = register({
 | 
			
		||||
        (!appState.draggingElement && appState.multiElement === null))) ||
 | 
			
		||||
    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
 | 
			
		||||
      appState.multiElement !== null),
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => (
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={done}
 | 
			
		||||
@@ -205,7 +173,6 @@ export const actionFinalize = register({
 | 
			
		||||
      aria-label={t("buttons.done")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      visible={appState.multiElement != null}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -6,14 +6,10 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
			
		||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { getTransformHandles } from "../element/transformHandles";
 | 
			
		||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { updateBoundElements } from "../element/binding";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import {
 | 
			
		||||
  getElementAbsoluteCoords,
 | 
			
		||||
  getElementPointsCoords,
 | 
			
		||||
} from "../element/bounds";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const enableActionFlipHorizontal = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -39,7 +35,6 @@ const enableActionFlipVertical = (
 | 
			
		||||
 | 
			
		||||
export const actionFlipHorizontal = register({
 | 
			
		||||
  name: "flipHorizontal",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "horizontal"),
 | 
			
		||||
@@ -55,7 +50,6 @@ export const actionFlipHorizontal = register({
 | 
			
		||||
 | 
			
		||||
export const actionFlipVertical = register({
 | 
			
		||||
  name: "flipVertical",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "vertical"),
 | 
			
		||||
@@ -122,6 +116,13 @@ const flipElement = (
 | 
			
		||||
  const height = element.height;
 | 
			
		||||
  const originalAngle = normalizeAngle(element.angle);
 | 
			
		||||
 | 
			
		||||
  let finalOffsetX = 0;
 | 
			
		||||
  if (isLinearElement(element) || isFreeDrawElement(element)) {
 | 
			
		||||
    finalOffsetX =
 | 
			
		||||
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
 | 
			
		||||
      element.width;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Rotate back to zero, if necessary
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    angle: normalizeAngle(0),
 | 
			
		||||
@@ -129,6 +130,7 @@ const flipElement = (
 | 
			
		||||
  // Flip unrotated by pulling TransformHandle to opposite side
 | 
			
		||||
  const transformHandles = getTransformHandles(element, appState.zoom);
 | 
			
		||||
  let usingNWHandle = true;
 | 
			
		||||
  let newNCoordsX = 0;
 | 
			
		||||
  let nHandle = transformHandles.nw;
 | 
			
		||||
  if (!nHandle) {
 | 
			
		||||
    // Use ne handle instead
 | 
			
		||||
@@ -142,51 +144,30 @@ const flipElement = (
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let finalOffsetX = 0;
 | 
			
		||||
  if (isLinearElement(element) && element.points.length < 3) {
 | 
			
		||||
    finalOffsetX =
 | 
			
		||||
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
 | 
			
		||||
      element.width;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let initialPointsCoords;
 | 
			
		||||
  if (isLinearElement(element)) {
 | 
			
		||||
    initialPointsCoords = getElementPointsCoords(
 | 
			
		||||
      element,
 | 
			
		||||
      element.points,
 | 
			
		||||
      element.strokeSharpness,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
 | 
			
		||||
 | 
			
		||||
  if (isLinearElement(element) && element.points.length < 3) {
 | 
			
		||||
    for (let index = 1; index < element.points.length; index++) {
 | 
			
		||||
      LinearElementEditor.movePoints(element, [
 | 
			
		||||
        {
 | 
			
		||||
          index,
 | 
			
		||||
          point: [-element.points[index][0], element.points[index][1]],
 | 
			
		||||
        },
 | 
			
		||||
        { index, point: [-element.points[index][0], element.points[index][1]] },
 | 
			
		||||
      ]);
 | 
			
		||||
    }
 | 
			
		||||
    LinearElementEditor.normalizePoints(element);
 | 
			
		||||
  } else {
 | 
			
		||||
    const elWidth = initialPointsCoords
 | 
			
		||||
      ? initialPointsCoords[2] - initialPointsCoords[0]
 | 
			
		||||
      : initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
 | 
			
		||||
 | 
			
		||||
    const startPoint = initialPointsCoords
 | 
			
		||||
      ? [initialPointsCoords[0], initialPointsCoords[1]]
 | 
			
		||||
      : [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
 | 
			
		||||
 | 
			
		||||
    // calculate new x-coord for transformation
 | 
			
		||||
    newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
 | 
			
		||||
    resizeSingleElement(
 | 
			
		||||
      new Map().set(element.id, element),
 | 
			
		||||
      false,
 | 
			
		||||
      true,
 | 
			
		||||
      element,
 | 
			
		||||
      usingNWHandle ? "nw" : "ne",
 | 
			
		||||
      true,
 | 
			
		||||
      usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
 | 
			
		||||
      startPoint[1],
 | 
			
		||||
      false,
 | 
			
		||||
      newNCoordsX,
 | 
			
		||||
      nHandle[1],
 | 
			
		||||
    );
 | 
			
		||||
    // fix the size to account for handle sizes
 | 
			
		||||
    mutateElement(element, {
 | 
			
		||||
      width,
 | 
			
		||||
      height,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Rotate by (360 degrees - original angle)
 | 
			
		||||
@@ -203,34 +184,9 @@ const flipElement = (
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    x: originalX + finalOffsetX,
 | 
			
		||||
    y: originalY,
 | 
			
		||||
    width,
 | 
			
		||||
    height,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  updateBoundElements(element);
 | 
			
		||||
 | 
			
		||||
  if (initialPointsCoords && isLinearElement(element)) {
 | 
			
		||||
    // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
 | 
			
		||||
    // There's still room for improvement since when the line roughness is > 1
 | 
			
		||||
    // we still have a small offset of the origin when fliipping the element.
 | 
			
		||||
    const finalPointsCoords = getElementPointsCoords(
 | 
			
		||||
      element,
 | 
			
		||||
      element.points,
 | 
			
		||||
      element.strokeSharpness,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
 | 
			
		||||
    const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
 | 
			
		||||
 | 
			
		||||
    const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
 | 
			
		||||
 | 
			
		||||
    mutateElement(element, {
 | 
			
		||||
      x: element.x + coordsDiff * 0.5,
 | 
			
		||||
      y: element.y,
 | 
			
		||||
      width,
 | 
			
		||||
      height,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
@@ -54,7 +54,6 @@ const enableActionGroup = (
 | 
			
		||||
 | 
			
		||||
export const actionGroup = register({
 | 
			
		||||
  name: "group",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -132,7 +131,7 @@ export const actionGroup = register({
 | 
			
		||||
  contextItemPredicate: (elements, appState) =>
 | 
			
		||||
    enableActionGroup(elements, appState),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
 | 
			
		||||
    !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      hidden={!enableActionGroup(elements, appState)}
 | 
			
		||||
@@ -148,7 +147,6 @@ export const actionGroup = register({
 | 
			
		||||
 | 
			
		||||
export const actionUngroup = register({
 | 
			
		||||
  name: "ungroup",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const groupIds = getSelectedGroupIds(appState);
 | 
			
		||||
    if (groupIds.length === 0) {
 | 
			
		||||
@@ -189,9 +187,7 @@ export const actionUngroup = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.shiftKey &&
 | 
			
		||||
    event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
    event.key === KEYS.G.toUpperCase(),
 | 
			
		||||
    event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
 | 
			
		||||
  contextItemLabel: "labels.ungroup",
 | 
			
		||||
  contextItemPredicate: (elements, appState) =>
 | 
			
		||||
    getSelectedGroupIds(appState).length > 0,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { Action, ActionResult } from "./types";
 | 
			
		||||
import { UndoIcon, RedoIcon } from "../components/icons";
 | 
			
		||||
import { undo, redo } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import History, { HistoryEntry } from "../history";
 | 
			
		||||
@@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.undoOnce()),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -72,7 +71,7 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={UndoIcon}
 | 
			
		||||
      icon={undo}
 | 
			
		||||
      aria-label={t("buttons.undo")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
 | 
			
		||||
export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "redo",
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.redoOnce()),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -94,7 +92,7 @@ export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={RedoIcon}
 | 
			
		||||
      icon={redo}
 | 
			
		||||
      aria-label={t("buttons.redo")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
 
 | 
			
		||||
@@ -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";
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,16 +1,14 @@
 | 
			
		||||
import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
 | 
			
		||||
import { menu, palette } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { HelpButton } from "../components/HelpButton";
 | 
			
		||||
import MenuItem from "../components/MenuItem";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { HelpIcon } from "../components/HelpIcon";
 | 
			
		||||
 | 
			
		||||
export const actionToggleCanvasMenu = register({
 | 
			
		||||
  name: "toggleCanvasMenu",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform: (_, appState) => ({
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
@@ -21,7 +19,7 @@ export const actionToggleCanvasMenu = register({
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={HamburgerMenuIcon}
 | 
			
		||||
      icon={menu}
 | 
			
		||||
      aria-label={t("buttons.menu")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      selected={appState.openMenu === "canvas"}
 | 
			
		||||
@@ -31,7 +29,6 @@ export const actionToggleCanvasMenu = register({
 | 
			
		||||
 | 
			
		||||
export const actionToggleEditMenu = register({
 | 
			
		||||
  name: "toggleEditMenu",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform: (_elements, appState) => ({
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
@@ -56,7 +53,6 @@ export const actionToggleEditMenu = register({
 | 
			
		||||
 | 
			
		||||
export const actionFullScreen = register({
 | 
			
		||||
  name: "toggleFullScreen",
 | 
			
		||||
  trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
 | 
			
		||||
  perform: () => {
 | 
			
		||||
    if (!isFullScreen()) {
 | 
			
		||||
      allowFullScreen();
 | 
			
		||||
@@ -68,35 +64,25 @@ export const actionFullScreen = register({
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
 | 
			
		||||
  keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionShortcuts = register({
 | 
			
		||||
  name: "toggleShortcuts",
 | 
			
		||||
  trackEvent: { category: "menu", action: "toggleHelpDialog" },
 | 
			
		||||
  perform: (_elements, appState, _, { focusContainer }) => {
 | 
			
		||||
    if (appState.openDialog === "help") {
 | 
			
		||||
    if (appState.showHelpDialog) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        openDialog: appState.openDialog === "help" ? null : "help",
 | 
			
		||||
        showHelpDialog: !appState.showHelpDialog,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ updateData, isInHamburgerMenu }) =>
 | 
			
		||||
    isInHamburgerMenu ? (
 | 
			
		||||
      <MenuItem
 | 
			
		||||
        label={t("helpDialog.title")}
 | 
			
		||||
        dataTestId="help-menu-item"
 | 
			
		||||
        icon={HelpIcon}
 | 
			
		||||
        onClick={updateData}
 | 
			
		||||
        shortcut="?"
 | 
			
		||||
      />
 | 
			
		||||
    ) : (
 | 
			
		||||
      <HelpButton title={t("helpDialog.title")} onClick={updateData} />
 | 
			
		||||
    ),
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <HelpIcon title={t("helpDialog.title")} onClick={updateData} />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) => event.key === KEYS.QUESTION_MARK,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { getClientColors } from "../clients";
 | 
			
		||||
import { getClientColors, getClientInitials } from "../clients";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { Collaborator } from "../types";
 | 
			
		||||
@@ -6,7 +6,6 @@ import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionGoToCollaborator = register({
 | 
			
		||||
  name: "goToCollaborator",
 | 
			
		||||
  trackEvent: { category: "collab" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    const point = value as Collaborator["pointer"];
 | 
			
		||||
    if (!point) {
 | 
			
		||||
@@ -31,18 +30,28 @@ export const actionGoToCollaborator = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => {
 | 
			
		||||
    const [clientId, collaborator] = data as [string, Collaborator];
 | 
			
		||||
    const clientId: string | undefined = data?.id;
 | 
			
		||||
    if (!clientId) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const collaborator = appState.collaborators.get(clientId);
 | 
			
		||||
 | 
			
		||||
    if (!collaborator) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { background, stroke } = getClientColors(clientId, appState);
 | 
			
		||||
    const shortName = getClientInitials(collaborator.username);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Avatar
 | 
			
		||||
        color={background}
 | 
			
		||||
        border={stroke}
 | 
			
		||||
        onClick={() => updateData(collaborator.pointer)}
 | 
			
		||||
        name={collaborator.username || ""}
 | 
			
		||||
        src={collaborator.avatarUrl}
 | 
			
		||||
      />
 | 
			
		||||
      >
 | 
			
		||||
        {shortName}
 | 
			
		||||
      </Avatar>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -2,41 +2,37 @@ import { AppState } from "../../src/types";
 | 
			
		||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { IconPicker } from "../components/IconPicker";
 | 
			
		||||
// TODO barnabasmolnar/editor-redesign
 | 
			
		||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
 | 
			
		||||
// ArrowHead icons
 | 
			
		||||
import {
 | 
			
		||||
  ArrowheadArrowIcon,
 | 
			
		||||
  ArrowheadBarIcon,
 | 
			
		||||
  ArrowheadDotIcon,
 | 
			
		||||
  ArrowheadTriangleIcon,
 | 
			
		||||
  ArrowheadNoneIcon,
 | 
			
		||||
  StrokeStyleDashedIcon,
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  TextAlignTopIcon,
 | 
			
		||||
  TextAlignBottomIcon,
 | 
			
		||||
  TextAlignMiddleIcon,
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  FillCrossHatchIcon,
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  FillSolidIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  FontFamilyHandDrawnIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  SloppinessArchitectIcon,
 | 
			
		||||
  SloppinessArtistIcon,
 | 
			
		||||
  SloppinessCartoonistIcon,
 | 
			
		||||
  StrokeWidthBaseIcon,
 | 
			
		||||
  StrokeWidthBoldIcon,
 | 
			
		||||
  StrokeWidthExtraBoldIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  FreedrawIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  StrokeStyleDashedIcon,
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  StrokeStyleSolidIcon,
 | 
			
		||||
  StrokeWidthIcon,
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  TextAlignRightIcon,
 | 
			
		||||
  TextAlignTopIcon,
 | 
			
		||||
  TextAlignBottomIcon,
 | 
			
		||||
  TextAlignMiddleIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
@@ -170,7 +166,11 @@ const changeFontSize = (
 | 
			
		||||
          let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
 | 
			
		||||
            fontSize: newFontSize,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
          redrawTextBoundingBox(
 | 
			
		||||
            newElement,
 | 
			
		||||
            getContainerElement(oldElement),
 | 
			
		||||
            appState,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          newElement = offsetElementAfterFontResize(oldElement, newElement);
 | 
			
		||||
 | 
			
		||||
@@ -198,7 +198,6 @@ const changeFontSize = (
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
@@ -248,7 +247,6 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeBackgroundColor = register({
 | 
			
		||||
  name: "changeBackgroundColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemBackgroundColor && {
 | 
			
		||||
@@ -291,7 +289,6 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFillStyle = register({
 | 
			
		||||
  name: "changeFillStyle",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -311,17 +308,17 @@ export const actionChangeFillStyle = register({
 | 
			
		||||
          {
 | 
			
		||||
            value: "hachure",
 | 
			
		||||
            text: t("labels.hachure"),
 | 
			
		||||
            icon: FillHachureIcon,
 | 
			
		||||
            icon: <FillHachureIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "cross-hatch",
 | 
			
		||||
            text: t("labels.crossHatch"),
 | 
			
		||||
            icon: FillCrossHatchIcon,
 | 
			
		||||
            icon: <FillCrossHatchIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "solid",
 | 
			
		||||
            text: t("labels.solid"),
 | 
			
		||||
            icon: FillSolidIcon,
 | 
			
		||||
            icon: <FillSolidIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        group="fill"
 | 
			
		||||
@@ -341,7 +338,6 @@ export const actionChangeFillStyle = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeWidth = register({
 | 
			
		||||
  name: "changeStrokeWidth",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -362,17 +358,17 @@ export const actionChangeStrokeWidth = register({
 | 
			
		||||
          {
 | 
			
		||||
            value: 1,
 | 
			
		||||
            text: t("labels.thin"),
 | 
			
		||||
            icon: StrokeWidthBaseIcon,
 | 
			
		||||
            icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 2,
 | 
			
		||||
            text: t("labels.bold"),
 | 
			
		||||
            icon: StrokeWidthBoldIcon,
 | 
			
		||||
            icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 4,
 | 
			
		||||
            text: t("labels.extraBold"),
 | 
			
		||||
            icon: StrokeWidthExtraBoldIcon,
 | 
			
		||||
            icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
@@ -389,7 +385,6 @@ export const actionChangeStrokeWidth = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeSloppiness = register({
 | 
			
		||||
  name: "changeSloppiness",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -411,17 +406,17 @@ export const actionChangeSloppiness = register({
 | 
			
		||||
          {
 | 
			
		||||
            value: 0,
 | 
			
		||||
            text: t("labels.architect"),
 | 
			
		||||
            icon: SloppinessArchitectIcon,
 | 
			
		||||
            icon: <SloppinessArchitectIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 1,
 | 
			
		||||
            text: t("labels.artist"),
 | 
			
		||||
            icon: SloppinessArtistIcon,
 | 
			
		||||
            icon: <SloppinessArtistIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 2,
 | 
			
		||||
            text: t("labels.cartoonist"),
 | 
			
		||||
            icon: SloppinessCartoonistIcon,
 | 
			
		||||
            icon: <SloppinessCartoonistIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
@@ -438,7 +433,6 @@ export const actionChangeSloppiness = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeStyle = register({
 | 
			
		||||
  name: "changeStrokeStyle",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -459,17 +453,17 @@ export const actionChangeStrokeStyle = register({
 | 
			
		||||
          {
 | 
			
		||||
            value: "solid",
 | 
			
		||||
            text: t("labels.strokeStyle_solid"),
 | 
			
		||||
            icon: StrokeWidthBaseIcon,
 | 
			
		||||
            icon: <StrokeStyleSolidIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "dashed",
 | 
			
		||||
            text: t("labels.strokeStyle_dashed"),
 | 
			
		||||
            icon: StrokeStyleDashedIcon,
 | 
			
		||||
            icon: <StrokeStyleDashedIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "dotted",
 | 
			
		||||
            text: t("labels.strokeStyle_dotted"),
 | 
			
		||||
            icon: StrokeStyleDottedIcon,
 | 
			
		||||
            icon: <StrokeStyleDottedIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
@@ -486,17 +480,12 @@ export const actionChangeStrokeStyle = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeOpacity = register({
 | 
			
		||||
  name: "changeOpacity",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (el) =>
 | 
			
		||||
          newElementWith(el, {
 | 
			
		||||
            opacity: value,
 | 
			
		||||
          }),
 | 
			
		||||
        true,
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
          opacity: value,
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: { ...appState, currentItemOpacity: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
@@ -511,6 +500,20 @@ export const actionChangeOpacity = register({
 | 
			
		||||
        max="100"
 | 
			
		||||
        step="10"
 | 
			
		||||
        onChange={(event) => updateData(+event.target.value)}
 | 
			
		||||
        onWheel={(event) => {
 | 
			
		||||
          event.stopPropagation();
 | 
			
		||||
          const target = event.target as HTMLInputElement;
 | 
			
		||||
          const STEP = 10;
 | 
			
		||||
          const MAX = 100;
 | 
			
		||||
          const MIN = 0;
 | 
			
		||||
          const value = +target.value;
 | 
			
		||||
 | 
			
		||||
          if (event.deltaY < 0 && value < MAX) {
 | 
			
		||||
            updateData(value + STEP);
 | 
			
		||||
          } else if (event.deltaY > 0 && value > MIN) {
 | 
			
		||||
            updateData(value - STEP);
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        value={
 | 
			
		||||
          getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
@@ -526,7 +529,6 @@ export const actionChangeOpacity = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontSize = register({
 | 
			
		||||
  name: "changeFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, () => value, value);
 | 
			
		||||
  },
 | 
			
		||||
@@ -539,25 +541,25 @@ export const actionChangeFontSize = register({
 | 
			
		||||
          {
 | 
			
		||||
            value: 16,
 | 
			
		||||
            text: t("labels.small"),
 | 
			
		||||
            icon: FontSizeSmallIcon,
 | 
			
		||||
            icon: <FontSizeSmallIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-small",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 20,
 | 
			
		||||
            text: t("labels.medium"),
 | 
			
		||||
            icon: FontSizeMediumIcon,
 | 
			
		||||
            icon: <FontSizeMediumIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-medium",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 28,
 | 
			
		||||
            text: t("labels.large"),
 | 
			
		||||
            icon: FontSizeLargeIcon,
 | 
			
		||||
            icon: <FontSizeLargeIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-large",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 36,
 | 
			
		||||
            text: t("labels.veryLarge"),
 | 
			
		||||
            icon: FontSizeExtraLargeIcon,
 | 
			
		||||
            icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
 | 
			
		||||
            testId: "fontSize-veryLarge",
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
@@ -584,7 +586,6 @@ export const actionChangeFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionDecreaseFontSize = register({
 | 
			
		||||
  name: "decreaseFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(
 | 
			
		||||
@@ -606,7 +607,6 @@ export const actionDecreaseFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionIncreaseFontSize = register({
 | 
			
		||||
  name: "increaseFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
 | 
			
		||||
@@ -624,7 +624,6 @@ export const actionIncreaseFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontFamily = register({
 | 
			
		||||
  name: "changeFontFamily",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -638,7 +637,11 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
                fontFamily: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -662,17 +665,17 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Virgil,
 | 
			
		||||
        text: t("labels.handDrawn"),
 | 
			
		||||
        icon: FreedrawIcon,
 | 
			
		||||
        icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Helvetica,
 | 
			
		||||
        text: t("labels.normal"),
 | 
			
		||||
        icon: FontFamilyNormalIcon,
 | 
			
		||||
        icon: <FontFamilyNormalIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Cascadia,
 | 
			
		||||
        text: t("labels.code"),
 | 
			
		||||
        icon: FontFamilyCodeIcon,
 | 
			
		||||
        icon: <FontFamilyCodeIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
@@ -706,7 +709,6 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeTextAlign = register({
 | 
			
		||||
  name: "changeTextAlign",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -718,7 +720,11 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
              oldElement,
 | 
			
		||||
              { textAlign: value },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -743,17 +749,17 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
            {
 | 
			
		||||
              value: "left",
 | 
			
		||||
              text: t("labels.left"),
 | 
			
		||||
              icon: TextAlignLeftIcon,
 | 
			
		||||
              icon: <TextAlignLeftIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: "center",
 | 
			
		||||
              text: t("labels.center"),
 | 
			
		||||
              icon: TextAlignCenterIcon,
 | 
			
		||||
              icon: <TextAlignCenterIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: "right",
 | 
			
		||||
              text: t("labels.right"),
 | 
			
		||||
              icon: TextAlignRightIcon,
 | 
			
		||||
              icon: <TextAlignRightIcon theme={appState.theme} />,
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
@@ -779,7 +785,6 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
});
 | 
			
		||||
export const actionChangeVerticalAlign = register({
 | 
			
		||||
  name: "changeVerticalAlign",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -792,7 +797,11 @@ export const actionChangeVerticalAlign = register({
 | 
			
		||||
              { verticalAlign: value },
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -847,7 +856,6 @@ export const actionChangeVerticalAlign = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeSharpness = register({
 | 
			
		||||
  name: "changeSharpness",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    const targetElements = getTargetElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -855,10 +863,10 @@ export const actionChangeSharpness = register({
 | 
			
		||||
    );
 | 
			
		||||
    const shouldUpdateForNonLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every((el) => !isLinearElement(el))
 | 
			
		||||
      : !isLinearElementType(appState.activeTool.type);
 | 
			
		||||
      : !isLinearElementType(appState.elementType);
 | 
			
		||||
    const shouldUpdateForLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every(isLinearElement)
 | 
			
		||||
      : isLinearElementType(appState.activeTool.type);
 | 
			
		||||
      : isLinearElementType(appState.elementType);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -886,20 +894,20 @@ export const actionChangeSharpness = register({
 | 
			
		||||
          {
 | 
			
		||||
            value: "sharp",
 | 
			
		||||
            text: t("labels.sharp"),
 | 
			
		||||
            icon: EdgeSharpIcon,
 | 
			
		||||
            icon: <EdgeSharpIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "round",
 | 
			
		||||
            text: t("labels.round"),
 | 
			
		||||
            icon: EdgeRoundIcon,
 | 
			
		||||
            icon: <EdgeRoundIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => element.strokeSharpness,
 | 
			
		||||
          (canChangeSharpness(appState.activeTool.type) &&
 | 
			
		||||
            (isLinearElementType(appState.activeTool.type)
 | 
			
		||||
          (canChangeSharpness(appState.elementType) &&
 | 
			
		||||
            (isLinearElementType(appState.elementType)
 | 
			
		||||
              ? appState.currentItemLinearStrokeSharpness
 | 
			
		||||
              : appState.currentItemStrokeSharpness)) ||
 | 
			
		||||
            null,
 | 
			
		||||
@@ -912,7 +920,6 @@ export const actionChangeSharpness = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeArrowhead = register({
 | 
			
		||||
  name: "changeArrowhead",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (
 | 
			
		||||
    elements,
 | 
			
		||||
    appState,
 | 
			
		||||
@@ -953,38 +960,42 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.arrowheads")}</legend>
 | 
			
		||||
        <div className="iconSelectList buttonList">
 | 
			
		||||
        <div className="iconSelectList">
 | 
			
		||||
          <IconPicker
 | 
			
		||||
            label="arrowhead_start"
 | 
			
		||||
            options={[
 | 
			
		||||
              {
 | 
			
		||||
                value: null,
 | 
			
		||||
                text: t("labels.arrowhead_none"),
 | 
			
		||||
                icon: ArrowheadNoneIcon,
 | 
			
		||||
                icon: <ArrowheadNoneIcon theme={appState.theme} />,
 | 
			
		||||
                keyBinding: "q",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "arrow",
 | 
			
		||||
                text: t("labels.arrowhead_arrow"),
 | 
			
		||||
                icon: <ArrowheadArrowIcon flip={!isRTL} />,
 | 
			
		||||
                icon: (
 | 
			
		||||
                  <ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
 | 
			
		||||
                ),
 | 
			
		||||
                keyBinding: "w",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "bar",
 | 
			
		||||
                text: t("labels.arrowhead_bar"),
 | 
			
		||||
                icon: <ArrowheadBarIcon flip={!isRTL} />,
 | 
			
		||||
                icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
 | 
			
		||||
                keyBinding: "e",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "dot",
 | 
			
		||||
                text: t("labels.arrowhead_dot"),
 | 
			
		||||
                icon: <ArrowheadDotIcon flip={!isRTL} />,
 | 
			
		||||
                icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
 | 
			
		||||
                keyBinding: "r",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "triangle",
 | 
			
		||||
                text: t("labels.arrowhead_triangle"),
 | 
			
		||||
                icon: <ArrowheadTriangleIcon flip={!isRTL} />,
 | 
			
		||||
                icon: (
 | 
			
		||||
                  <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
 | 
			
		||||
                ),
 | 
			
		||||
                keyBinding: "t",
 | 
			
		||||
              },
 | 
			
		||||
            ]}
 | 
			
		||||
@@ -1007,30 +1018,34 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
                value: null,
 | 
			
		||||
                text: t("labels.arrowhead_none"),
 | 
			
		||||
                keyBinding: "q",
 | 
			
		||||
                icon: ArrowheadNoneIcon,
 | 
			
		||||
                icon: <ArrowheadNoneIcon theme={appState.theme} />,
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "arrow",
 | 
			
		||||
                text: t("labels.arrowhead_arrow"),
 | 
			
		||||
                keyBinding: "w",
 | 
			
		||||
                icon: <ArrowheadArrowIcon flip={isRTL} />,
 | 
			
		||||
                icon: (
 | 
			
		||||
                  <ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
 | 
			
		||||
                ),
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "bar",
 | 
			
		||||
                text: t("labels.arrowhead_bar"),
 | 
			
		||||
                keyBinding: "e",
 | 
			
		||||
                icon: <ArrowheadBarIcon flip={isRTL} />,
 | 
			
		||||
                icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "dot",
 | 
			
		||||
                text: t("labels.arrowhead_dot"),
 | 
			
		||||
                keyBinding: "r",
 | 
			
		||||
                icon: <ArrowheadDotIcon flip={isRTL} />,
 | 
			
		||||
                icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "triangle",
 | 
			
		||||
                text: t("labels.arrowhead_triangle"),
 | 
			
		||||
                icon: <ArrowheadTriangleIcon flip={isRTL} />,
 | 
			
		||||
                icon: (
 | 
			
		||||
                  <ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
 | 
			
		||||
                ),
 | 
			
		||||
                keyBinding: "t",
 | 
			
		||||
              },
 | 
			
		||||
            ]}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,43 +2,27 @@ import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { selectGroupsForSelectedElements } from "../groups";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
 | 
			
		||||
export const actionSelectAll = register({
 | 
			
		||||
  name: "selectAll",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState, value, app) => {
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    const selectedElementIds = elements.reduce(
 | 
			
		||||
      (map: Record<ExcalidrawElement["id"], true>, element) => {
 | 
			
		||||
        if (
 | 
			
		||||
          !element.isDeleted &&
 | 
			
		||||
          !(isTextElement(element) && element.containerId) &&
 | 
			
		||||
          !element.locked
 | 
			
		||||
        ) {
 | 
			
		||||
          map[element.id] = true;
 | 
			
		||||
        }
 | 
			
		||||
        return map;
 | 
			
		||||
      },
 | 
			
		||||
      {},
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: selectGroupsForSelectedElements(
 | 
			
		||||
        {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          selectedLinearElement:
 | 
			
		||||
            // single linear element selected
 | 
			
		||||
            Object.keys(selectedElementIds).length === 1 &&
 | 
			
		||||
            isLinearElement(elements[0])
 | 
			
		||||
              ? new LinearElementEditor(elements[0], app.scene)
 | 
			
		||||
              : null,
 | 
			
		||||
          editingGroupId: null,
 | 
			
		||||
          selectedElementIds,
 | 
			
		||||
          selectedElementIds: elements.reduce((map, element) => {
 | 
			
		||||
            if (
 | 
			
		||||
              !element.isDeleted &&
 | 
			
		||||
              !(isTextElement(element) && element.containerId)
 | 
			
		||||
            ) {
 | 
			
		||||
              map[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
            return map;
 | 
			
		||||
          }, {} as any),
 | 
			
		||||
        },
 | 
			
		||||
        getNonDeletedElements(elements),
 | 
			
		||||
      ),
 | 
			
		||||
 
 | 
			
		||||
@@ -48,7 +48,7 @@ describe("actionStyles", () => {
 | 
			
		||||
    Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
 | 
			
		||||
      Keyboard.codeDown(CODES.C);
 | 
			
		||||
    });
 | 
			
		||||
    const secondRect = JSON.parse(copiedStyles)[0];
 | 
			
		||||
    const secondRect = JSON.parse(copiedStyles);
 | 
			
		||||
    expect(secondRect.id).toBe(h.elements[1].id);
 | 
			
		||||
 | 
			
		||||
    mouse.reset();
 | 
			
		||||
 
 | 
			
		||||
@@ -6,37 +6,28 @@ import {
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { getBoundTextElement } from "../element/textElement";
 | 
			
		||||
import { hasBoundTextElement } from "../element/typeChecks";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getContainerElement } from "../element/textElement";
 | 
			
		||||
 | 
			
		||||
// `copiedStyles` is exported only for tests.
 | 
			
		||||
export let copiedStyles: string = "{}";
 | 
			
		||||
 | 
			
		||||
export const actionCopyStyles = register({
 | 
			
		||||
  name: "copyStyles",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const elementsCopied = [];
 | 
			
		||||
    const element = elements.find((el) => appState.selectedElementIds[el.id]);
 | 
			
		||||
    elementsCopied.push(element);
 | 
			
		||||
    if (element && hasBoundTextElement(element)) {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      elementsCopied.push(boundTextElement);
 | 
			
		||||
    }
 | 
			
		||||
    if (element) {
 | 
			
		||||
      copiedStyles = JSON.stringify(elementsCopied);
 | 
			
		||||
      copiedStyles = JSON.stringify(element);
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        toast: { message: t("toast.copyStyles") },
 | 
			
		||||
        toastMessage: t("toast.copyStyles"),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
@@ -48,64 +39,36 @@ export const actionCopyStyles = register({
 | 
			
		||||
 | 
			
		||||
export const actionPasteStyles = register({
 | 
			
		||||
  name: "pasteStyles",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const elementsCopied = JSON.parse(copiedStyles);
 | 
			
		||||
    const pastedElement = elementsCopied[0];
 | 
			
		||||
    const boundTextElement = elementsCopied[1];
 | 
			
		||||
    const pastedElement = JSON.parse(copiedStyles);
 | 
			
		||||
    if (!isExcalidrawElement(pastedElement)) {
 | 
			
		||||
      return { elements, commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
    const selectedElementIds = selectedElements.map((element) => element.id);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) => {
 | 
			
		||||
        if (selectedElementIds.includes(element.id)) {
 | 
			
		||||
          let elementStylesToCopyFrom = pastedElement;
 | 
			
		||||
          if (isTextElement(element) && element.containerId) {
 | 
			
		||||
            elementStylesToCopyFrom = boundTextElement;
 | 
			
		||||
          }
 | 
			
		||||
          if (!elementStylesToCopyFrom) {
 | 
			
		||||
            return element;
 | 
			
		||||
          }
 | 
			
		||||
          let newElement = newElementWith(element, {
 | 
			
		||||
            backgroundColor: elementStylesToCopyFrom?.backgroundColor,
 | 
			
		||||
            strokeWidth: elementStylesToCopyFrom?.strokeWidth,
 | 
			
		||||
            strokeColor: elementStylesToCopyFrom?.strokeColor,
 | 
			
		||||
            strokeStyle: elementStylesToCopyFrom?.strokeStyle,
 | 
			
		||||
            fillStyle: elementStylesToCopyFrom?.fillStyle,
 | 
			
		||||
            opacity: elementStylesToCopyFrom?.opacity,
 | 
			
		||||
            roughness: elementStylesToCopyFrom?.roughness,
 | 
			
		||||
        if (appState.selectedElementIds[element.id]) {
 | 
			
		||||
          const newElement = newElementWith(element, {
 | 
			
		||||
            backgroundColor: pastedElement?.backgroundColor,
 | 
			
		||||
            strokeWidth: pastedElement?.strokeWidth,
 | 
			
		||||
            strokeColor: pastedElement?.strokeColor,
 | 
			
		||||
            strokeStyle: pastedElement?.strokeStyle,
 | 
			
		||||
            fillStyle: pastedElement?.fillStyle,
 | 
			
		||||
            opacity: pastedElement?.opacity,
 | 
			
		||||
            roughness: pastedElement?.roughness,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          if (isTextElement(newElement)) {
 | 
			
		||||
            newElement = newElementWith(newElement, {
 | 
			
		||||
              fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
              fontFamily:
 | 
			
		||||
                elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
              textAlign:
 | 
			
		||||
                elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
			
		||||
          if (isTextElement(newElement) && isTextElement(element)) {
 | 
			
		||||
            mutateElement(newElement, {
 | 
			
		||||
              fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
              fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
			
		||||
            });
 | 
			
		||||
            let container = null;
 | 
			
		||||
            if (newElement.containerId) {
 | 
			
		||||
              container =
 | 
			
		||||
                selectedElements.find(
 | 
			
		||||
                  (element) =>
 | 
			
		||||
                    isTextElement(newElement) &&
 | 
			
		||||
                    element.id === newElement.containerId,
 | 
			
		||||
                ) || null;
 | 
			
		||||
            }
 | 
			
		||||
            redrawTextBoundingBox(newElement, container);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (newElement.type === "arrow") {
 | 
			
		||||
            newElement = newElementWith(newElement, {
 | 
			
		||||
              startArrowhead: elementStylesToCopyFrom.startArrowhead,
 | 
			
		||||
              endArrowhead: elementStylesToCopyFrom.endArrowhead,
 | 
			
		||||
            });
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(newElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return newElement;
 | 
			
		||||
        }
 | 
			
		||||
        return element;
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleGridMode = register({
 | 
			
		||||
  name: "gridMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.gridSize,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "grid");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,66 +0,0 @@
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionToggleLock = register({
 | 
			
		||||
  name: "toggleLock",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
 | 
			
		||||
    if (!selectedElements.length) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const operation = getOperation(selectedElements);
 | 
			
		||||
    const selectedElementsMap = arrayToMap(selectedElements);
 | 
			
		||||
    const lock = operation === "lock";
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) => {
 | 
			
		||||
        if (!selectedElementsMap.has(element.id)) {
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return newElementWith(element, { locked: lock });
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedLinearElement: lock ? null : appState.selectedLinearElement,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: (elements, appState) => {
 | 
			
		||||
    const selected = getSelectedElements(elements, appState, false);
 | 
			
		||||
    if (selected.length === 1) {
 | 
			
		||||
      return selected[0].locked
 | 
			
		||||
        ? "labels.elementLock.unlock"
 | 
			
		||||
        : "labels.elementLock.lock";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (selected.length > 1) {
 | 
			
		||||
      return getOperation(selected) === "lock"
 | 
			
		||||
        ? "labels.elementLock.lockAll"
 | 
			
		||||
        : "labels.elementLock.unlockAll";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      "Unexpected zero elements to lock/unlock. This should never happen.",
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState, elements) => {
 | 
			
		||||
    return (
 | 
			
		||||
      event.key.toLocaleLowerCase() === KEYS.L &&
 | 
			
		||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      getSelectedElements(elements, appState, false).length > 0
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const getOperation = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
 | 
			
		||||
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,11 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleViewMode = register({
 | 
			
		||||
  name: "viewMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.viewModeEnabled,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "view");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,12 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleZenMode = register({
 | 
			
		||||
  name: "zenMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.zenModeEnabled,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "zen");
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,44 @@
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { getBoundTextElement, measureText } from "../element/textElement";
 | 
			
		||||
import { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -10,15 +10,14 @@ import { t } from "../i18n";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import {
 | 
			
		||||
  BringForwardIcon,
 | 
			
		||||
  BringToFrontIcon,
 | 
			
		||||
  SendBackwardIcon,
 | 
			
		||||
  BringToFrontIcon,
 | 
			
		||||
  SendToBackIcon,
 | 
			
		||||
  BringForwardIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
 | 
			
		||||
export const actionSendBackward = register({
 | 
			
		||||
  name: "sendBackward",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveOneLeft(elements, appState),
 | 
			
		||||
@@ -39,14 +38,13 @@ export const actionSendBackward = register({
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
 | 
			
		||||
    >
 | 
			
		||||
      {SendBackwardIcon}
 | 
			
		||||
      <SendBackwardIcon theme={appState.theme} />
 | 
			
		||||
    </button>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionBringForward = register({
 | 
			
		||||
  name: "bringForward",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveOneRight(elements, appState),
 | 
			
		||||
@@ -67,14 +65,13 @@ export const actionBringForward = register({
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
 | 
			
		||||
    >
 | 
			
		||||
      {BringForwardIcon}
 | 
			
		||||
      <BringForwardIcon theme={appState.theme} />
 | 
			
		||||
    </button>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionSendToBack = register({
 | 
			
		||||
  name: "sendToBack",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveAllLeft(elements, appState),
 | 
			
		||||
@@ -102,15 +99,13 @@ export const actionSendToBack = register({
 | 
			
		||||
          : getShortcutKey("CtrlOrCmd+Shift+[")
 | 
			
		||||
      }`}
 | 
			
		||||
    >
 | 
			
		||||
      {SendToBackIcon}
 | 
			
		||||
      <SendToBackIcon theme={appState.theme} />
 | 
			
		||||
    </button>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionBringToFront = register({
 | 
			
		||||
  name: "bringToFront",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveAllRight(elements, appState),
 | 
			
		||||
@@ -138,7 +133,7 @@ export const actionBringToFront = register({
 | 
			
		||||
          : getShortcutKey("CtrlOrCmd+Shift+]")
 | 
			
		||||
      }`}
 | 
			
		||||
    >
 | 
			
		||||
      {BringToFrontIcon}
 | 
			
		||||
      <BringToFrontIcon theme={appState.theme} />
 | 
			
		||||
    </button>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -75,14 +75,11 @@ export {
 | 
			
		||||
  actionCut,
 | 
			
		||||
  actionCopyAsPng,
 | 
			
		||||
  actionCopyAsSvg,
 | 
			
		||||
  copyText,
 | 
			
		||||
} from "./actionClipboard";
 | 
			
		||||
 | 
			
		||||
export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
			
		||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
			
		||||
 | 
			
		||||
export { actionToggleStats } from "./actionToggleStats";
 | 
			
		||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
 | 
			
		||||
export { actionUnbindText } from "./actionUnbindText";
 | 
			
		||||
export { actionLink } from "../element/Hyperlink";
 | 
			
		||||
export { actionToggleLock } from "./actionToggleLock";
 | 
			
		||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  Action,
 | 
			
		||||
  ActionsManagerInterface,
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
  ActionResult,
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
  ActionSource,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
@@ -14,25 +14,21 @@ import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
const trackAction = (
 | 
			
		||||
  action: Action,
 | 
			
		||||
  source: ActionSource,
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
  source: "ui" | "keyboard" | "api",
 | 
			
		||||
  value: any,
 | 
			
		||||
) => {
 | 
			
		||||
  if (action.trackEvent) {
 | 
			
		||||
  if (action.trackEvent !== false) {
 | 
			
		||||
    try {
 | 
			
		||||
      if (typeof action.trackEvent === "object") {
 | 
			
		||||
        const shouldTrack = action.trackEvent.predicate
 | 
			
		||||
          ? action.trackEvent.predicate(appState, elements, value)
 | 
			
		||||
          : true;
 | 
			
		||||
        if (shouldTrack) {
 | 
			
		||||
          trackEvent(
 | 
			
		||||
            action.trackEvent.category,
 | 
			
		||||
            action.trackEvent.action || action.name,
 | 
			
		||||
            `${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      if (action.trackEvent === true) {
 | 
			
		||||
        trackEvent(
 | 
			
		||||
          action.name,
 | 
			
		||||
          source,
 | 
			
		||||
          typeof value === "number" || typeof value === "string"
 | 
			
		||||
            ? String(value)
 | 
			
		||||
            : undefined,
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        action.trackEvent?.(action, source, value);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("error while logging action:", error);
 | 
			
		||||
@@ -40,8 +36,8 @@ const trackAction = (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class ActionManager {
 | 
			
		||||
  actions = {} as Record<ActionName, Action>;
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
 | 
			
		||||
  updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 | 
			
		||||
 | 
			
		||||
@@ -110,36 +106,36 @@ export class ActionManager {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
    const appState = this.getAppState();
 | 
			
		||||
    const value = null;
 | 
			
		||||
 | 
			
		||||
    trackAction(action, "keyboard", appState, elements, this.app, null);
 | 
			
		||||
    trackAction(action, "keyboard", null);
 | 
			
		||||
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
    this.updater(data[0].perform(elements, appState, value, this.app));
 | 
			
		||||
    this.updater(
 | 
			
		||||
      data[0].perform(
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  executeAction(action: Action, source: ActionSource = "api") {
 | 
			
		||||
    const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
    const appState = this.getAppState();
 | 
			
		||||
    const value = null;
 | 
			
		||||
 | 
			
		||||
    trackAction(action, source, appState, elements, this.app, value);
 | 
			
		||||
 | 
			
		||||
    this.updater(action.perform(elements, appState, value, this.app));
 | 
			
		||||
  executeAction(action: Action) {
 | 
			
		||||
    this.updater(
 | 
			
		||||
      action.perform(
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    trackAction(action, "api", null);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @param data additional data sent to the PanelComponent
 | 
			
		||||
   */
 | 
			
		||||
  renderAction = (
 | 
			
		||||
    name: ActionName,
 | 
			
		||||
    data?: PanelComponentProps["data"],
 | 
			
		||||
    isInHamburgerMenu = false,
 | 
			
		||||
  ) => {
 | 
			
		||||
  renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
 | 
			
		||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
@@ -151,12 +147,7 @@ export class ActionManager {
 | 
			
		||||
    ) {
 | 
			
		||||
      const action = this.actions[name];
 | 
			
		||||
      const PanelComponent = action.PanelComponent!;
 | 
			
		||||
      PanelComponent.displayName = "PanelComponent";
 | 
			
		||||
      const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
      const appState = this.getAppState();
 | 
			
		||||
      const updateData = (formState?: any) => {
 | 
			
		||||
        trackAction(action, "ui", appState, elements, this.app, formState);
 | 
			
		||||
 | 
			
		||||
        this.updater(
 | 
			
		||||
          action.perform(
 | 
			
		||||
            this.getElementsIncludingDeleted(),
 | 
			
		||||
@@ -165,6 +156,8 @@ export class ActionManager {
 | 
			
		||||
            this.app,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        trackAction(action, "ui", formState);
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
@@ -174,7 +167,6 @@ export class ActionManager {
 | 
			
		||||
          updateData={updateData}
 | 
			
		||||
          appProps={this.app.props}
 | 
			
		||||
          data={data}
 | 
			
		||||
          isInHamburgerMenu={isInHamburgerMenu}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -3,45 +3,35 @@ import { isDarwin } from "../keys";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { ActionName } from "./types";
 | 
			
		||||
 | 
			
		||||
export type ShortcutName =
 | 
			
		||||
  | SubtypeOf<
 | 
			
		||||
      ActionName,
 | 
			
		||||
      | "toggleTheme"
 | 
			
		||||
      | "loadScene"
 | 
			
		||||
      | "cut"
 | 
			
		||||
      | "copy"
 | 
			
		||||
      | "paste"
 | 
			
		||||
      | "copyStyles"
 | 
			
		||||
      | "pasteStyles"
 | 
			
		||||
      | "selectAll"
 | 
			
		||||
      | "deleteSelectedElements"
 | 
			
		||||
      | "duplicateSelection"
 | 
			
		||||
      | "sendBackward"
 | 
			
		||||
      | "bringForward"
 | 
			
		||||
      | "sendToBack"
 | 
			
		||||
      | "bringToFront"
 | 
			
		||||
      | "copyAsPng"
 | 
			
		||||
      | "copyAsSvg"
 | 
			
		||||
      | "group"
 | 
			
		||||
      | "ungroup"
 | 
			
		||||
      | "gridMode"
 | 
			
		||||
      | "zenMode"
 | 
			
		||||
      | "stats"
 | 
			
		||||
      | "addToLibrary"
 | 
			
		||||
      | "viewMode"
 | 
			
		||||
      | "flipHorizontal"
 | 
			
		||||
      | "flipVertical"
 | 
			
		||||
      | "hyperlink"
 | 
			
		||||
      | "toggleLock"
 | 
			
		||||
    >
 | 
			
		||||
  | "saveScene"
 | 
			
		||||
  | "imageExport";
 | 
			
		||||
export type ShortcutName = SubtypeOf<
 | 
			
		||||
  ActionName,
 | 
			
		||||
  | "cut"
 | 
			
		||||
  | "copy"
 | 
			
		||||
  | "paste"
 | 
			
		||||
  | "copyStyles"
 | 
			
		||||
  | "pasteStyles"
 | 
			
		||||
  | "selectAll"
 | 
			
		||||
  | "deleteSelectedElements"
 | 
			
		||||
  | "duplicateSelection"
 | 
			
		||||
  | "sendBackward"
 | 
			
		||||
  | "bringForward"
 | 
			
		||||
  | "sendToBack"
 | 
			
		||||
  | "bringToFront"
 | 
			
		||||
  | "copyAsPng"
 | 
			
		||||
  | "copyAsSvg"
 | 
			
		||||
  | "group"
 | 
			
		||||
  | "ungroup"
 | 
			
		||||
  | "gridMode"
 | 
			
		||||
  | "zenMode"
 | 
			
		||||
  | "stats"
 | 
			
		||||
  | "addToLibrary"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "hyperlink"
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
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")],
 | 
			
		||||
  copy: [getShortcutKey("CtrlOrCmd+C")],
 | 
			
		||||
  paste: [getShortcutKey("CtrlOrCmd+V")],
 | 
			
		||||
@@ -77,7 +67,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
			
		||||
  viewMode: [getShortcutKey("Alt+R")],
 | 
			
		||||
  hyperlink: [getShortcutKey("CtrlOrCmd+K")],
 | 
			
		||||
  toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -6,8 +6,7 @@ import {
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
} from "../types";
 | 
			
		||||
 | 
			
		||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
 | 
			
		||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
export type ActionResult =
 | 
			
		||||
@@ -40,7 +39,6 @@ export type ActionName =
 | 
			
		||||
  | "paste"
 | 
			
		||||
  | "copyAsPng"
 | 
			
		||||
  | "copyAsSvg"
 | 
			
		||||
  | "copyText"
 | 
			
		||||
  | "sendBackward"
 | 
			
		||||
  | "bringForward"
 | 
			
		||||
  | "sendToBack"
 | 
			
		||||
@@ -108,25 +106,19 @@ export type ActionName =
 | 
			
		||||
  | "increaseFontSize"
 | 
			
		||||
  | "decreaseFontSize"
 | 
			
		||||
  | "unbindText"
 | 
			
		||||
  | "hyperlink"
 | 
			
		||||
  | "eraser"
 | 
			
		||||
  | "bindText"
 | 
			
		||||
  | "toggleLock"
 | 
			
		||||
  | "toggleLinearEditor";
 | 
			
		||||
  | "hyperlink";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  updateData: (formData?: any) => void;
 | 
			
		||||
  appProps: ExcalidrawProps;
 | 
			
		||||
  data?: Record<string, any>;
 | 
			
		||||
  data?: Partial<{ id: string; size: ToolButtonSize }>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface Action {
 | 
			
		||||
  name: ActionName;
 | 
			
		||||
  PanelComponent?: React.FC<
 | 
			
		||||
    PanelComponentProps & { isInHamburgerMenu: boolean }
 | 
			
		||||
  >;
 | 
			
		||||
  PanelComponent?: React.FC<PanelComponentProps>;
 | 
			
		||||
  perform: ActionFn;
 | 
			
		||||
  keyPriority?: number;
 | 
			
		||||
  keyTest?: (
 | 
			
		||||
@@ -145,23 +137,15 @@ export interface Action {
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  checked?: (appState: Readonly<AppState>) => boolean;
 | 
			
		||||
  trackEvent:
 | 
			
		||||
    | false
 | 
			
		||||
    | {
 | 
			
		||||
        category:
 | 
			
		||||
          | "toolbar"
 | 
			
		||||
          | "element"
 | 
			
		||||
          | "canvas"
 | 
			
		||||
          | "export"
 | 
			
		||||
          | "history"
 | 
			
		||||
          | "menu"
 | 
			
		||||
          | "collab"
 | 
			
		||||
          | "hyperlink";
 | 
			
		||||
        action?: string;
 | 
			
		||||
        predicate?: (
 | 
			
		||||
          appState: Readonly<AppState>,
 | 
			
		||||
          elements: readonly ExcalidrawElement[],
 | 
			
		||||
          value: any,
 | 
			
		||||
        ) => boolean;
 | 
			
		||||
      };
 | 
			
		||||
  trackEvent?:
 | 
			
		||||
    | boolean
 | 
			
		||||
    | ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ActionsManagerInterface {
 | 
			
		||||
  actions: Record<ActionName, Action>;
 | 
			
		||||
  registerAction: (action: Action) => void;
 | 
			
		||||
  handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
 | 
			
		||||
  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
			
		||||
  executeAction: (action: Action) => void;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,19 +4,15 @@ export const trackEvent =
 | 
			
		||||
  typeof window !== "undefined" &&
 | 
			
		||||
  window.gtag
 | 
			
		||||
    ? (category: string, action: string, label?: string, value?: number) => {
 | 
			
		||||
        try {
 | 
			
		||||
          window.gtag("event", action, {
 | 
			
		||||
            event_category: category,
 | 
			
		||||
            event_label: label,
 | 
			
		||||
            value,
 | 
			
		||||
          });
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error("error logging to ga", error);
 | 
			
		||||
        }
 | 
			
		||||
        window.gtag("event", action, {
 | 
			
		||||
          event_category: category,
 | 
			
		||||
          event_label: label,
 | 
			
		||||
          value,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
 | 
			
		||||
    ? (category: string, action: string, label?: string, value?: number) => {}
 | 
			
		||||
    : (category: string, action: string, label?: string, value?: number) => {
 | 
			
		||||
        // Uncomment the next line to track locally
 | 
			
		||||
        // console.log("Track Event", { category, action, label, value });
 | 
			
		||||
        // console.info("Track Event", category, action, label, value);
 | 
			
		||||
      };
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,6 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
  "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
> => {
 | 
			
		||||
  return {
 | 
			
		||||
    showWelcomeScreen: false,
 | 
			
		||||
    theme: THEME.LIGHT,
 | 
			
		||||
    collaborators: new Map(),
 | 
			
		||||
    currentChartType: "bar",
 | 
			
		||||
@@ -42,12 +41,8 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    editingElement: null,
 | 
			
		||||
    editingGroupId: null,
 | 
			
		||||
    editingLinearElement: null,
 | 
			
		||||
    activeTool: {
 | 
			
		||||
      type: "selection",
 | 
			
		||||
      customType: null,
 | 
			
		||||
      locked: false,
 | 
			
		||||
      lastActiveToolBeforeEraser: null,
 | 
			
		||||
    },
 | 
			
		||||
    elementLocked: false,
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    penMode: false,
 | 
			
		||||
    penDetected: false,
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
@@ -58,7 +53,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    fileHandle: null,
 | 
			
		||||
    gridSize: null,
 | 
			
		||||
    isBindingEnabled: true,
 | 
			
		||||
    isSidebarDocked: false,
 | 
			
		||||
    isLibraryOpen: false,
 | 
			
		||||
    isLoading: false,
 | 
			
		||||
    isResizing: false,
 | 
			
		||||
    isRotating: false,
 | 
			
		||||
@@ -67,8 +62,6 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
			
		||||
    openMenu: null,
 | 
			
		||||
    openPopup: null,
 | 
			
		||||
    openSidebar: null,
 | 
			
		||||
    openDialog: null,
 | 
			
		||||
    pasteDialog: { shown: false, data: null },
 | 
			
		||||
    previousSelectedElementIds: {},
 | 
			
		||||
    resizingElement: null,
 | 
			
		||||
@@ -79,19 +72,19 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    selectedGroupIds: {},
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showHelpDialog: false,
 | 
			
		||||
    showStats: false,
 | 
			
		||||
    startBoundElement: null,
 | 
			
		||||
    suggestedBindings: [],
 | 
			
		||||
    toast: null,
 | 
			
		||||
    toastMessage: null,
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: {
 | 
			
		||||
      value: 1 as NormalizedZoomValue,
 | 
			
		||||
    },
 | 
			
		||||
    viewModeEnabled: false,
 | 
			
		||||
    pendingImageElementId: null,
 | 
			
		||||
    pendingImageElement: null,
 | 
			
		||||
    showHyperlinkPopup: false,
 | 
			
		||||
    selectedLinearElement: null,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -111,7 +104,6 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  T extends Record<keyof AppState, Values>,
 | 
			
		||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
 | 
			
		||||
  config)({
 | 
			
		||||
  showWelcomeScreen: { browser: true, export: false, server: false },
 | 
			
		||||
  theme: { browser: true, export: false, server: false },
 | 
			
		||||
  collaborators: { browser: false, export: false, server: false },
 | 
			
		||||
  currentChartType: { browser: true, export: false, server: false },
 | 
			
		||||
@@ -138,9 +130,10 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  editingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false, server: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false, server: false },
 | 
			
		||||
  activeTool: { browser: true, export: false, server: false },
 | 
			
		||||
  penMode: { browser: true, export: false, server: false },
 | 
			
		||||
  penDetected: { browser: true, export: false, server: false },
 | 
			
		||||
  elementLocked: { browser: true, export: false, server: false },
 | 
			
		||||
  elementType: { browser: true, export: false, server: false },
 | 
			
		||||
  penMode: { browser: false, export: false, server: false },
 | 
			
		||||
  penDetected: { browser: false, export: false, server: false },
 | 
			
		||||
  errorMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false, server: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false, server: false },
 | 
			
		||||
@@ -150,7 +143,7 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  gridSize: { browser: true, export: true, server: true },
 | 
			
		||||
  height: { browser: false, export: false, server: false },
 | 
			
		||||
  isBindingEnabled: { browser: false, export: false, server: false },
 | 
			
		||||
  isSidebarDocked: { browser: true, export: false, server: false },
 | 
			
		||||
  isLibraryOpen: { browser: false, export: false, server: false },
 | 
			
		||||
  isLoading: { browser: false, export: false, server: false },
 | 
			
		||||
  isResizing: { browser: false, export: false, server: false },
 | 
			
		||||
  isRotating: { browser: false, export: false, server: false },
 | 
			
		||||
@@ -161,8 +154,6 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  offsetTop: { browser: false, export: false, server: false },
 | 
			
		||||
  openMenu: { browser: true, export: false, server: false },
 | 
			
		||||
  openPopup: { browser: false, export: false, server: false },
 | 
			
		||||
  openSidebar: { browser: true, export: false, server: false },
 | 
			
		||||
  openDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false, server: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false, server: false },
 | 
			
		||||
@@ -173,18 +164,18 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  selectedGroupIds: { browser: true, export: false, server: false },
 | 
			
		||||
  selectionElement: { browser: false, export: false, server: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
 | 
			
		||||
  showHelpDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  showStats: { browser: true, export: false, server: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false, server: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false, server: false },
 | 
			
		||||
  toast: { browser: false, export: false, server: false },
 | 
			
		||||
  toastMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  viewBackgroundColor: { browser: true, export: true, server: true },
 | 
			
		||||
  width: { browser: false, export: false, server: false },
 | 
			
		||||
  zenModeEnabled: { browser: true, export: false, server: false },
 | 
			
		||||
  zoom: { browser: true, export: false, server: false },
 | 
			
		||||
  viewModeEnabled: { browser: false, export: false, server: false },
 | 
			
		||||
  pendingImageElementId: { browser: false, export: false, server: false },
 | 
			
		||||
  pendingImageElement: { browser: false, export: false, server: false },
 | 
			
		||||
  showHyperlinkPopup: { browser: false, export: false, server: false },
 | 
			
		||||
  selectedLinearElement: { browser: true, export: false, server: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const _clearAppStateForStorage = <
 | 
			
		||||
@@ -222,9 +213,3 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
 | 
			
		||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
 | 
			
		||||
  return _clearAppStateForStorage(appState, "server");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isEraserActive = ({
 | 
			
		||||
  activeTool,
 | 
			
		||||
}: {
 | 
			
		||||
  activeTool: AppState["activeTool"];
 | 
			
		||||
}) => activeTool.type === "eraser";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,121 +0,0 @@
 | 
			
		||||
import {
 | 
			
		||||
  Spreadsheet,
 | 
			
		||||
  tryParseCells,
 | 
			
		||||
  tryParseNumber,
 | 
			
		||||
  VALID_SPREADSHEET,
 | 
			
		||||
} from "./charts";
 | 
			
		||||
 | 
			
		||||
describe("charts", () => {
 | 
			
		||||
  describe("tryParseNumber", () => {
 | 
			
		||||
    it.each<[string, number]>([
 | 
			
		||||
      ["1", 1],
 | 
			
		||||
      ["0", 0],
 | 
			
		||||
      ["-1", -1],
 | 
			
		||||
      ["0.1", 0.1],
 | 
			
		||||
      [".1", 0.1],
 | 
			
		||||
      ["1.", 1],
 | 
			
		||||
      ["424.", 424],
 | 
			
		||||
      ["$1", 1],
 | 
			
		||||
      ["-.1", -0.1],
 | 
			
		||||
      ["-$1", -1],
 | 
			
		||||
      ["$-1", -1],
 | 
			
		||||
    ])("should correctly identify %s as numbers", (given, expected) => {
 | 
			
		||||
      expect(tryParseNumber(given)).toEqual(expected);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
 | 
			
		||||
      "should correctly identify %s as not a number",
 | 
			
		||||
      (given) => {
 | 
			
		||||
        expect(tryParseNumber(given)).toBeNull();
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe("tryParseCells", () => {
 | 
			
		||||
    it("Successfully parses a spreadsheet", () => {
 | 
			
		||||
      const spreadsheet = [
 | 
			
		||||
        ["time", "value"],
 | 
			
		||||
        ["01:00", "61"],
 | 
			
		||||
        ["02:00", "-60"],
 | 
			
		||||
        ["03:00", "85"],
 | 
			
		||||
        ["04:00", "-67"],
 | 
			
		||||
        ["05:00", "54"],
 | 
			
		||||
        ["06:00", "95"],
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      const result = tryParseCells(spreadsheet);
 | 
			
		||||
 | 
			
		||||
      expect(result.type).toBe(VALID_SPREADSHEET);
 | 
			
		||||
 | 
			
		||||
      const { title, labels, values } = (
 | 
			
		||||
        result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
 | 
			
		||||
      ).spreadsheet;
 | 
			
		||||
 | 
			
		||||
      expect(title).toEqual("value");
 | 
			
		||||
      expect(labels).toEqual([
 | 
			
		||||
        "01:00",
 | 
			
		||||
        "02:00",
 | 
			
		||||
        "03:00",
 | 
			
		||||
        "04:00",
 | 
			
		||||
        "05:00",
 | 
			
		||||
        "06:00",
 | 
			
		||||
      ]);
 | 
			
		||||
      expect(values).toEqual([61, -60, 85, -67, 54, 95]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("Uses the second column as the label if it is not a number", () => {
 | 
			
		||||
      const spreadsheet = [
 | 
			
		||||
        ["time", "value"],
 | 
			
		||||
        ["01:00", "61"],
 | 
			
		||||
        ["02:00", "-60"],
 | 
			
		||||
        ["03:00", "85"],
 | 
			
		||||
        ["04:00", "-67"],
 | 
			
		||||
        ["05:00", "54"],
 | 
			
		||||
        ["06:00", "95"],
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      const result = tryParseCells(spreadsheet);
 | 
			
		||||
 | 
			
		||||
      expect(result.type).toBe(VALID_SPREADSHEET);
 | 
			
		||||
 | 
			
		||||
      const { title, labels, values } = (
 | 
			
		||||
        result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
 | 
			
		||||
      ).spreadsheet;
 | 
			
		||||
 | 
			
		||||
      expect(title).toEqual("value");
 | 
			
		||||
      expect(labels).toEqual([
 | 
			
		||||
        "01:00",
 | 
			
		||||
        "02:00",
 | 
			
		||||
        "03:00",
 | 
			
		||||
        "04:00",
 | 
			
		||||
        "05:00",
 | 
			
		||||
        "06:00",
 | 
			
		||||
      ]);
 | 
			
		||||
      expect(values).toEqual([61, -60, 85, -67, 54, 95]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("treats the first column as labels if both columns are numbers", () => {
 | 
			
		||||
      const spreadsheet = [
 | 
			
		||||
        ["time", "value"],
 | 
			
		||||
        ["01", "61"],
 | 
			
		||||
        ["02", "-60"],
 | 
			
		||||
        ["03", "85"],
 | 
			
		||||
        ["04", "-67"],
 | 
			
		||||
        ["05", "54"],
 | 
			
		||||
        ["06", "95"],
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      const result = tryParseCells(spreadsheet);
 | 
			
		||||
 | 
			
		||||
      expect(result.type).toBe(VALID_SPREADSHEET);
 | 
			
		||||
 | 
			
		||||
      const { title, labels, values } = (
 | 
			
		||||
        result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
 | 
			
		||||
      ).spreadsheet;
 | 
			
		||||
 | 
			
		||||
      expect(title).toEqual("value");
 | 
			
		||||
      expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
 | 
			
		||||
      expect(values).toEqual([61, -60, 85, -67, 54, 95]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -29,24 +29,18 @@ type ParseSpreadsheetResult =
 | 
			
		||||
  | { type: typeof NOT_SPREADSHEET; reason: string }
 | 
			
		||||
  | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @private exported for testing
 | 
			
		||||
 */
 | 
			
		||||
export const tryParseNumber = (s: string): number | null => {
 | 
			
		||||
  const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
 | 
			
		||||
const tryParseNumber = (s: string): number | null => {
 | 
			
		||||
  const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
 | 
			
		||||
  if (!match) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
 | 
			
		||||
  return parseFloat(match[1].replace(/,/g, ""));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
 | 
			
		||||
  lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @private exported for testing
 | 
			
		||||
 */
 | 
			
		||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 | 
			
		||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 | 
			
		||||
  const numCols = cells[0].length;
 | 
			
		||||
 | 
			
		||||
  if (numCols > 2) {
 | 
			
		||||
@@ -77,16 +71,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const labelColumnNumeric = isNumericColumn(cells, 0);
 | 
			
		||||
  const valueColumnNumeric = isNumericColumn(cells, 1);
 | 
			
		||||
  const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
 | 
			
		||||
 | 
			
		||||
  if (!labelColumnNumeric && !valueColumnNumeric) {
 | 
			
		||||
  if (!isNumericColumn(cells, valueColumnIndex)) {
 | 
			
		||||
    return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
 | 
			
		||||
    ? [0, 1]
 | 
			
		||||
    : [1, 0];
 | 
			
		||||
  const labelColumnIndex = (valueColumnIndex + 1) % 2;
 | 
			
		||||
  const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
 | 
			
		||||
  const rows = hasHeader ? cells.slice(1) : cells;
 | 
			
		||||
 | 
			
		||||
@@ -176,7 +167,6 @@ const commonProps = {
 | 
			
		||||
  strokeStyle: "solid",
 | 
			
		||||
  strokeWidth: 1,
 | 
			
		||||
  verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
  locked: false,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -11,18 +11,27 @@ export const getClientColors = (clientId: string, appState: AppState) => {
 | 
			
		||||
  // Naive way of getting an integer out of the clientId
 | 
			
		||||
  const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
 | 
			
		||||
 | 
			
		||||
  // Skip transparent & gray colors
 | 
			
		||||
  const backgrounds = colors.elementBackground.slice(3);
 | 
			
		||||
  const strokes = colors.elementStroke.slice(3);
 | 
			
		||||
  // Skip transparent background.
 | 
			
		||||
  const backgrounds = colors.elementBackground.slice(1);
 | 
			
		||||
  const strokes = colors.elementStroke.slice(1);
 | 
			
		||||
  return {
 | 
			
		||||
    background: backgrounds[sum % backgrounds.length],
 | 
			
		||||
    stroke: strokes[sum % strokes.length],
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getClientInitials = (userName?: string | null) => {
 | 
			
		||||
  if (!userName) {
 | 
			
		||||
export const getClientInitials = (username?: string | null) => {
 | 
			
		||||
  if (!username) {
 | 
			
		||||
    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();
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -2,16 +2,16 @@ import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "./element/types";
 | 
			
		||||
import { getSelectedElements } from "./scene";
 | 
			
		||||
import { AppState, BinaryFiles } from "./types";
 | 
			
		||||
import { SVG_EXPORT_TAG } from "./scene/export";
 | 
			
		||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 | 
			
		||||
import { isInitializedImageElement } from "./element/typeChecks";
 | 
			
		||||
import { isPromiseLike } from "./utils";
 | 
			
		||||
 | 
			
		||||
type ElementsClipboard = {
 | 
			
		||||
  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  elements: ExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -56,20 +56,19 @@ const clipboardContainsElements = (
 | 
			
		||||
export const copyToClipboard = async (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  files: BinaryFiles | null,
 | 
			
		||||
  files: BinaryFiles,
 | 
			
		||||
) => {
 | 
			
		||||
  // select binded text elements when copying
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
  const contents: ElementsClipboard = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
			
		||||
    elements,
 | 
			
		||||
    files: files
 | 
			
		||||
      ? elements.reduce((acc, element) => {
 | 
			
		||||
          if (isInitializedImageElement(element) && files[element.fileId]) {
 | 
			
		||||
            acc[element.fileId] = files[element.fileId];
 | 
			
		||||
          }
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {} as BinaryFiles)
 | 
			
		||||
      : undefined,
 | 
			
		||||
    elements: selectedElements,
 | 
			
		||||
    files: selectedElements.reduce((acc, element) => {
 | 
			
		||||
      if (isInitializedImageElement(element) && files[element.fileId]) {
 | 
			
		||||
        acc[element.fileId] = files[element.fileId];
 | 
			
		||||
      }
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {} as BinaryFiles),
 | 
			
		||||
  };
 | 
			
		||||
  const json = JSON.stringify(contents);
 | 
			
		||||
  CLIPBOARD = json;
 | 
			
		||||
@@ -167,35 +166,10 @@ export const parseClipboard = async (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
 | 
			
		||||
  let promise;
 | 
			
		||||
  try {
 | 
			
		||||
    // in Safari so far we need to construct the ClipboardItem synchronously
 | 
			
		||||
    // (i.e. in the same tick) otherwise browser will complain for lack of
 | 
			
		||||
    // user intent. Using a Promise ClipboardItem constructor solves this.
 | 
			
		||||
    // https://bugs.webkit.org/show_bug.cgi?id=222262
 | 
			
		||||
    //
 | 
			
		||||
    // not await so that we can detect whether the thrown error likely relates
 | 
			
		||||
    // to a lack of support for the Promise ClipboardItem constructor
 | 
			
		||||
    promise = navigator.clipboard.write([
 | 
			
		||||
      new window.ClipboardItem({
 | 
			
		||||
        [MIME_TYPES.png]: blob,
 | 
			
		||||
      }),
 | 
			
		||||
    ]);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // if we're using a Promise ClipboardItem, let's try constructing
 | 
			
		||||
    // with resolution value instead
 | 
			
		||||
    if (isPromiseLike(blob)) {
 | 
			
		||||
      await navigator.clipboard.write([
 | 
			
		||||
        new window.ClipboardItem({
 | 
			
		||||
          [MIME_TYPES.png]: await blob,
 | 
			
		||||
        }),
 | 
			
		||||
      ]);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  await promise;
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
 | 
			
		||||
  await navigator.clipboard.write([
 | 
			
		||||
    new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement, PointerType } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useDevice } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
@@ -15,30 +15,22 @@ import {
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
import { AppState, Zoom } from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  capitalizeString,
 | 
			
		||||
  isTransparent,
 | 
			
		||||
  updateActiveTool,
 | 
			
		||||
  setCursorForShape,
 | 
			
		||||
} from "../utils";
 | 
			
		||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { actionToggleZenMode } from "../actions";
 | 
			
		||||
import "./Actions.scss";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
 | 
			
		||||
export const SelectedShapeActions = ({
 | 
			
		||||
  appState,
 | 
			
		||||
  elements,
 | 
			
		||||
  renderAction,
 | 
			
		||||
  elementType,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  renderAction: ActionManager["renderAction"];
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const targetElements = getTargetElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
@@ -54,22 +46,19 @@ export const SelectedShapeActions = ({
 | 
			
		||||
    isSingleElementBoundContainer = true;
 | 
			
		||||
  }
 | 
			
		||||
  const isEditing = Boolean(appState.editingElement);
 | 
			
		||||
  const device = useDevice();
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 | 
			
		||||
 | 
			
		||||
  const showFillIcons =
 | 
			
		||||
    hasBackground(appState.activeTool.type) ||
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    targetElements.some(
 | 
			
		||||
      (element) =>
 | 
			
		||||
        hasBackground(element.type) && !isTransparent(element.backgroundColor),
 | 
			
		||||
    );
 | 
			
		||||
  const showChangeBackgroundIcons =
 | 
			
		||||
    hasBackground(appState.activeTool.type) ||
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    targetElements.some((element) => hasBackground(element.type));
 | 
			
		||||
 | 
			
		||||
  const showLinkIcon =
 | 
			
		||||
    targetElements.length === 1 || isSingleElementBoundContainer;
 | 
			
		||||
 | 
			
		||||
  let commonSelectedType: string | null = targetElements[0]?.type || null;
 | 
			
		||||
 | 
			
		||||
  for (const element of targetElements) {
 | 
			
		||||
@@ -81,27 +70,23 @@ export const SelectedShapeActions = ({
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="panelColumn">
 | 
			
		||||
      <div>
 | 
			
		||||
        {((hasStrokeColor(appState.activeTool.type) &&
 | 
			
		||||
          appState.activeTool.type !== "image" &&
 | 
			
		||||
          commonSelectedType !== "image") ||
 | 
			
		||||
          targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
			
		||||
          renderAction("changeStrokeColor")}
 | 
			
		||||
      </div>
 | 
			
		||||
      {showChangeBackgroundIcons && (
 | 
			
		||||
        <div>{renderAction("changeBackgroundColor")}</div>
 | 
			
		||||
      )}
 | 
			
		||||
      {((hasStrokeColor(elementType) &&
 | 
			
		||||
        elementType !== "image" &&
 | 
			
		||||
        commonSelectedType !== "image") ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeColor")}
 | 
			
		||||
      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
			
		||||
      {showFillIcons && renderAction("changeFillStyle")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeWidth(appState.activeTool.type) ||
 | 
			
		||||
      {(hasStrokeWidth(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeWidth(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeWidth")}
 | 
			
		||||
 | 
			
		||||
      {(appState.activeTool.type === "freedraw" ||
 | 
			
		||||
      {(elementType === "freedraw" ||
 | 
			
		||||
        targetElements.some((element) => element.type === "freedraw")) &&
 | 
			
		||||
        renderAction("changeStrokeShape")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeStyle(appState.activeTool.type) ||
 | 
			
		||||
      {(hasStrokeStyle(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeStyle(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeStrokeStyle")}
 | 
			
		||||
@@ -109,12 +94,12 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(canChangeSharpness(appState.activeTool.type) ||
 | 
			
		||||
      {(canChangeSharpness(elementType) ||
 | 
			
		||||
        targetElements.some((element) => canChangeSharpness(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeSharpness")}</>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(hasText(appState.activeTool.type) ||
 | 
			
		||||
      {(hasText(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasText(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeFontSize")}
 | 
			
		||||
@@ -129,7 +114,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        (element) =>
 | 
			
		||||
          hasBoundTextElement(element) || isBoundToContainer(element),
 | 
			
		||||
      ) && renderAction("changeVerticalAlign")}
 | 
			
		||||
      {(canHaveArrowheads(appState.activeTool.type) ||
 | 
			
		||||
      {(canHaveArrowheads(elementType) ||
 | 
			
		||||
        targetElements.some((element) => canHaveArrowheads(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeArrowhead")}</>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -169,16 +154,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
            )}
 | 
			
		||||
            {targetElements.length > 2 &&
 | 
			
		||||
              renderAction("distributeHorizontally")}
 | 
			
		||||
            {/* breaks the row ˇˇ */}
 | 
			
		||||
            <div style={{ flexBasis: "100%", height: 0 }} />
 | 
			
		||||
            <div
 | 
			
		||||
              style={{
 | 
			
		||||
                display: "flex",
 | 
			
		||||
                flexWrap: "wrap",
 | 
			
		||||
                gap: ".5rem",
 | 
			
		||||
                marginTop: "-0.5rem",
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
            <div className="iconRow">
 | 
			
		||||
              {renderAction("alignTop")}
 | 
			
		||||
              {renderAction("alignVerticallyCentered")}
 | 
			
		||||
              {renderAction("alignBottom")}
 | 
			
		||||
@@ -192,11 +168,11 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        <fieldset>
 | 
			
		||||
          <legend>{t("labels.actions")}</legend>
 | 
			
		||||
          <div className="buttonList">
 | 
			
		||||
            {!device.isMobile && renderAction("duplicateSelection")}
 | 
			
		||||
            {!device.isMobile && renderAction("deleteSelectedElements")}
 | 
			
		||||
            {!isMobile && renderAction("duplicateSelection")}
 | 
			
		||||
            {!isMobile && renderAction("deleteSelectedElements")}
 | 
			
		||||
            {renderAction("group")}
 | 
			
		||||
            {renderAction("ungroup")}
 | 
			
		||||
            {showLinkIcon && renderAction("hyperlink")}
 | 
			
		||||
            {targetElements.length === 1 && renderAction("hyperlink")}
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -206,61 +182,42 @@ export const SelectedShapeActions = ({
 | 
			
		||||
 | 
			
		||||
export const ShapesSwitcher = ({
 | 
			
		||||
  canvas,
 | 
			
		||||
  activeTool,
 | 
			
		||||
  elementType,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  appState,
 | 
			
		||||
}: {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  activeTool: AppState["activeTool"];
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  onImageAction: (data: { pointerType: PointerType | null }) => void;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
}) => (
 | 
			
		||||
  <>
 | 
			
		||||
    {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
 | 
			
		||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
      const label = t(`toolBar.${value}`);
 | 
			
		||||
      const letter = key && (typeof key === "string" ? key : key[0]);
 | 
			
		||||
      const shortcut = letter
 | 
			
		||||
        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
 | 
			
		||||
        : `${numericKey}`;
 | 
			
		||||
        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
 | 
			
		||||
        : `${index + 1}`;
 | 
			
		||||
      return (
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          className={clsx("Shape", { fillable })}
 | 
			
		||||
          className="Shape"
 | 
			
		||||
          key={value}
 | 
			
		||||
          type="radio"
 | 
			
		||||
          icon={icon}
 | 
			
		||||
          checked={activeTool.type === value}
 | 
			
		||||
          checked={elementType === value}
 | 
			
		||||
          name="editor-current-shape"
 | 
			
		||||
          title={`${capitalizeString(label)} — ${shortcut}`}
 | 
			
		||||
          keyBindingLabel={numericKey}
 | 
			
		||||
          keyBindingLabel={`${index + 1}`}
 | 
			
		||||
          aria-label={capitalizeString(label)}
 | 
			
		||||
          aria-keyshortcuts={shortcut}
 | 
			
		||||
          data-testid={`toolbar-${value}`}
 | 
			
		||||
          onPointerDown={({ pointerType }) => {
 | 
			
		||||
            if (!appState.penDetected && pointerType === "pen") {
 | 
			
		||||
              setAppState({
 | 
			
		||||
                penDetected: true,
 | 
			
		||||
                penMode: true,
 | 
			
		||||
              });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          data-testid={value}
 | 
			
		||||
          onChange={({ pointerType }) => {
 | 
			
		||||
            if (appState.activeTool.type !== value) {
 | 
			
		||||
              trackEvent("toolbar", value, "ui");
 | 
			
		||||
            }
 | 
			
		||||
            const nextActiveTool = updateActiveTool(appState, {
 | 
			
		||||
              type: value,
 | 
			
		||||
            });
 | 
			
		||||
            setAppState({
 | 
			
		||||
              activeTool: nextActiveTool,
 | 
			
		||||
              elementType: value,
 | 
			
		||||
              multiElement: null,
 | 
			
		||||
              selectedElementIds: {},
 | 
			
		||||
            });
 | 
			
		||||
            setCursorForShape(canvas, {
 | 
			
		||||
              ...appState,
 | 
			
		||||
              activeTool: nextActiveTool,
 | 
			
		||||
            });
 | 
			
		||||
            setCursorForShape(canvas, value);
 | 
			
		||||
            if (value === "image") {
 | 
			
		||||
              onImageAction({ pointerType });
 | 
			
		||||
            }
 | 
			
		||||
@@ -278,57 +235,11 @@ export const ZoomActions = ({
 | 
			
		||||
  renderAction: ActionManager["renderAction"];
 | 
			
		||||
  zoom: Zoom;
 | 
			
		||||
}) => (
 | 
			
		||||
  <Stack.Col gap={1} className="zoom-actions">
 | 
			
		||||
    <Stack.Row align="center">
 | 
			
		||||
  <Stack.Col gap={1}>
 | 
			
		||||
    <Stack.Row gap={1} align="center">
 | 
			
		||||
      {renderAction("zoomOut")}
 | 
			
		||||
      {renderAction("resetZoom")}
 | 
			
		||||
      {renderAction("zoomIn")}
 | 
			
		||||
      {renderAction("resetZoom")}
 | 
			
		||||
    </Stack.Row>
 | 
			
		||||
  </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>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,9 @@
 | 
			
		||||
// TODO barnabasmolnar/editor-redesign
 | 
			
		||||
// this icon is not great
 | 
			
		||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
 | 
			
		||||
import { save } from "../components/icons";
 | 
			
		||||
import Stack from "../components/Stack";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { save, file } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import "./ActiveFile.scss";
 | 
			
		||||
import MenuItem from "./MenuItem";
 | 
			
		||||
 | 
			
		||||
type ActiveFileProps = {
 | 
			
		||||
  fileName?: string;
 | 
			
		||||
@@ -13,11 +11,18 @@ type ActiveFileProps = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
 | 
			
		||||
  <MenuItem
 | 
			
		||||
    label={`${t("buttons.save")}`}
 | 
			
		||||
    shortcut={getShortcutFromShortcutName("saveScene")}
 | 
			
		||||
    dataTestId="save-button"
 | 
			
		||||
    onClick={onSave}
 | 
			
		||||
    icon={save}
 | 
			
		||||
  />
 | 
			
		||||
  <Stack.Row className="ActiveFile" gap={1} align="center">
 | 
			
		||||
    <span className="ActiveFile__fileName">
 | 
			
		||||
      {file}
 | 
			
		||||
      <span>{fileName}</span>
 | 
			
		||||
    </span>
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="icon"
 | 
			
		||||
      icon={save}
 | 
			
		||||
      title={t("buttons.save")}
 | 
			
		||||
      aria-label={t("buttons.save")}
 | 
			
		||||
      onClick={onSave}
 | 
			
		||||
      data-testid="save-button"
 | 
			
		||||
    />
 | 
			
		||||
  </Stack.Row>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -2,24 +2,15 @@
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Avatar {
 | 
			
		||||
    width: 1.25rem;
 | 
			
		||||
    height: 1.25rem;
 | 
			
		||||
    border-radius: 100%;
 | 
			
		||||
    outline: 2px solid var(--avatar-border-color);
 | 
			
		||||
    outline-offset: 2px;
 | 
			
		||||
    width: 2.5rem;
 | 
			
		||||
    height: 2.5rem;
 | 
			
		||||
    border-radius: 1.25rem;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    color: $oc-white;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    font-size: 0.625rem;
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
 | 
			
		||||
    &-img {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      border-radius: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,34 +1,20 @@
 | 
			
		||||
import "./Avatar.scss";
 | 
			
		||||
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { getClientInitials } from "../clients";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
type AvatarProps = {
 | 
			
		||||
  children: string;
 | 
			
		||||
  onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
 | 
			
		||||
  color: string;
 | 
			
		||||
  border: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  src?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
 | 
			
		||||
  const shortName = getClientInitials(name);
 | 
			
		||||
  const [error, setError] = useState(false);
 | 
			
		||||
  const loadImg = !error && src;
 | 
			
		||||
  const style = loadImg ? undefined : { background: color };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="Avatar" style={style} onClick={onClick}>
 | 
			
		||||
      {loadImg ? (
 | 
			
		||||
        <img
 | 
			
		||||
          className="Avatar-img"
 | 
			
		||||
          src={src}
 | 
			
		||||
          alt={shortName}
 | 
			
		||||
          referrerPolicy="no-referrer"
 | 
			
		||||
          onError={() => setError(true)}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        shortName
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className="Avatar"
 | 
			
		||||
    style={{ background: color, border: `1px solid ${border}` }}
 | 
			
		||||
    onClick={onClick}
 | 
			
		||||
  >
 | 
			
		||||
    {children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								src/components/BackgroundPickerAndDarkModeToggle.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
);
 | 
			
		||||
@@ -4,7 +4,6 @@ import "./Card.scss";
 | 
			
		||||
 | 
			
		||||
export const Card: React.FC<{
 | 
			
		||||
  color: keyof OpenColor | "primary";
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
}> = ({ children, color }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
 
 | 
			
		||||