Compare commits
	
		
			136 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					3bc80ea3a3 | ||
| 
						 | 
					7ead1848da | ||
| 
						 | 
					7d00d2f9bb | ||
| 
						 | 
					704986042d | ||
| 
						 | 
					0d0fe32485 | ||
| 
						 | 
					6576b9442e | ||
| 
						 | 
					ee4cb2d4a9 | ||
| 
						 | 
					cdcc91faa5 | ||
| 
						 | 
					9093341dc1 | ||
| 
						 | 
					1973ae9444 | ||
| 
						 | 
					10cd6a24b0 | ||
| 
						 | 
					6abf4f52ff | ||
| 
						 | 
					4624ec2bd6 | ||
| 
						 | 
					e8685c5236 | ||
| 
						 | 
					6e9df2bae7 | ||
| 
						 | 
					ed0bec41dc | ||
| 
						 | 
					d4e12a2962 | ||
| 
						 | 
					978e85a33b | ||
| 
						 | 
					b5e26ba81f | ||
| 
						 | 
					4392a4644a | ||
| 
						 | 
					b1c8c538ee | ||
| 
						 | 
					f6492895df | ||
| 
						 | 
					e75f5f20e7 | ||
| 
						 | 
					0a0be839b9 | ||
| 
						 | 
					03f6d9c783 | ||
| 
						 | 
					df745c1098 | ||
| 
						 | 
					b888f7e7ba | ||
| 
						 | 
					3010253f72 | ||
| 
						 | 
					0815f3282e | ||
| 
						 | 
					9dd58288de | ||
| 
						 | 
					c2e2bb495c | ||
| 
						 | 
					a31cfe1f07 | ||
| 
						 | 
					d3367bfe12 | ||
| 
						 | 
					70791dfa7b | ||
| 
						 | 
					d63ec678db | ||
| 
						 | 
					26acebcdb6 | ||
| 
						 | 
					9dc930b447 | ||
| 
						 | 
					6e767fc949 | ||
| 
						 | 
					49bd683401 | ||
| 
						 | 
					3922ee8c11 | ||
| 
						 | 
					8c2bc94336 | ||
| 
						 | 
					fb4d97ef78 | ||
| 
						 | 
					a8e28afbad | ||
| 
						 | 
					da45a0ecbb | ||
| 
						 | 
					fde1579884 | ||
| 
						 | 
					68347ba476 | ||
| 
						 | 
					ce2c341910 | ||
| 
						 | 
					07cc858926 | ||
| 
						 | 
					a7a2936f7c | ||
| 
						 | 
					e72ff6be66 | ||
| 
						 | 
					0ea29675df | ||
| 
						 | 
					da2ad4f37c | ||
| 
						 | 
					33a7cf0d3f | ||
| 
						 | 
					f8e890df7b | ||
| 
						 | 
					12337a54a3 | ||
| 
						 | 
					7a9ed2cfa1 | ||
| 
						 | 
					553bf2956f | ||
| 
						 | 
					1e16a6e5bd | ||
| 
						 | 
					e4a0254c47 | ||
| 
						 | 
					e26f374ca6 | ||
| 
						 | 
					c799b28a0e | ||
| 
						 | 
					ee703206b0 | ||
| 
						 | 
					5c7113cb72 | ||
| 
						 | 
					1acd42a44c | ||
| 
						 | 
					bf2566d65e | ||
| 
						 | 
					85e8e93d60 | ||
| 
						 | 
					1e7dfa692b | ||
| 
						 | 
					1eb8920bc3 | ||
| 
						 | 
					543c624405 | ||
| 
						 | 
					0bf6830373 | ||
| 
						 | 
					af79461f41 | ||
| 
						 | 
					fd699c0447 | ||
| 
						 | 
					6a16caf13c | ||
| 
						 | 
					511eb62228 | ||
| 
						 | 
					04c46fc01a | ||
| 
						 | 
					49e792649d | ||
| 
						 | 
					4e1caf2417 | ||
| 
						 | 
					034f2e470b | ||
| 
						 | 
					adcd28f348 | ||
| 
						 | 
					62f1ed74f1 | ||
| 
						 | 
					8fa4273969 | ||
| 
						 | 
					672068ce7e | ||
| 
						 | 
					f1fc308a5d | ||
| 
						 | 
					001880ba88 | ||
| 
						 | 
					3a130cb102 | ||
| 
						 | 
					e682cf9bf6 | ||
| 
						 | 
					2a169924d0 | ||
| 
						 | 
					eb6e75b806 | ||
| 
						 | 
					38857b9e9d | ||
| 
						 | 
					342289f261 | ||
| 
						 | 
					095d7de618 | ||
| 
						 | 
					f57d52028a | ||
| 
						 | 
					60557df23a | ||
| 
						 | 
					bafbe9bbc8 | ||
| 
						 | 
					eb71e571e0 | ||
| 
						 | 
					b608ab74cc | ||
| 
						 | 
					a13c4f72f5 | ||
| 
						 | 
					629341da4d | ||
| 
						 | 
					778e4b08af | ||
| 
						 | 
					e16266ce5d | ||
| 
						 | 
					b6708fb73f | ||
| 
						 | 
					cdffed285d | ||
| 
						 | 
					3aa01ad272 | ||
| 
						 | 
					4acdc47ef0 | ||
| 
						 | 
					ade2565f49 | ||
| 
						 | 
					abacd22c3f | ||
| 
						 | 
					b05e0709b5 | ||
| 
						 | 
					229aa84668 | ||
| 
						 | 
					75148f6bac | ||
| 
						 | 
					c35d983fef | ||
| 
						 | 
					69878167c2 | ||
| 
						 | 
					eb1f717d35 | ||
| 
						 | 
					8e9af5c51b | ||
| 
						 | 
					afe0c760f6 | ||
| 
						 | 
					a231cefac0 | ||
| 
						 | 
					cb4c9d16fc | ||
| 
						 | 
					7366f089ba | ||
| 
						 | 
					7c3513b9df | ||
| 
						 | 
					aef3644c93 | ||
| 
						 | 
					0cf58adb4c | ||
| 
						 | 
					154654bb9f | ||
| 
						 | 
					dc2581a308 | ||
| 
						 | 
					428752542d | ||
| 
						 | 
					2712a06ab8 | ||
| 
						 | 
					3aab81bc35 | ||
| 
						 | 
					3b0fb1562d | ||
| 
						 | 
					0488b7b5c6 | ||
| 
						 | 
					b8d13c98b5 | ||
| 
						 | 
					6f82a88b79 | ||
| 
						 | 
					022f349dc6 | ||
| 
						 | 
					c1e2146d78 | ||
| 
						 | 
					8091ac6c08 | ||
| 
						 | 
					bc414ccaaf | ||
| 
						 | 
					0cf5f1ac1f | ||
| 
						 | 
					e9cb7ee77c | ||
| 
						 | 
					86c036505b | 
@@ -1,12 +0,0 @@
 | 
			
		||||
# http://EditorConfig.org
 | 
			
		||||
 | 
			
		||||
# top-level EditorConfig file
 | 
			
		||||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
end_of_line = lf
 | 
			
		||||
indent_size = 2
 | 
			
		||||
indent_style = space
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
trim_trailing_whitespace = true
 | 
			
		||||
							
								
								
									
										2
									
								
								.env
									
									
									
									
									
								
							
							
						
						@@ -1,5 +1,5 @@
 | 
			
		||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
 | 
			
		||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
 | 
			
		||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
 | 
			
		||||
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
 | 
			
		||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
 | 
			
		||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1 @@
 | 
			
		||||
REACT_APP_INCLUDE_GTAG=true
 | 
			
		||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										64
									
								
								.github/CONTRIBUTING.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,64 @@
 | 
			
		||||
# Contributing
 | 
			
		||||
 | 
			
		||||
## Setup
 | 
			
		||||
 | 
			
		||||
### Option 1 - Manual
 | 
			
		||||
 | 
			
		||||
1. Fork and clone the repo
 | 
			
		||||
1. Run `npm install` to install dependencies
 | 
			
		||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
 | 
			
		||||
 | 
			
		||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
 | 
			
		||||
>
 | 
			
		||||
> ```sh
 | 
			
		||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
 | 
			
		||||
> git fetch upstream
 | 
			
		||||
> git branch --set-upstream-to=upstream/master master
 | 
			
		||||
> ```
 | 
			
		||||
 | 
			
		||||
### Option 2 - CodeSandbox
 | 
			
		||||
 | 
			
		||||
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
 | 
			
		||||
1. Connect your Github account
 | 
			
		||||
1. Go to Git tab on left side
 | 
			
		||||
1. Tap on `Fork Sandbox`
 | 
			
		||||
1. Write your code
 | 
			
		||||
1. Commit and PR automatically
 | 
			
		||||
 | 
			
		||||
## Pull Request Guidelines
 | 
			
		||||
 | 
			
		||||
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
 | 
			
		||||
 | 
			
		||||
### Title
 | 
			
		||||
 | 
			
		||||
Make sure the title starts with a semantic prefix:
 | 
			
		||||
 | 
			
		||||
- **feat**: A new feature
 | 
			
		||||
- **fix**: A bug fix
 | 
			
		||||
- **improvement**: An improvement to a current feature
 | 
			
		||||
- **docs**: Documentation only changes
 | 
			
		||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
 | 
			
		||||
- **refactor**: A code change that neither fixes a bug nor adds a feature
 | 
			
		||||
- **perf**: A code change that improves performance
 | 
			
		||||
- **test**: Adding missing tests or correcting existing tests
 | 
			
		||||
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
 | 
			
		||||
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
 | 
			
		||||
- **chore**: Other changes that don't modify src or test files
 | 
			
		||||
- **revert**: Reverts a previous commit
 | 
			
		||||
 | 
			
		||||
### Changelog
 | 
			
		||||
 | 
			
		||||
Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
 | 
			
		||||
 | 
			
		||||
Notes:
 | 
			
		||||
 | 
			
		||||
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
 | 
			
		||||
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
 | 
			
		||||
 | 
			
		||||
### Testing
 | 
			
		||||
 | 
			
		||||
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
 | 
			
		||||
 | 
			
		||||
It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
 | 
			
		||||
 | 
			
		||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								.github/assets/logo.png
									
									
									
									
										vendored
									
									
								
							
							
						
						| 
		 Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 20 KiB  | 
							
								
								
									
										2
									
								
								.github/workflows/build-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -6,7 +6,7 @@ on:
 | 
			
		||||
      - master
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build-docker:
 | 
			
		||||
  build:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/build-packages.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -13,10 +13,10 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
 | 
			
		||||
      - name: Setup Node.js 12.x
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v1
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 12.x
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								.github/workflows/cancel.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,14 @@
 | 
			
		||||
name: Cancel previous runs
 | 
			
		||||
 | 
			
		||||
on: push
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  cancel:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    timeout-minutes: 3
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: styfle/cancel-workflow-action@0.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
 | 
			
		||||
          access_token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
							
								
								
									
										12
									
								
								.github/workflows/lint.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,10 +1,6 @@
 | 
			
		||||
name: Lint
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - master
 | 
			
		||||
  pull_request:
 | 
			
		||||
on: push
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  lint:
 | 
			
		||||
@@ -13,10 +9,10 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
 | 
			
		||||
      - name: Setup Node.js 12.x
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v1
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 12.x
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
 | 
			
		||||
      - name: Install and lint
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -24,5 +20,3 @@ jobs:
 | 
			
		||||
          npm run test:other
 | 
			
		||||
          npm run test:code
 | 
			
		||||
          npm run test:typecheck
 | 
			
		||||
        env:
 | 
			
		||||
          CI: true
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/locales-coverage.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -14,18 +14,18 @@ jobs:
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 | 
			
		||||
 | 
			
		||||
      - name: Setup Node.js 12.x
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v1
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 12.x
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
 | 
			
		||||
      - name: Create report file
 | 
			
		||||
        run: |
 | 
			
		||||
          npm run locales-coverage
 | 
			
		||||
          FILE_CHANGED=$(git diff src/locales/percentages.json)
 | 
			
		||||
          if [ ! -z "${FILE_CHANGED}" ]; then
 | 
			
		||||
            git config --global user.name 'Kostas Bariotis'
 | 
			
		||||
            git config --global user.email 'konmpar@gmail.com'
 | 
			
		||||
            git config --global user.name 'Excalidraw Bot'
 | 
			
		||||
            git config --global user.email 'bot@excalidraw.com'
 | 
			
		||||
            git add src/locales/percentages.json
 | 
			
		||||
            git commit -am "Auto commit: Calculate translation coverage"
 | 
			
		||||
            git push
 | 
			
		||||
@@ -43,5 +43,5 @@ jobs:
 | 
			
		||||
        uses: kt3k/update-pr-description@v1.0.1
 | 
			
		||||
        with:
 | 
			
		||||
          pr_body: ${{ steps.getCommentBody.outputs.body }}
 | 
			
		||||
          pr_title: "chore: New Crowdin updates"
 | 
			
		||||
          pr_title: "chore: Update translations from Crowdin"
 | 
			
		||||
          github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								.github/workflows/semantic-pr-title.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -10,7 +10,8 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  main:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: amannn/action-semantic-pull-request@v2.1.0
 | 
			
		||||
      - uses: amannn/action-semantic-pull-request@v3.0.0
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								.github/workflows/sentry-production.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -8,13 +8,14 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  release:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1.0.0
 | 
			
		||||
 | 
			
		||||
      - name: Setup Node.js 12.x
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v1
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 12.x
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
 | 
			
		||||
      - name: Install and build
 | 
			
		||||
        run: |
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,10 +1,6 @@
 | 
			
		||||
name: Tests
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - master
 | 
			
		||||
  pull_request:
 | 
			
		||||
on: push
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  test:
 | 
			
		||||
@@ -13,14 +9,12 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
 | 
			
		||||
      - name: Setup Node.js 12.x
 | 
			
		||||
      - name: Setup Node.js 14.x
 | 
			
		||||
        uses: actions/setup-node@v1
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 12.x
 | 
			
		||||
          node-version: 14.x
 | 
			
		||||
 | 
			
		||||
      - name: Install and test
 | 
			
		||||
        run: |
 | 
			
		||||
          npm ci
 | 
			
		||||
          npm run test:app
 | 
			
		||||
        env:
 | 
			
		||||
          CI: true
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "proseWrap": "never",
 | 
			
		||||
  "trailingComma": "all"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +0,0 @@
 | 
			
		||||
# Contributing
 | 
			
		||||
 | 
			
		||||
## Setup
 | 
			
		||||
 | 
			
		||||
### Option 1 - Manual
 | 
			
		||||
 | 
			
		||||
1. Fork and clone the repo
 | 
			
		||||
1. Run `npm install` to install dependencies
 | 
			
		||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
 | 
			
		||||
 | 
			
		||||
> To keep `master` branch pointing to remote repository and make
 | 
			
		||||
> pull requests from branches on your fork. To do this, run:
 | 
			
		||||
>
 | 
			
		||||
> ```sh
 | 
			
		||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
 | 
			
		||||
> git fetch upstream
 | 
			
		||||
> git branch --set-upstream-to=upstream/master master
 | 
			
		||||
> ```
 | 
			
		||||
 | 
			
		||||
### Option 2 - CodeSandbox
 | 
			
		||||
 | 
			
		||||
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
 | 
			
		||||
1. Connect your Github account
 | 
			
		||||
1. Go to Git tab on left side
 | 
			
		||||
1. Tap on `Fork Sandbox`
 | 
			
		||||
1. Write your code
 | 
			
		||||
1. Commit and PR automatically
 | 
			
		||||
@@ -5,7 +5,6 @@ WORKDIR /opt/node_app
 | 
			
		||||
COPY package.json package-lock.json ./
 | 
			
		||||
RUN npm i --no-optional
 | 
			
		||||
 | 
			
		||||
ARG REACT_APP_INCLUDE_GTAG=false
 | 
			
		||||
ARG NODE_ENV=production
 | 
			
		||||
 | 
			
		||||
COPY . .
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@@ -1,8 +1,8 @@
 | 
			
		||||
<div align="center" style="display:flex;flex-direction:column;">
 | 
			
		||||
  <a href="https://excalidraw.com">
 | 
			
		||||
    <img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
 | 
			
		||||
    <img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
 | 
			
		||||
  </a>
 | 
			
		||||
  <h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
 | 
			
		||||
  <h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end to end encrypted.</h3>
 | 
			
		||||
  <p>
 | 
			
		||||
    <a href="https://twitter.com/Excalidraw">
 | 
			
		||||
      <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
 | 
			
		||||
@@ -10,9 +10,6 @@
 | 
			
		||||
    <a target="_blank" href="https://crowdin.com/project/excalidraw">
 | 
			
		||||
      <img src="https://badges.crowdin.net/excalidraw/localized.svg">
 | 
			
		||||
    </a>
 | 
			
		||||
    <a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
 | 
			
		||||
      <img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
 | 
			
		||||
    </a>
 | 
			
		||||
  </p>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@@ -20,13 +17,51 @@
 | 
			
		||||
 | 
			
		||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
 | 
			
		||||
 | 
			
		||||
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
 | 
			
		||||
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
### Shortcuts
 | 
			
		||||
 | 
			
		||||
You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
 | 
			
		||||
 | 
			
		||||
### Curved lines and arrows
 | 
			
		||||
 | 
			
		||||
Choose line or arrow and click click click instead of drag.
 | 
			
		||||
 | 
			
		||||
### Charts
 | 
			
		||||
 | 
			
		||||
You can easily create charts by copy pasting data from Excel or just plain comma separated text.
 | 
			
		||||
 | 
			
		||||
### Translating
 | 
			
		||||
 | 
			
		||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
 | 
			
		||||
 | 
			
		||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
 | 
			
		||||
 | 
			
		||||
### Create a collaboration session manually
 | 
			
		||||
 | 
			
		||||
In order to create a session manually you just need to generate a link of this form:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Example
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
The first set of digits is the room. This is visible from the server that’s going to dispatch messages to everyone that knows this number.
 | 
			
		||||
 | 
			
		||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
 | 
			
		||||
 | 
			
		||||
## Shape libraries
 | 
			
		||||
 | 
			
		||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
 | 
			
		||||
 | 
			
		||||
## Run the code
 | 
			
		||||
## Developement
 | 
			
		||||
 | 
			
		||||
### Code Sandbox
 | 
			
		||||
 | 
			
		||||
@@ -63,7 +98,7 @@ You can use docker-compose to work on excalidraw locally if you don't want to se
 | 
			
		||||
docker-compose up --build -d
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Self hosting
 | 
			
		||||
### Self hosting
 | 
			
		||||
 | 
			
		||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
 | 
			
		||||
 | 
			
		||||
@@ -82,57 +117,11 @@ We are working towards providing a full-fledged solution for self hosting your o
 | 
			
		||||
 | 
			
		||||
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
 | 
			
		||||
 | 
			
		||||
## Translating
 | 
			
		||||
## Notable used tools
 | 
			
		||||
 | 
			
		||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
 | 
			
		||||
 | 
			
		||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
 | 
			
		||||
 | 
			
		||||
## Excalidraw is built using these awesome tools
 | 
			
		||||
 | 
			
		||||
- [React](https://reactjs.org)
 | 
			
		||||
- [Create React App](https://github.com/facebook/create-react-app)
 | 
			
		||||
- [Rough.js](https://roughjs.com)
 | 
			
		||||
- [TypeScript](https://www.typescriptlang.org)
 | 
			
		||||
- [Vercel](https://vercel.com)
 | 
			
		||||
 | 
			
		||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
 | 
			
		||||
 | 
			
		||||
## Testimonials
 | 
			
		||||
 | 
			
		||||
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
 | 
			
		||||
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
 | 
			
		||||
 | 
			
		||||
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
 | 
			
		||||
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
 | 
			
		||||
 | 
			
		||||
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
 | 
			
		||||
 | 
			
		||||
## Contributors
 | 
			
		||||
 | 
			
		||||
### Code Contributors
 | 
			
		||||
 | 
			
		||||
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
 | 
			
		||||
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
 | 
			
		||||
 | 
			
		||||
### Financial Contributors
 | 
			
		||||
 | 
			
		||||
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
 | 
			
		||||
 | 
			
		||||
#### Individuals
 | 
			
		||||
 | 
			
		||||
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
 | 
			
		||||
 | 
			
		||||
#### Organizations
 | 
			
		||||
 | 
			
		||||
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
 | 
			
		||||
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
 | 
			
		||||
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										64
									
								
								analytics.md
									
									
									
									
									
								
							
							
						
						@@ -1,64 +0,0 @@
 | 
			
		||||
| Excalidraw              | Category | Name                               | Label                           | Value     |
 | 
			
		||||
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
 | 
			
		||||
| Shape / Selection       | shape    | selection, rectangle, diamond, etc | `toolbar` or `shortcut`         |
 | 
			
		||||
| Text on double click    | shape    | text                               | `double-click`                  |
 | 
			
		||||
| Lock selection          | shape    | lock                               | `on` or `off`                   |
 | 
			
		||||
| Clear canvas            | action   | clear canvas                       |
 | 
			
		||||
| Zoom in                 | action   | zoom                               | in                              | `zoom`    |
 | 
			
		||||
| Zoom out                | action   | zoom                               | out                             | `zoom`    |
 | 
			
		||||
| Zoom fit                | action   | zoom                               | fit                             | `zoom`    |
 | 
			
		||||
| Zoom reset              | action   | zoom                               | reset                           | `zoom`    |
 | 
			
		||||
| Scroll back to content  | action   | scroll to content                  |
 | 
			
		||||
| Load file               | io       | load                               | `MIME type`                     |
 | 
			
		||||
| Import from URL         | io       | import                             |
 | 
			
		||||
| Save                    | io       | save                               |
 | 
			
		||||
| Save as                 | io       | save as                            |
 | 
			
		||||
| Export to backend       | io       | export                             | backend                         |
 | 
			
		||||
| Export as SVG           | io       | export                             | `svg` or `clipboard-svg`        |
 | 
			
		||||
| Export to PNG           | io       | export                             | `png` or `clipboard-png`        |
 | 
			
		||||
| Canvas color            | change   | canvas color                       | `color`                         |
 | 
			
		||||
| Background color        | change   | background color                   | `color`                         |
 | 
			
		||||
| Stroke color            | change   | stroke color                       | `color`                         |
 | 
			
		||||
| Stroke width            | change   | stroke                             | width                           | `width`   |
 | 
			
		||||
| Stroke style            | change   | style                              | `solid` or `dashed` or `dotted` |
 | 
			
		||||
| Stroke sloppiness       | change   | stroke                             | sloppiness                      | `value`   |
 | 
			
		||||
| Fill                    | change   | fill                               | `value`                         |
 | 
			
		||||
| Edge                    | change   | edge                               | `value`                         |
 | 
			
		||||
| Opacity                 | change   | opacity                            | value                           | `opacity` |
 | 
			
		||||
| Project name            | change   | title                              |
 | 
			
		||||
| Theme                   | change   | theme                              | `light` or `dark`               |
 | 
			
		||||
| Change language         | change   | language                           | `language`                      |
 | 
			
		||||
| Send to back            | layer    | move                               | `back`                          |
 | 
			
		||||
| Send backward           | layer    | move                               | `down`                          |
 | 
			
		||||
| Bring to front          | layer    | move                               | `front`                         |
 | 
			
		||||
| Bring forward           | layer    | move                               | `up`                            |
 | 
			
		||||
| Align left              | align    | align                              | `left`                          |
 | 
			
		||||
| Align right             | align    | align                              | `right`                         |
 | 
			
		||||
| Align top               | align    | align                              | `top`                           |
 | 
			
		||||
| Align bottom            | align    | align                              | `bottom`                        |
 | 
			
		||||
| Center horizontally     | align    | horizontally                       | `center`                        |
 | 
			
		||||
| Center vertically       | align    | vertically                         | `center`                        |
 | 
			
		||||
| Distribute horizontally | align    | distribute                         | `horizontally`                  |
 | 
			
		||||
| Distribute vertically   | align    | distribute                         | `vertically`                    |
 | 
			
		||||
| Start session           | share    | session start                      |
 | 
			
		||||
| Join session            | share    | session join                       |
 | 
			
		||||
| Start end               | share    | session end                        |
 | 
			
		||||
| Copy room link          | share    | copy link                          |
 | 
			
		||||
| Go to collaborator      | share    | go to collaborator                 |
 | 
			
		||||
| Change name             | share    | name                               |
 | 
			
		||||
| Add to library          | library  | add                                |
 | 
			
		||||
| Remove from library     | library  | remove                             |
 | 
			
		||||
| Load library            | library  | load                               |
 | 
			
		||||
| Save library            | library  | save                               |
 | 
			
		||||
| Import library          | library  | import                             |
 | 
			
		||||
| Shortcuts dialog        | dialog   | shortcuts                          |
 | 
			
		||||
| Collaboration dialog    | dialog   | collaboration                      |
 | 
			
		||||
| Export dialog           | dialog   | export                             |
 | 
			
		||||
| Library dialog          | dialog   | library                            |
 | 
			
		||||
| E2EE shield             | exit     | e2ee shield                        |
 | 
			
		||||
| GitHub corner           | exit     | github                             |
 | 
			
		||||
| Excalidraw blog         | exit     | blog                               |
 | 
			
		||||
| Excalidraw guides       | exit     | guides                             |
 | 
			
		||||
| File issues             | exit     | issues                             |
 | 
			
		||||
| First load              | load     | first load                         |
 | 
			
		||||
| Load from stroage       | load     | storage                            | size                            | `bytes`   |
 | 
			
		||||
							
								
								
									
										3951
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										59
									
								
								package.json
									
									
									
									
									
								
							
							
						
						@@ -1,5 +1,10 @@
 | 
			
		||||
{
 | 
			
		||||
  "browserslist": {
 | 
			
		||||
    "development": [
 | 
			
		||||
      "last 1 chrome version",
 | 
			
		||||
      "last 1 firefox version",
 | 
			
		||||
      "last 1 safari version"
 | 
			
		||||
    ],
 | 
			
		||||
    "production": [
 | 
			
		||||
      ">0.2%",
 | 
			
		||||
      "not dead",
 | 
			
		||||
@@ -11,31 +16,25 @@
 | 
			
		||||
      "not chrome < 70",
 | 
			
		||||
      "not and_uc < 13",
 | 
			
		||||
      "not samsung < 10"
 | 
			
		||||
    ],
 | 
			
		||||
    "development": [
 | 
			
		||||
      "last 1 chrome version",
 | 
			
		||||
      "last 1 firefox version",
 | 
			
		||||
      "last 1 safari version"
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@sentry/browser": "5.29.0",
 | 
			
		||||
    "@sentry/integrations": "5.29.0",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.6",
 | 
			
		||||
    "@testing-library/react": "11.2.2",
 | 
			
		||||
    "@types/jest": "26.0.19",
 | 
			
		||||
    "@types/nanoid": "2.1.0",
 | 
			
		||||
    "@sentry/browser": "6.0.1",
 | 
			
		||||
    "@sentry/integrations": "6.0.1",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.9",
 | 
			
		||||
    "@testing-library/react": "11.2.3",
 | 
			
		||||
    "@types/jest": "26.0.20",
 | 
			
		||||
    "@types/react": "17.0.0",
 | 
			
		||||
    "@types/react-dom": "17.0.0",
 | 
			
		||||
    "@types/socket.io-client": "1.4.34",
 | 
			
		||||
    "browser-nativefs": "0.12.0",
 | 
			
		||||
    "@types/socket.io-client": "1.4.35",
 | 
			
		||||
    "browser-fs-access": "0.13.0",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "firebase": "8.2.1",
 | 
			
		||||
    "firebase": "8.2.5",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.0.1",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "2.1.11",
 | 
			
		||||
    "nanoid": "3.1.20",
 | 
			
		||||
    "node-sass": "4.14.1",
 | 
			
		||||
    "open-color": "1.7.0",
 | 
			
		||||
    "open-color": "1.8.0",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "png-chunk-text": "1.0.0",
 | 
			
		||||
    "png-chunks-encode": "1.0.0",
 | 
			
		||||
@@ -52,10 +51,10 @@
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/pako": "1.0.1",
 | 
			
		||||
    "eslint-config-prettier": "7.1.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.0",
 | 
			
		||||
    "firebase-tools": "9.0.1",
 | 
			
		||||
    "husky": "4.3.6",
 | 
			
		||||
    "eslint-config-prettier": "7.2.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "firebase-tools": "9.2.2",
 | 
			
		||||
    "husky": "4.3.8",
 | 
			
		||||
    "jest-canvas-mock": "2.3.0",
 | 
			
		||||
    "lint-staged": "10.5.3",
 | 
			
		||||
    "pepjs": "0.5.3",
 | 
			
		||||
@@ -72,34 +71,34 @@
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "jest": {
 | 
			
		||||
    "resetMocks": false,
 | 
			
		||||
    "transformIgnorePatterns": [
 | 
			
		||||
      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
 | 
			
		||||
    ],
 | 
			
		||||
    "resetMocks": false
 | 
			
		||||
      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "name": "excalidraw",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build-node": "node ./scripts/build-node.js",
 | 
			
		||||
    "build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
 | 
			
		||||
    "build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
 | 
			
		||||
    "build:version": "node ./scripts/build-version.js",
 | 
			
		||||
    "build": "npm run build:app && npm run build:version",
 | 
			
		||||
    "build-node": "node ./scripts/build-node.js",
 | 
			
		||||
    "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
 | 
			
		||||
    "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
 | 
			
		||||
    "build:version": "node ./scripts/build-version.js",
 | 
			
		||||
    "eject": "react-scripts eject",
 | 
			
		||||
    "fix": "npm run fix:other && npm run fix:code",
 | 
			
		||||
    "fix:code": "npm run test:code -- --fix",
 | 
			
		||||
    "fix:other": "npm run prettier -- --write",
 | 
			
		||||
    "fix": "npm run fix:other && npm run fix:code",
 | 
			
		||||
    "locales-coverage": "node scripts/build-locales-coverage.js",
 | 
			
		||||
    "locales-coverage:description": "node scripts/locales-coverage-description.js",
 | 
			
		||||
    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
 | 
			
		||||
    "start": "react-scripts start",
 | 
			
		||||
    "test": "npm run test:app",
 | 
			
		||||
    "test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
 | 
			
		||||
    "test:app": "react-scripts test --passWithNoTests",
 | 
			
		||||
    "test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
 | 
			
		||||
    "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
 | 
			
		||||
    "test:other": "npm run prettier -- --list-different",
 | 
			
		||||
    "test:typecheck": "tsc",
 | 
			
		||||
    "test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
 | 
			
		||||
    "test": "npm run test:app"
 | 
			
		||||
    "test:update": "npm run test:app -- --updateSnapshot --watchAll=false"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 4.3 KiB  | 
@@ -86,10 +86,10 @@
 | 
			
		||||
 | 
			
		||||
    <link rel="stylesheet" href="fonts.css" type="text/css" />
 | 
			
		||||
 | 
			
		||||
    <% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
 | 
			
		||||
    <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
 | 
			
		||||
    <script
 | 
			
		||||
      async
 | 
			
		||||
      src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
 | 
			
		||||
      src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
 | 
			
		||||
    ></script>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.dataLayer = window.dataLayer || [];
 | 
			
		||||
@@ -97,7 +97,7 @@
 | 
			
		||||
        dataLayer.push(arguments);
 | 
			
		||||
      }
 | 
			
		||||
      gtag("js", new Date());
 | 
			
		||||
      gtag("config", "UA-387204-13");
 | 
			
		||||
      gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
 | 
			
		||||
    </script>
 | 
			
		||||
    <% } %>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.6 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								public/og-image-sm.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 76 KiB  | 
| 
		 Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 70 KiB  | 
@@ -5,23 +5,40 @@ const path = require("path");
 | 
			
		||||
const versionFile = path.join("build", "version.json");
 | 
			
		||||
const indexFile = path.join("build", "index.html");
 | 
			
		||||
 | 
			
		||||
const zero = (digit) => `0${digit}`.slice(-2);
 | 
			
		||||
const versionDate = (date) => date.toISOString().replace(".000", "");
 | 
			
		||||
 | 
			
		||||
const versionDate = (date) => {
 | 
			
		||||
  const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
 | 
			
		||||
    date.getDate(),
 | 
			
		||||
  )}`;
 | 
			
		||||
  const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
 | 
			
		||||
    date.getSeconds(),
 | 
			
		||||
  )}`;
 | 
			
		||||
  return `${date_}-${time}`;
 | 
			
		||||
const commitHash = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    return require("child_process")
 | 
			
		||||
      .execSync("git rev-parse --short HEAD")
 | 
			
		||||
      .toString()
 | 
			
		||||
      .trim();
 | 
			
		||||
  } catch {
 | 
			
		||||
    return "none";
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const now = new Date();
 | 
			
		||||
const commitDate = (hash) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const unix = require("child_process")
 | 
			
		||||
      .execSync(`git show -s --format=%ct ${hash}`)
 | 
			
		||||
      .toString()
 | 
			
		||||
      .trim();
 | 
			
		||||
    const date = new Date(parseInt(unix) * 1000);
 | 
			
		||||
    return versionDate(date);
 | 
			
		||||
  } catch {
 | 
			
		||||
    return versionDate(new Date());
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getFullVersion = () => {
 | 
			
		||||
  const hash = commitHash();
 | 
			
		||||
  return `${commitDate(hash)}-${hash}`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const data = JSON.stringify(
 | 
			
		||||
  {
 | 
			
		||||
    version: versionDate(now),
 | 
			
		||||
    version: getFullVersion(),
 | 
			
		||||
  },
 | 
			
		||||
  undefined,
 | 
			
		||||
  2,
 | 
			
		||||
@@ -34,7 +51,7 @@ fs.readFile(indexFile, "utf8", (error, data) => {
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
  const result = data.replace(/{version}/g, versionDate(now));
 | 
			
		||||
  const result = data.replace(/{version}/g, getFullVersion());
 | 
			
		||||
 | 
			
		||||
  fs.writeFile(indexFile, result, "utf8", (error) => {
 | 
			
		||||
    if (error) {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,26 +4,28 @@ const THRESSHOLD = 85;
 | 
			
		||||
 | 
			
		||||
const crowdinMap = {
 | 
			
		||||
  "ar-SA": "en-ar",
 | 
			
		||||
  "el-GR": "en-el",
 | 
			
		||||
  "fi-FI": "en-fi",
 | 
			
		||||
  "ja-JP": "en-ja",
 | 
			
		||||
  "bg-BG": "en-bg",
 | 
			
		||||
  "ca-ES": "en-ca",
 | 
			
		||||
  "de-DE": "en-de",
 | 
			
		||||
  "el-GR": "en-el",
 | 
			
		||||
  "es-ES": "en-es",
 | 
			
		||||
  "fa-IR": "en-fa",
 | 
			
		||||
  "fi-FI": "en-fi",
 | 
			
		||||
  "fr-FR": "en-fr",
 | 
			
		||||
  "he-IL": "en-he",
 | 
			
		||||
  "hi-IN": "en-hi",
 | 
			
		||||
  "hu-HU": "en-hu",
 | 
			
		||||
  "id-ID": "en-id",
 | 
			
		||||
  "it-IT": "en-it",
 | 
			
		||||
  "ja-JP": "en-ja",
 | 
			
		||||
  "ko-KR": "en-ko",
 | 
			
		||||
  "my-MM": "en-my",
 | 
			
		||||
  "nb-NO": "en-nb",
 | 
			
		||||
  "nl-NL": "en-nl",
 | 
			
		||||
  "nn-NO": "en-nnno",
 | 
			
		||||
  "pa-IN": "en-pain",
 | 
			
		||||
  "pl-PL": "en-pl",
 | 
			
		||||
  "pt-BR": "en-ptbr",
 | 
			
		||||
  "pt-PT": "en-pt",
 | 
			
		||||
  "ro-RO": "en-ro",
 | 
			
		||||
  "ru-RU": "en-ru",
 | 
			
		||||
@@ -56,7 +58,9 @@ const flags = {
 | 
			
		||||
  "nb-NO": "🇳🇴",
 | 
			
		||||
  "nl-NL": "🇳🇱",
 | 
			
		||||
  "nn-NO": "🇳🇴",
 | 
			
		||||
  "pa-IN": "🇮🇳",
 | 
			
		||||
  "pl-PL": "🇵🇱",
 | 
			
		||||
  "pt-BR": "🇧🇷",
 | 
			
		||||
  "pt-PT": "🇵🇹",
 | 
			
		||||
  "ro-RO": "🇷🇴",
 | 
			
		||||
  "ru-RU": "🇷🇺",
 | 
			
		||||
@@ -71,7 +75,7 @@ const flags = {
 | 
			
		||||
const languages = {
 | 
			
		||||
  "ar-SA": "العربية",
 | 
			
		||||
  "bg-BG": "Български",
 | 
			
		||||
  "ca-ES": "Catalan",
 | 
			
		||||
  "ca-ES": "Català",
 | 
			
		||||
  "de-DE": "Deutsch",
 | 
			
		||||
  "el-GR": "Ελληνικά",
 | 
			
		||||
  "es-ES": "Español",
 | 
			
		||||
@@ -89,7 +93,9 @@ const languages = {
 | 
			
		||||
  "nb-NO": "Norsk bokmål",
 | 
			
		||||
  "nl-NL": "Nederlands",
 | 
			
		||||
  "nn-NO": "Norsk nynorsk",
 | 
			
		||||
  "pa-IN": "ਪੰਜਾਬੀ",
 | 
			
		||||
  "pl-PL": "Polski",
 | 
			
		||||
  "pt-BR": "Português Brasileiro",
 | 
			
		||||
  "pt-PT": "Português",
 | 
			
		||||
  "ro-RO": "Română",
 | 
			
		||||
  "ru-RU": "Русский",
 | 
			
		||||
@@ -114,16 +120,14 @@ const boldIf = (text, condition) => (condition ? `**${text}**` : text);
 | 
			
		||||
 | 
			
		||||
const printHeader = () => {
 | 
			
		||||
  let result = "| | Flag | Locale | % |\n";
 | 
			
		||||
  result += "| --: | :--: | -- | --: |";
 | 
			
		||||
  result += "| :--: | :--: | -- | :--: |";
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const printRow = (id, locale, coverage) => {
 | 
			
		||||
  const isOver = coverage > THRESSHOLD;
 | 
			
		||||
  let result = `| ${boldIf(id, isOver)} | `;
 | 
			
		||||
 | 
			
		||||
  const isOver = coverage >= THRESSHOLD;
 | 
			
		||||
  let result = `| ${isOver ? id : "..."} | `;
 | 
			
		||||
  result += `${locale in flags ? flags[locale] : ""} | `;
 | 
			
		||||
 | 
			
		||||
  const language = locale in languages ? languages[locale] : locale;
 | 
			
		||||
  if (locale in crowdinMap && crowdinMap[locale]) {
 | 
			
		||||
    result += `[${boldIf(
 | 
			
		||||
@@ -133,14 +137,12 @@ const printRow = (id, locale, coverage) => {
 | 
			
		||||
  } else {
 | 
			
		||||
    result += `${boldIf(language, isOver)} | `;
 | 
			
		||||
  }
 | 
			
		||||
  result += `${boldIf(coverage, isOver)} |`;
 | 
			
		||||
  result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
console.info("## Languages check");
 | 
			
		||||
console.info("\n\r");
 | 
			
		||||
console.info(
 | 
			
		||||
  `Our translations for every languages should be at least **${THRESSHOLD}%** to appear on Excalidraw. Join our project in [Crowdin](https://crowdin.com/project/excalidraw) and help us translate it in your language. **Can't find your own?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
 | 
			
		||||
  `Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
 | 
			
		||||
);
 | 
			
		||||
console.info("\n\r");
 | 
			
		||||
console.info(printHeader());
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
@@ -16,9 +15,7 @@ export const actionAddToLibrary = register({
 | 
			
		||||
    Library.loadLibrary().then((items) => {
 | 
			
		||||
      Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
 | 
			
		||||
    });
 | 
			
		||||
    trackEvent(EVENT_LIBRARY, "add");
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
  contextMenuOrder: 6,
 | 
			
		||||
  contextItemLabel: "labels.addToLibrary",
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { alignElements, Alignment } from "../align";
 | 
			
		||||
import {
 | 
			
		||||
  AlignBottomIcon,
 | 
			
		||||
  AlignLeftIcon,
 | 
			
		||||
@@ -10,14 +8,15 @@ import {
 | 
			
		||||
  CenterHorizontallyIcon,
 | 
			
		||||
  CenterVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { alignElements, Alignment } from "../align";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const enableActionGroup = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -44,7 +43,6 @@ const alignSelectedElements = (
 | 
			
		||||
export const actionAlignTop = register({
 | 
			
		||||
  name: "alignTop",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "align", "top");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: alignSelectedElements(elements, appState, {
 | 
			
		||||
@@ -74,7 +72,6 @@ export const actionAlignTop = register({
 | 
			
		||||
export const actionAlignBottom = register({
 | 
			
		||||
  name: "alignBottom",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "align", "bottom");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: alignSelectedElements(elements, appState, {
 | 
			
		||||
@@ -104,7 +101,6 @@ export const actionAlignBottom = register({
 | 
			
		||||
export const actionAlignLeft = register({
 | 
			
		||||
  name: "alignLeft",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "align", "left");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: alignSelectedElements(elements, appState, {
 | 
			
		||||
@@ -134,7 +130,6 @@ export const actionAlignLeft = register({
 | 
			
		||||
export const actionAlignRight = register({
 | 
			
		||||
  name: "alignRight",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "align", "right");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: alignSelectedElements(elements, appState, {
 | 
			
		||||
@@ -164,7 +159,6 @@ export const actionAlignRight = register({
 | 
			
		||||
export const actionAlignVerticallyCentered = register({
 | 
			
		||||
  name: "alignVerticallyCentered",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "vertically", "center");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: alignSelectedElements(elements, appState, {
 | 
			
		||||
@@ -190,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
 | 
			
		||||
export const actionAlignHorizontallyCentered = register({
 | 
			
		||||
  name: "alignHorizontallyCentered",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "horizontally", "center");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: alignSelectedElements(elements, appState, {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,36 +1,25 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { ZOOM_STEP } from "../constants";
 | 
			
		||||
import { getCommonBounds, getNonDeletedElements } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getCommonBounds } from "../element";
 | 
			
		||||
import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
 | 
			
		||||
import colors from "../colors";
 | 
			
		||||
import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
  name: "changeViewBackgroundColor",
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    if (value !== appState.viewBackgroundColor) {
 | 
			
		||||
      trackEvent(
 | 
			
		||||
        EVENT_CHANGE,
 | 
			
		||||
        "canvas color",
 | 
			
		||||
        colors.canvasBackground.includes(value)
 | 
			
		||||
          ? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
 | 
			
		||||
          : value,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, viewBackgroundColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
@@ -53,7 +42,6 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
export const actionClearCanvas = register({
 | 
			
		||||
  name: "clearCanvas",
 | 
			
		||||
  perform: (elements, appState: AppState) => {
 | 
			
		||||
    trackEvent(EVENT_ACTION, "clear canvas");
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) =>
 | 
			
		||||
        newElementWith(element, { isDeleted: true }),
 | 
			
		||||
@@ -64,9 +52,10 @@ export const actionClearCanvas = register({
 | 
			
		||||
        elementLocked: appState.elementLocked,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        showGrid: appState.showGrid,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        shouldAddWatermark: appState.shouldAddWatermark,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
@@ -87,8 +76,6 @@ export const actionClearCanvas = register({
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const ZOOM_STEP = 0.1;
 | 
			
		||||
 | 
			
		||||
export const actionZoomIn = register({
 | 
			
		||||
  name: "zoomIn",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
@@ -98,7 +85,6 @@ export const actionZoomIn = register({
 | 
			
		||||
      { left: appState.offsetLeft, top: appState.offsetTop },
 | 
			
		||||
      { x: appState.width / 2, y: appState.height / 2 },
 | 
			
		||||
    );
 | 
			
		||||
    trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
@@ -133,7 +119,6 @@ export const actionZoomOut = register({
 | 
			
		||||
      { x: appState.width / 2, y: appState.height / 2 },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
@@ -161,7 +146,6 @@ export const actionZoomOut = register({
 | 
			
		||||
export const actionResetZoom = register({
 | 
			
		||||
  name: "resetZoom",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ACTION, "zoom", "reset", 100);
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
@@ -234,12 +218,10 @@ const zoomToFitElements = (
 | 
			
		||||
    left: appState.offsetLeft,
 | 
			
		||||
    top: appState.offsetTop,
 | 
			
		||||
  });
 | 
			
		||||
  const action = zoomToSelection ? "selection" : "fit";
 | 
			
		||||
 | 
			
		||||
  const [x1, y1, x2, y2] = commonBounds;
 | 
			
		||||
  const centerX = (x1 + x2) / 2;
 | 
			
		||||
  const centerY = (y1 + y2) / 2;
 | 
			
		||||
  trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
 | 
			
		||||
  return {
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										114
									
								
								src/actions/actionClipboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,114 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { copyToClipboard } from "../clipboard";
 | 
			
		||||
import { actionDeleteSelected } from "./actionDeleteSelected";
 | 
			
		||||
import { getSelectedElements } from "../scene/selection";
 | 
			
		||||
import { exportCanvas } from "../data/index";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionCopy = register({
 | 
			
		||||
  name: "copy",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.copy",
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionCut = register({
 | 
			
		||||
  name: "cut",
 | 
			
		||||
  perform: (elements, appState, data, app) => {
 | 
			
		||||
    actionCopy.perform(elements, appState, data, app);
 | 
			
		||||
    return actionDeleteSelected.perform(elements, appState, data, app);
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.cut",
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionCopyAsSvg = register({
 | 
			
		||||
  name: "copyAsSvg",
 | 
			
		||||
  perform: async (elements, appState, _data, app) => {
 | 
			
		||||
    if (!app.canvas) {
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
        "clipboard-svg",
 | 
			
		||||
        selectedElements.length
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          errorMessage: error.message,
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.copyAsSvg",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionCopyAsPng = register({
 | 
			
		||||
  name: "copyAsPng",
 | 
			
		||||
  perform: async (elements, appState, _data, app) => {
 | 
			
		||||
    if (!app.canvas) {
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
        "clipboard",
 | 
			
		||||
        selectedElements.length
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          toastMessage: t("toast.copyToClipboardAsPng"),
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          errorMessage: error.message,
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.copyAsPng",
 | 
			
		||||
  keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
 | 
			
		||||
});
 | 
			
		||||
@@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.delete",
 | 
			
		||||
  contextMenuOrder: 999999,
 | 
			
		||||
  keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,18 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { CODES } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import {
 | 
			
		||||
  DistributeHorizontallyIcon,
 | 
			
		||||
  DistributeVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { distributeElements, Distribution } from "../disitrubte";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const enableActionGroup = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -40,7 +39,6 @@ const distributeSelectedElements = (
 | 
			
		||||
export const distributeHorizontally = register({
 | 
			
		||||
  name: "distributeHorizontally",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "distribute", "horizontally");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: distributeSelectedElements(elements, appState, {
 | 
			
		||||
@@ -69,7 +67,6 @@ export const distributeHorizontally = register({
 | 
			
		||||
export const distributeVertically = register({
 | 
			
		||||
  name: "distributeVertically",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_ALIGN, "distribute", "vertically");
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: distributeSelectedElements(elements, appState, {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,20 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
 | 
			
		||||
import { load, save, saveAs } from "../components/icons";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { load, questionCircle, save, saveAs } from "../components/icons";
 | 
			
		||||
import { ProjectName } from "../components/ProjectName";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import "../components/ToolIcon.scss";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { questionCircle } from "../components/icons";
 | 
			
		||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import "../components/ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
export const actionChangeProjectName = register({
 | 
			
		||||
  name: "changeProjectName",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "title");
 | 
			
		||||
    trackEvent("change", "title");
 | 
			
		||||
    return { appState: { ...appState, name: value }, commitToHistory: false };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
@@ -100,7 +98,6 @@ export const actionSaveScene = register({
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(elements, appState);
 | 
			
		||||
      trackEvent(EVENT_IO, "save");
 | 
			
		||||
      return { commitToHistory: false, appState: { ...appState, fileHandle } };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
@@ -131,7 +128,6 @@ export const actionSaveAsScene = register({
 | 
			
		||||
        ...appState,
 | 
			
		||||
        fileHandle: null,
 | 
			
		||||
      });
 | 
			
		||||
      trackEvent(EVENT_IO, "save as");
 | 
			
		||||
      return { commitToHistory: false, appState: { ...appState, fileHandle } };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
@@ -159,18 +155,29 @@ export const actionSaveAsScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  perform: (
 | 
			
		||||
    elements,
 | 
			
		||||
    appState,
 | 
			
		||||
    { elements: loadedElements, appState: loadedAppState, error },
 | 
			
		||||
  ) => ({
 | 
			
		||||
    elements: loadedElements,
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...loadedAppState,
 | 
			
		||||
      errorMessage: error,
 | 
			
		||||
    },
 | 
			
		||||
    commitToHistory: true,
 | 
			
		||||
  }),
 | 
			
		||||
  perform: async (elements, appState) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
      } = await loadFromJSON(appState);
 | 
			
		||||
      return {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name === "AbortError") {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
        appState: { ...appState, errorMessage: error.message },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
@@ -178,16 +185,7 @@ export const actionLoadScene = register({
 | 
			
		||||
      title={t("buttons.load")}
 | 
			
		||||
      aria-label={t("buttons.load")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        loadFromJSON(appState)
 | 
			
		||||
          .then(({ elements, appState }) => {
 | 
			
		||||
            updateData({ elements, appState });
 | 
			
		||||
          })
 | 
			
		||||
          .catch(muteFSAbortError)
 | 
			
		||||
          .catch((error) => {
 | 
			
		||||
            updateData({ error: error.message });
 | 
			
		||||
          });
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -118,11 +118,14 @@ export const actionFinalize = register({
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!appState.elementLocked) {
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "draw") {
 | 
			
		||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (!appState.elementLocked || !multiPointElement) {
 | 
			
		||||
    if (
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "draw") ||
 | 
			
		||||
      !multiPointElement
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor();
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
@@ -130,7 +133,8 @@ export const actionFinalize = register({
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        elementType:
 | 
			
		||||
          appState.elementLocked && multiPointElement
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "draw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.elementType
 | 
			
		||||
            : "selection",
 | 
			
		||||
        draggingElement: null,
 | 
			
		||||
@@ -139,7 +143,9 @@ export const actionFinalize = register({
 | 
			
		||||
        startBoundElement: null,
 | 
			
		||||
        suggestedBindings: [],
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement && !appState.elementLocked
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "draw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
 
 | 
			
		||||
@@ -125,7 +125,6 @@ export const actionGroup = register({
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextMenuOrder: 4,
 | 
			
		||||
  contextItemLabel: "labels.group",
 | 
			
		||||
  contextItemPredicate: (elements, appState) =>
 | 
			
		||||
    enableActionGroup(elements, appState),
 | 
			
		||||
@@ -174,7 +173,6 @@ export const actionUngroup = register({
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
 | 
			
		||||
  contextMenuOrder: 5,
 | 
			
		||||
  contextItemLabel: "labels.ungroup",
 | 
			
		||||
  contextItemPredicate: (elements, appState) =>
 | 
			
		||||
    getSelectedGroupIds(appState).length > 0,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ import { t } from "../i18n";
 | 
			
		||||
import { SceneHistory, HistoryEntry } from "../history";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { isWindows, KEYS } from "../keys";
 | 
			
		||||
import { getElementMap } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
@@ -59,16 +59,16 @@ const writeData = (
 | 
			
		||||
  return { commitToHistory };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
 | 
			
		||||
  event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
 | 
			
		||||
 | 
			
		||||
type ActionCreator = (history: SceneHistory) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.undoOnce()),
 | 
			
		||||
  keyTest: testUndo(false),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
    event.key.toLowerCase() === KEYS.Z &&
 | 
			
		||||
    !event.shiftKey,
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
@@ -84,7 +84,11 @@ export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "redo",
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.redoOnce()),
 | 
			
		||||
  keyTest: testUndo(true),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    (event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      event.key.toLowerCase() === KEYS.Z) ||
 | 
			
		||||
    (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,6 @@ import { register } from "./register";
 | 
			
		||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { HelpIcon } from "../components/HelpIcon";
 | 
			
		||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleCanvasMenu = register({
 | 
			
		||||
  name: "toggleCanvasMenu",
 | 
			
		||||
@@ -72,17 +71,16 @@ export const actionFullScreen = register({
 | 
			
		||||
export const actionShortcuts = register({
 | 
			
		||||
  name: "toggleShortcuts",
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    trackEvent(EVENT_DIALOG, "shortcuts");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        showShortcutsDialog: true,
 | 
			
		||||
        showHelpDialog: !appState.showHelpDialog,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
 | 
			
		||||
    <HelpIcon title={t("helpDialog.title")} onClick={updateData} />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) => event.key === KEYS.QUESTION_MARK,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,14 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getClientColors, getClientInitials } from "../clients";
 | 
			
		||||
import { Collaborator } from "../types";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { EVENT_SHARE, trackEvent } from "../analytics";
 | 
			
		||||
import { Collaborator } from "../types";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionGoToCollaborator = register({
 | 
			
		||||
  name: "goToCollaborator",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    const point = value as Collaborator["pointer"];
 | 
			
		||||
    trackEvent(EVENT_SHARE, "go to collaborator");
 | 
			
		||||
    if (!point) {
 | 
			
		||||
      return { appState, commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +1,53 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { getLanguage } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  TextAlign,
 | 
			
		||||
  FontFamily,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  Arrowhead,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import {
 | 
			
		||||
  getCommonAttributeOfSelectedElements,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { ButtonSelect } from "../components/ButtonSelect";
 | 
			
		||||
import { AppState } from "../../src/types";
 | 
			
		||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
			
		||||
import { ButtonSelect } from "../components/ButtonSelect";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { IconPicker } from "../components/IconPicker";
 | 
			
		||||
import {
 | 
			
		||||
  isTextElement,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { AppState } from "../../src/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
 | 
			
		||||
import { randomInteger } from "../random";
 | 
			
		||||
import {
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  FillCrossHatchIcon,
 | 
			
		||||
  FillSolidIcon,
 | 
			
		||||
  StrokeWidthIcon,
 | 
			
		||||
  StrokeStyleSolidIcon,
 | 
			
		||||
  StrokeStyleDashedIcon,
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  SloppinessArchitectIcon,
 | 
			
		||||
  SloppinessArtistIcon,
 | 
			
		||||
  SloppinessCartoonistIcon,
 | 
			
		||||
  ArrowheadArrowIcon,
 | 
			
		||||
  ArrowheadBarIcon,
 | 
			
		||||
  ArrowheadDotIcon,
 | 
			
		||||
  ArrowheadNoneIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  FillCrossHatchIcon,
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  FillSolidIcon,
 | 
			
		||||
  SloppinessArchitectIcon,
 | 
			
		||||
  SloppinessArtistIcon,
 | 
			
		||||
  SloppinessCartoonistIcon,
 | 
			
		||||
  StrokeStyleDashedIcon,
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  StrokeStyleSolidIcon,
 | 
			
		||||
  StrokeWidthIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
 | 
			
		||||
import colors from "../colors";
 | 
			
		||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  Arrowhead,
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  FontFamily,
 | 
			
		||||
  TextAlign,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getLanguage, t } from "../i18n";
 | 
			
		||||
import { randomInteger } from "../random";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
  getCommonAttributeOfSelectedElements,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const changeProperty = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -92,15 +89,6 @@ const getFormValue = function <T>(
 | 
			
		||||
export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    if (value !== appState.currentItemStrokeColor) {
 | 
			
		||||
      trackEvent(
 | 
			
		||||
        EVENT_CHANGE,
 | 
			
		||||
        "stroke color",
 | 
			
		||||
        colors.elementStroke.includes(value)
 | 
			
		||||
          ? `${value} (picker ${colors.elementStroke.indexOf(value)})`
 | 
			
		||||
          : value,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -132,16 +120,6 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
export const actionChangeBackgroundColor = register({
 | 
			
		||||
  name: "changeBackgroundColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    if (value !== appState.currentItemBackgroundColor) {
 | 
			
		||||
      trackEvent(
 | 
			
		||||
        EVENT_CHANGE,
 | 
			
		||||
        "background color",
 | 
			
		||||
        colors.elementBackground.includes(value)
 | 
			
		||||
          ? `${value} (picker ${colors.elementBackground.indexOf(value)})`
 | 
			
		||||
          : value,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -173,7 +151,6 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
export const actionChangeFillStyle = register({
 | 
			
		||||
  name: "changeFillStyle",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "fill", value);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -223,7 +200,6 @@ export const actionChangeFillStyle = register({
 | 
			
		||||
export const actionChangeStrokeWidth = register({
 | 
			
		||||
  name: "changeStrokeWidth",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "stroke", "width", value);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -286,7 +262,6 @@ export const actionChangeStrokeWidth = register({
 | 
			
		||||
export const actionChangeSloppiness = register({
 | 
			
		||||
  name: "changeSloppiness",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -335,7 +310,6 @@ export const actionChangeSloppiness = register({
 | 
			
		||||
export const actionChangeStrokeStyle = register({
 | 
			
		||||
  name: "changeStrokeStyle",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "style", value);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -383,7 +357,6 @@ export const actionChangeStrokeStyle = register({
 | 
			
		||||
export const actionChangeOpacity = register({
 | 
			
		||||
  name: "changeOpacity",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "opacity", "value", value);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -580,7 +553,6 @@ export const actionChangeSharpness = register({
 | 
			
		||||
    const shouldUpdateForLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every(isLinearElement)
 | 
			
		||||
      : isLinearElementType(appState.elementType);
 | 
			
		||||
    trackEvent(EVENT_CHANGE, "edge", value);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -642,12 +614,6 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isLinearElement(el)) {
 | 
			
		||||
          trackEvent(
 | 
			
		||||
            EVENT_CHANGE,
 | 
			
		||||
            `arrowhead ${value.position}`,
 | 
			
		||||
            value.type || "none",
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          const { position, type } = value;
 | 
			
		||||
 | 
			
		||||
          if (position === "start") {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import {
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
@@ -23,13 +24,16 @@ export const actionCopyStyles = register({
 | 
			
		||||
      copiedStyles = JSON.stringify(element);
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        toastMessage: t("toast.copyStyles"),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.copyStyles",
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
 | 
			
		||||
  contextMenuOrder: 0,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionPasteStyles = register({
 | 
			
		||||
@@ -69,5 +73,4 @@ export const actionPasteStyles = register({
 | 
			
		||||
  contextItemLabel: "labels.pasteStyles",
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
 | 
			
		||||
  contextMenuOrder: 1,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								src/actions/actionToggleGridMode.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,20 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const actionToggleGridMode = register({
 | 
			
		||||
  name: "gridMode",
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        gridSize: this.checked!(appState) ? null : GRID_SIZE,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  checked: (appState: AppState) => appState.gridSize !== null,
 | 
			
		||||
  contextItemLabel: "labels.gridMode",
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										16
									
								
								src/actions/actionToggleStats.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,16 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        showStats: !this.checked!(appState),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  checked: (appState) => appState.showStats,
 | 
			
		||||
  contextItemLabel: "stats.title",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										19
									
								
								src/actions/actionToggleZenMode.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,19 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionToggleZenMode = register({
 | 
			
		||||
  name: "zenMode",
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        zenModeEnabled: !this.checked!(appState),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  checked: (appState) => appState.zenModeEnabled,
 | 
			
		||||
  contextItemLabel: "buttons.zenMode",
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
 | 
			
		||||
});
 | 
			
		||||
@@ -65,3 +65,15 @@ export {
 | 
			
		||||
  distributeHorizontally,
 | 
			
		||||
  distributeVertically,
 | 
			
		||||
} from "./actionDistribute";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  actionCopy,
 | 
			
		||||
  actionCut,
 | 
			
		||||
  actionCopyAsPng,
 | 
			
		||||
  actionCopyAsSvg,
 | 
			
		||||
} from "./actionClipboard";
 | 
			
		||||
 | 
			
		||||
export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
			
		||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
			
		||||
 | 
			
		||||
export { actionToggleStats } from "./actionToggleStats";
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,15 @@ import {
 | 
			
		||||
  Action,
 | 
			
		||||
  ActionsManagerInterface,
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionFilterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
  ActionResult,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { ShortcutName } from "./shortcuts";
 | 
			
		||||
 | 
			
		||||
// This is the <App> component, but for now we don't care about anything but its
 | 
			
		||||
// `canvas` state.
 | 
			
		||||
type App = { canvas: HTMLCanvasElement | null };
 | 
			
		||||
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
@@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 | 
			
		||||
 | 
			
		||||
  getAppState: () => Readonly<AppState>;
 | 
			
		||||
 | 
			
		||||
  getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
 | 
			
		||||
  app: App;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    updater: UpdaterFn,
 | 
			
		||||
    getAppState: () => AppState,
 | 
			
		||||
    getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
 | 
			
		||||
    app: App,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.updater = (actionResult) => {
 | 
			
		||||
      if (actionResult && "then" in actionResult) {
 | 
			
		||||
@@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
    };
 | 
			
		||||
    this.getAppState = getAppState;
 | 
			
		||||
    this.getElementsIncludingDeleted = getElementsIncludingDeleted;
 | 
			
		||||
    this.app = app;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  registerAction(action: Action) {
 | 
			
		||||
@@ -70,6 +73,7 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    return true;
 | 
			
		||||
@@ -81,43 +85,11 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
 | 
			
		||||
    return Object.values(this.actions)
 | 
			
		||||
      .filter(actionFilter)
 | 
			
		||||
      .filter((action) => "contextItemLabel" in action)
 | 
			
		||||
      .filter((action) =>
 | 
			
		||||
        action.contextItemPredicate
 | 
			
		||||
          ? action.contextItemPredicate(
 | 
			
		||||
              this.getElementsIncludingDeleted(),
 | 
			
		||||
              this.getAppState(),
 | 
			
		||||
            )
 | 
			
		||||
          : true,
 | 
			
		||||
      )
 | 
			
		||||
      .sort(
 | 
			
		||||
        (a, b) =>
 | 
			
		||||
          (a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
 | 
			
		||||
          (b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
 | 
			
		||||
      )
 | 
			
		||||
      .map((action) => ({
 | 
			
		||||
        // take last bit of the label  "labels.<shortcutName>"
 | 
			
		||||
        shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
 | 
			
		||||
        label: action.contextItemLabel ? t(action.contextItemLabel) : "",
 | 
			
		||||
        action: () => {
 | 
			
		||||
          this.updater(
 | 
			
		||||
            action.perform(
 | 
			
		||||
              this.getElementsIncludingDeleted(),
 | 
			
		||||
              this.getAppState(),
 | 
			
		||||
              null,
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      }));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Id is an attribute that we can use to pass in data like keys.
 | 
			
		||||
  // This is needed for dynamically generated action components
 | 
			
		||||
  // like the user list. We can use this key to extract more
 | 
			
		||||
@@ -132,6 +104,7 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
            this.getElementsIncludingDeleted(),
 | 
			
		||||
            this.getAppState(),
 | 
			
		||||
            formState,
 | 
			
		||||
            this.app,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      };
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export type ShortcutName =
 | 
			
		||||
  | "copyStyles"
 | 
			
		||||
  | "pasteStyles"
 | 
			
		||||
  | "selectAll"
 | 
			
		||||
  | "delete"
 | 
			
		||||
  | "deleteSelectedElements"
 | 
			
		||||
  | "duplicateSelection"
 | 
			
		||||
  | "sendBackward"
 | 
			
		||||
  | "bringForward"
 | 
			
		||||
@@ -20,6 +20,7 @@ export type ShortcutName =
 | 
			
		||||
  | "group"
 | 
			
		||||
  | "ungroup"
 | 
			
		||||
  | "gridMode"
 | 
			
		||||
  | "zenMode"
 | 
			
		||||
  | "stats"
 | 
			
		||||
  | "addToLibrary";
 | 
			
		||||
 | 
			
		||||
@@ -30,10 +31,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
 | 
			
		||||
  pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
 | 
			
		||||
  selectAll: [getShortcutKey("CtrlOrCmd+A")],
 | 
			
		||||
  delete: [getShortcutKey("Del")],
 | 
			
		||||
  deleteSelectedElements: [getShortcutKey("Del")],
 | 
			
		||||
  duplicateSelection: [
 | 
			
		||||
    getShortcutKey("CtrlOrCmd+D"),
 | 
			
		||||
    getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
 | 
			
		||||
    getShortcutKey(`Alt+${t("helpDialog.drag")}`),
 | 
			
		||||
  ],
 | 
			
		||||
  sendBackward: [getShortcutKey("CtrlOrCmd+[")],
 | 
			
		||||
  bringForward: [getShortcutKey("CtrlOrCmd+]")],
 | 
			
		||||
@@ -52,6 +53,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  group: [getShortcutKey("CtrlOrCmd+G")],
 | 
			
		||||
  ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
 | 
			
		||||
  gridMode: [getShortcutKey("CtrlOrCmd+'")],
 | 
			
		||||
  zenMode: [getShortcutKey("Alt+Z")],
 | 
			
		||||
  stats: [],
 | 
			
		||||
  addToLibrary: [],
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -16,12 +16,18 @@ type ActionFn = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  formData: any,
 | 
			
		||||
  app: { canvas: HTMLCanvasElement | null },
 | 
			
		||||
) => ActionResult | Promise<ActionResult>;
 | 
			
		||||
 | 
			
		||||
export type UpdaterFn = (res: ActionResult) => void;
 | 
			
		||||
export type ActionFilterFn = (action: Action) => void;
 | 
			
		||||
 | 
			
		||||
export type ActionName =
 | 
			
		||||
  | "copy"
 | 
			
		||||
  | "cut"
 | 
			
		||||
  | "paste"
 | 
			
		||||
  | "copyAsPng"
 | 
			
		||||
  | "copyAsSvg"
 | 
			
		||||
  | "sendBackward"
 | 
			
		||||
  | "bringForward"
 | 
			
		||||
  | "sendToBack"
 | 
			
		||||
@@ -29,6 +35,9 @@ export type ActionName =
 | 
			
		||||
  | "copyStyles"
 | 
			
		||||
  | "selectAll"
 | 
			
		||||
  | "pasteStyles"
 | 
			
		||||
  | "gridMode"
 | 
			
		||||
  | "zenMode"
 | 
			
		||||
  | "stats"
 | 
			
		||||
  | "changeStrokeColor"
 | 
			
		||||
  | "changeBackgroundColor"
 | 
			
		||||
  | "changeFillStyle"
 | 
			
		||||
@@ -93,19 +102,16 @@ export interface Action {
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  contextItemLabel?: string;
 | 
			
		||||
  contextMenuOrder?: number;
 | 
			
		||||
  contextItemPredicate?: (
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  checked?: (appState: Readonly<AppState>) => boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ActionsManagerInterface {
 | 
			
		||||
  actions: Record<ActionName, Action>;
 | 
			
		||||
  registerAction: (action: Action) => void;
 | 
			
		||||
  handleKeyDown: (event: KeyboardEvent) => boolean;
 | 
			
		||||
  getContextMenuItems: (
 | 
			
		||||
    actionFilter: ActionFilterFn,
 | 
			
		||||
  ) => { label: string; action: () => void }[];
 | 
			
		||||
  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,8 @@
 | 
			
		||||
export const EVENT_ACTION = "action";
 | 
			
		||||
export const EVENT_ALIGN = "align";
 | 
			
		||||
export const EVENT_CHANGE = "change";
 | 
			
		||||
export const EVENT_DIALOG = "dialog";
 | 
			
		||||
export const EVENT_EXIT = "exit";
 | 
			
		||||
export const EVENT_IO = "io";
 | 
			
		||||
export const EVENT_LAYER = "layer";
 | 
			
		||||
export const EVENT_LIBRARY = "library";
 | 
			
		||||
export const EVENT_LOAD = "load";
 | 
			
		||||
export const EVENT_SHAPE = "shape";
 | 
			
		||||
export const EVENT_SHARE = "share";
 | 
			
		||||
export const EVENT_MAGIC = "magic";
 | 
			
		||||
 | 
			
		||||
export const trackEvent =
 | 
			
		||||
  typeof window !== "undefined" && window.gtag
 | 
			
		||||
  typeof process !== "undefined" &&
 | 
			
		||||
  process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
 | 
			
		||||
  typeof window !== "undefined" &&
 | 
			
		||||
  window.gtag
 | 
			
		||||
    ? (category: string, name: string, label?: string, value?: number) => {
 | 
			
		||||
        window.gtag("event", name, {
 | 
			
		||||
          event_category: category,
 | 
			
		||||
@@ -20,8 +10,9 @@ export const trackEvent =
 | 
			
		||||
          value,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    : typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
 | 
			
		||||
    : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
 | 
			
		||||
    ? (category: string, name: string, label?: string, value?: number) => {}
 | 
			
		||||
    : (category: string, name: string, label?: string, value?: number) => {
 | 
			
		||||
        console.info("Track Event", category, name, label, value);
 | 
			
		||||
        // Uncomment the next line to track locally
 | 
			
		||||
        // console.info("Track Event", category, name, label, value);
 | 
			
		||||
      };
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										129
									
								
								src/appState.ts
									
									
									
									
									
								
							
							
						
						@@ -1,12 +1,12 @@
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
 | 
			
		||||
import { getDateTime } from "./utils";
 | 
			
		||||
import { t } from "./i18n";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { t } from "./i18n";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "./types";
 | 
			
		||||
import { getDateTime } from "./utils";
 | 
			
		||||
 | 
			
		||||
export const getDefaultAppState = (): Omit<
 | 
			
		||||
  AppState,
 | 
			
		||||
@@ -14,66 +14,64 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
> => {
 | 
			
		||||
  return {
 | 
			
		||||
    appearance: "light",
 | 
			
		||||
    isLoading: false,
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
    collaborators: new Map(),
 | 
			
		||||
    currentChartType: "bar",
 | 
			
		||||
    currentItemBackgroundColor: "transparent",
 | 
			
		||||
    currentItemEndArrowhead: "arrow",
 | 
			
		||||
    currentItemFillStyle: "hachure",
 | 
			
		||||
    currentItemFontFamily: DEFAULT_FONT_FAMILY,
 | 
			
		||||
    currentItemFontSize: DEFAULT_FONT_SIZE,
 | 
			
		||||
    currentItemLinearStrokeSharpness: "round",
 | 
			
		||||
    currentItemOpacity: 100,
 | 
			
		||||
    currentItemRoughness: 1,
 | 
			
		||||
    currentItemStartArrowhead: null,
 | 
			
		||||
    currentItemStrokeColor: oc.black,
 | 
			
		||||
    currentItemStrokeSharpness: "sharp",
 | 
			
		||||
    currentItemStrokeStyle: "solid",
 | 
			
		||||
    currentItemStrokeWidth: 1,
 | 
			
		||||
    currentItemTextAlign: DEFAULT_TEXT_ALIGN,
 | 
			
		||||
    cursorButton: "up",
 | 
			
		||||
    draggingElement: null,
 | 
			
		||||
    resizingElement: null,
 | 
			
		||||
    multiElement: null,
 | 
			
		||||
    editingElement: null,
 | 
			
		||||
    startBoundElement: null,
 | 
			
		||||
    editingGroupId: null,
 | 
			
		||||
    editingLinearElement: null,
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    elementLocked: false,
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
    exportBackground: true,
 | 
			
		||||
    exportEmbedScene: false,
 | 
			
		||||
    shouldAddWatermark: false,
 | 
			
		||||
    currentItemStrokeColor: oc.black,
 | 
			
		||||
    currentItemBackgroundColor: "transparent",
 | 
			
		||||
    currentItemFillStyle: "hachure",
 | 
			
		||||
    currentItemStrokeWidth: 1,
 | 
			
		||||
    currentItemStrokeStyle: "solid",
 | 
			
		||||
    currentItemRoughness: 1,
 | 
			
		||||
    currentItemOpacity: 100,
 | 
			
		||||
    currentItemFontSize: DEFAULT_FONT_SIZE,
 | 
			
		||||
    currentItemFontFamily: DEFAULT_FONT_FAMILY,
 | 
			
		||||
    currentItemTextAlign: DEFAULT_TEXT_ALIGN,
 | 
			
		||||
    currentItemStrokeSharpness: "sharp",
 | 
			
		||||
    currentItemLinearStrokeSharpness: "round",
 | 
			
		||||
    currentItemStartArrowhead: null,
 | 
			
		||||
    currentItemEndArrowhead: "arrow",
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    scrollX: 0 as FlooredNumber,
 | 
			
		||||
    scrollY: 0 as FlooredNumber,
 | 
			
		||||
    cursorX: 0,
 | 
			
		||||
    cursorY: 0,
 | 
			
		||||
    cursorButton: "up",
 | 
			
		||||
    scrolledOutside: false,
 | 
			
		||||
    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
			
		||||
    fileHandle: null,
 | 
			
		||||
    gridSize: null,
 | 
			
		||||
    height: window.innerHeight,
 | 
			
		||||
    isBindingEnabled: true,
 | 
			
		||||
    isLibraryOpen: false,
 | 
			
		||||
    isLoading: false,
 | 
			
		||||
    isResizing: false,
 | 
			
		||||
    isRotating: false,
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    zoom: {
 | 
			
		||||
      value: 1 as NormalizedZoomValue,
 | 
			
		||||
      translation: { x: 0, y: 0 },
 | 
			
		||||
    },
 | 
			
		||||
    openMenu: null,
 | 
			
		||||
    lastPointerDownWith: "mouse",
 | 
			
		||||
    selectedElementIds: {},
 | 
			
		||||
    multiElement: null,
 | 
			
		||||
    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
			
		||||
    openMenu: null,
 | 
			
		||||
    pasteDialog: { shown: false, data: null },
 | 
			
		||||
    previousSelectedElementIds: {},
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showShortcutsDialog: false,
 | 
			
		||||
    suggestedBindings: [],
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    showGrid: false,
 | 
			
		||||
    editingGroupId: null,
 | 
			
		||||
    resizingElement: null,
 | 
			
		||||
    scrolledOutside: false,
 | 
			
		||||
    scrollX: 0,
 | 
			
		||||
    scrollY: 0,
 | 
			
		||||
    selectedElementIds: {},
 | 
			
		||||
    selectedGroupIds: {},
 | 
			
		||||
    width: window.innerWidth,
 | 
			
		||||
    height: window.innerHeight,
 | 
			
		||||
    isLibraryOpen: false,
 | 
			
		||||
    fileHandle: null,
 | 
			
		||||
    collaborators: new Map(),
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    shouldAddWatermark: false,
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showHelpDialog: false,
 | 
			
		||||
    showStats: false,
 | 
			
		||||
    startBoundElement: null,
 | 
			
		||||
    suggestedBindings: [],
 | 
			
		||||
    toastMessage: null,
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    width: window.innerWidth,
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -93,26 +91,25 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
 | 
			
		||||
) => config)({
 | 
			
		||||
  appearance: { browser: true, export: false },
 | 
			
		||||
  collaborators: { browser: false, export: false },
 | 
			
		||||
  currentChartType: { browser: true, export: false },
 | 
			
		||||
  currentItemBackgroundColor: { browser: true, export: false },
 | 
			
		||||
  currentItemEndArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemFillStyle: { browser: true, export: false },
 | 
			
		||||
  currentItemFontFamily: { browser: true, export: false },
 | 
			
		||||
  currentItemFontSize: { browser: true, export: false },
 | 
			
		||||
  currentItemLinearStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemOpacity: { browser: true, export: false },
 | 
			
		||||
  currentItemRoughness: { browser: true, export: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeColor: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeStyle: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeWidth: { browser: true, export: false },
 | 
			
		||||
  currentItemTextAlign: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemLinearStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemEndArrowhead: { browser: true, export: false },
 | 
			
		||||
  cursorButton: { browser: true, export: false },
 | 
			
		||||
  cursorX: { browser: true, export: false },
 | 
			
		||||
  cursorY: { browser: true, export: false },
 | 
			
		||||
  draggingElement: { browser: false, export: false },
 | 
			
		||||
  editingElement: { browser: false, export: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false },
 | 
			
		||||
  elementLocked: { browser: true, export: false },
 | 
			
		||||
@@ -120,7 +117,8 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  errorMessage: { browser: false, export: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false },
 | 
			
		||||
  showGrid: { browser: true, export: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false },
 | 
			
		||||
  gridSize: { browser: true, export: true },
 | 
			
		||||
  height: { browser: false, export: false },
 | 
			
		||||
  isBindingEnabled: { browser: false, export: false },
 | 
			
		||||
  isLibraryOpen: { browser: false, export: false },
 | 
			
		||||
@@ -130,7 +128,10 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  lastPointerDownWith: { browser: true, export: false },
 | 
			
		||||
  multiElement: { browser: false, export: false },
 | 
			
		||||
  name: { browser: true, export: false },
 | 
			
		||||
  offsetLeft: { browser: false, export: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false },
 | 
			
		||||
  openMenu: { browser: true, export: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false },
 | 
			
		||||
  scrolledOutside: { browser: true, export: false },
 | 
			
		||||
@@ -141,17 +142,15 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  selectionElement: { browser: false, export: false },
 | 
			
		||||
  shouldAddWatermark: { browser: true, export: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false },
 | 
			
		||||
  showShortcutsDialog: { browser: false, export: false },
 | 
			
		||||
  showHelpDialog: { browser: false, export: false },
 | 
			
		||||
  showStats: { browser: true, export: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false },
 | 
			
		||||
  toastMessage: { browser: false, export: false },
 | 
			
		||||
  viewBackgroundColor: { browser: true, export: true },
 | 
			
		||||
  width: { browser: false, export: false },
 | 
			
		||||
  zenModeEnabled: { browser: true, export: false },
 | 
			
		||||
  zoom: { browser: true, export: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false },
 | 
			
		||||
  offsetLeft: { browser: false, export: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false },
 | 
			
		||||
  collaborators: { browser: false, export: false },
 | 
			
		||||
  showStats: { browser: true, export: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										426
									
								
								src/charts.ts
									
									
									
									
									
								
							
							
						
						@@ -1,13 +1,15 @@
 | 
			
		||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
 | 
			
		||||
import colors from "./colors";
 | 
			
		||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
 | 
			
		||||
import { newElement, newTextElement, newLinearElement } from "./element";
 | 
			
		||||
import { ExcalidrawElement } from "./element/types";
 | 
			
		||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
 | 
			
		||||
import { newElement, newLinearElement, newTextElement } from "./element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "./element/types";
 | 
			
		||||
import { randomId } from "./random";
 | 
			
		||||
 | 
			
		||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
 | 
			
		||||
const BAR_WIDTH = 32;
 | 
			
		||||
const BAR_GAP = 12;
 | 
			
		||||
const BAR_HEIGHT = 256;
 | 
			
		||||
const GRID_OPACITY = 50;
 | 
			
		||||
 | 
			
		||||
export interface Spreadsheet {
 | 
			
		||||
  title: string | null;
 | 
			
		||||
@@ -139,114 +141,48 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
 | 
			
		||||
      return transposedResults;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
 | 
			
		||||
export const renderSpreadsheet = (
 | 
			
		||||
const bgColors = colors.elementBackground.slice(
 | 
			
		||||
  2,
 | 
			
		||||
  colors.elementBackground.length,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Put all the common properties here so when the whole chart is selected
 | 
			
		||||
// the properties dialog shows the correct selected values
 | 
			
		||||
const commonProps = {
 | 
			
		||||
  fillStyle: "hachure",
 | 
			
		||||
  fontFamily: DEFAULT_FONT_FAMILY,
 | 
			
		||||
  fontSize: DEFAULT_FONT_SIZE,
 | 
			
		||||
  opacity: 100,
 | 
			
		||||
  roughness: 1,
 | 
			
		||||
  strokeColor: colors.elementStroke[0],
 | 
			
		||||
  strokeSharpness: "sharp",
 | 
			
		||||
  strokeStyle: "solid",
 | 
			
		||||
  strokeWidth: 1,
 | 
			
		||||
  verticalAlign: "middle",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
 | 
			
		||||
  const chartWidth =
 | 
			
		||||
    (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
 | 
			
		||||
  const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
 | 
			
		||||
  return { chartWidth, chartHeight };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const chartXLabels = (
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
): ExcalidrawElement[] => {
 | 
			
		||||
  const values = spreadsheet.values;
 | 
			
		||||
  const max = Math.max(...values);
 | 
			
		||||
  const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
 | 
			
		||||
  const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
 | 
			
		||||
  const maxColors = colors.elementBackground.length;
 | 
			
		||||
  const bgColors = colors.elementBackground.slice(2, maxColors);
 | 
			
		||||
 | 
			
		||||
  // Put all the common properties here so when the whole chart is selected
 | 
			
		||||
  // the properties dialog shows the correct selected values
 | 
			
		||||
  const commonProps = {
 | 
			
		||||
    backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
 | 
			
		||||
    fillStyle: "hachure",
 | 
			
		||||
    fontFamily: DEFAULT_FONT_FAMILY,
 | 
			
		||||
    fontSize: DEFAULT_FONT_SIZE,
 | 
			
		||||
    groupIds: [randomId()],
 | 
			
		||||
    opacity: 100,
 | 
			
		||||
    roughness: 1,
 | 
			
		||||
    strokeColor: colors.elementStroke[0],
 | 
			
		||||
    strokeSharpness: "sharp",
 | 
			
		||||
    strokeStyle: "solid",
 | 
			
		||||
    strokeWidth: 1,
 | 
			
		||||
    verticalAlign: "middle",
 | 
			
		||||
  } as const;
 | 
			
		||||
 | 
			
		||||
  const minYLabel = newTextElement({
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    x: x - BAR_GAP,
 | 
			
		||||
    y: y - BAR_GAP,
 | 
			
		||||
    text: "0",
 | 
			
		||||
    textAlign: "right",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const maxYLabel = newTextElement({
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    x: x - BAR_GAP,
 | 
			
		||||
    y: y - BAR_HEIGHT - minYLabel.height / 2,
 | 
			
		||||
    text: max.toLocaleString(),
 | 
			
		||||
    textAlign: "right",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const xAxisLine = newLinearElement({
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    width: chartWidth,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
      [chartWidth, 0],
 | 
			
		||||
    ],
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const yAxisLine = newLinearElement({
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    height: chartHeight,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
      [0, -chartHeight],
 | 
			
		||||
    ],
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const maxValueLine = newLinearElement({
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y: y - BAR_HEIGHT - BAR_GAP,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    strokeStyle: "dotted",
 | 
			
		||||
    width: chartWidth,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
      [chartWidth, 0],
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const bars = values.map((value, index) => {
 | 
			
		||||
    const barHeight = (value / max) * BAR_HEIGHT;
 | 
			
		||||
    return newElement({
 | 
			
		||||
      ...commonProps,
 | 
			
		||||
      type: "rectangle",
 | 
			
		||||
      x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
 | 
			
		||||
      y: y - barHeight - BAR_GAP,
 | 
			
		||||
      width: BAR_WIDTH,
 | 
			
		||||
      height: barHeight,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const xLabels =
 | 
			
		||||
  groupId: string,
 | 
			
		||||
  backgroundColor: string,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  return (
 | 
			
		||||
    spreadsheet.labels?.map((label, index) => {
 | 
			
		||||
      return newTextElement({
 | 
			
		||||
        groupIds: [groupId],
 | 
			
		||||
        backgroundColor,
 | 
			
		||||
        ...commonProps,
 | 
			
		||||
        text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
 | 
			
		||||
        x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
 | 
			
		||||
@@ -257,29 +193,287 @@ export const renderSpreadsheet = (
 | 
			
		||||
        textAlign: "center",
 | 
			
		||||
        verticalAlign: "top",
 | 
			
		||||
      });
 | 
			
		||||
    }) || [];
 | 
			
		||||
    }) || []
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const chartYLabels = (
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
  groupId: string,
 | 
			
		||||
  backgroundColor: string,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  const minYLabel = newTextElement({
 | 
			
		||||
    groupIds: [groupId],
 | 
			
		||||
    backgroundColor,
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    x: x - BAR_GAP,
 | 
			
		||||
    y: y - BAR_GAP,
 | 
			
		||||
    text: "0",
 | 
			
		||||
    textAlign: "right",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const maxYLabel = newTextElement({
 | 
			
		||||
    groupIds: [groupId],
 | 
			
		||||
    backgroundColor,
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    x: x - BAR_GAP,
 | 
			
		||||
    y: y - BAR_HEIGHT - minYLabel.height / 2,
 | 
			
		||||
    text: Math.max(...spreadsheet.values).toLocaleString(),
 | 
			
		||||
    textAlign: "right",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return [minYLabel, maxYLabel];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const chartLines = (
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
  groupId: string,
 | 
			
		||||
  backgroundColor: string,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
 | 
			
		||||
  const xLine = newLinearElement({
 | 
			
		||||
    backgroundColor,
 | 
			
		||||
    groupIds: [groupId],
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    width: chartWidth,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
      [chartWidth, 0],
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const yLine = newLinearElement({
 | 
			
		||||
    backgroundColor,
 | 
			
		||||
    groupIds: [groupId],
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    height: chartHeight,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
      [0, -chartHeight],
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const maxLine = newLinearElement({
 | 
			
		||||
    backgroundColor,
 | 
			
		||||
    groupIds: [groupId],
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y: y - BAR_HEIGHT - BAR_GAP,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    strokeStyle: "dotted",
 | 
			
		||||
    width: chartWidth,
 | 
			
		||||
    opacity: GRID_OPACITY,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
      [chartWidth, 0],
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return [xLine, yLine, maxLine];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
 | 
			
		||||
const chartBaseElements = (
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
  groupId: string,
 | 
			
		||||
  backgroundColor: string,
 | 
			
		||||
  debug?: boolean,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
 | 
			
		||||
 | 
			
		||||
  const title = spreadsheet.title
 | 
			
		||||
    ? newTextElement({
 | 
			
		||||
        backgroundColor,
 | 
			
		||||
        groupIds: [groupId],
 | 
			
		||||
        ...commonProps,
 | 
			
		||||
        text: spreadsheet.title,
 | 
			
		||||
        x: x + chartWidth / 2,
 | 
			
		||||
        y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
 | 
			
		||||
        y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
 | 
			
		||||
        strokeSharpness: "sharp",
 | 
			
		||||
        strokeStyle: "solid",
 | 
			
		||||
        textAlign: "center",
 | 
			
		||||
      })
 | 
			
		||||
    : null;
 | 
			
		||||
 | 
			
		||||
  trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
 | 
			
		||||
  const debugRect = debug
 | 
			
		||||
    ? newElement({
 | 
			
		||||
        backgroundColor,
 | 
			
		||||
        groupIds: [groupId],
 | 
			
		||||
        ...commonProps,
 | 
			
		||||
        type: "rectangle",
 | 
			
		||||
        x,
 | 
			
		||||
        y: y - chartHeight,
 | 
			
		||||
        width: chartWidth,
 | 
			
		||||
        height: chartHeight,
 | 
			
		||||
        strokeColor: colors.elementStroke[0],
 | 
			
		||||
        fillStyle: "solid",
 | 
			
		||||
        opacity: 6,
 | 
			
		||||
      })
 | 
			
		||||
    : null;
 | 
			
		||||
 | 
			
		||||
  return [
 | 
			
		||||
    title,
 | 
			
		||||
    ...bars,
 | 
			
		||||
    ...xLabels,
 | 
			
		||||
    xAxisLine,
 | 
			
		||||
    yAxisLine,
 | 
			
		||||
    maxValueLine,
 | 
			
		||||
    minYLabel,
 | 
			
		||||
    maxYLabel,
 | 
			
		||||
  ].filter((element) => element !== null) as ExcalidrawElement[];
 | 
			
		||||
    ...(debugRect ? [debugRect] : []),
 | 
			
		||||
    ...(title ? [title] : []),
 | 
			
		||||
    ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
 | 
			
		||||
    ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
 | 
			
		||||
    ...chartLines(spreadsheet, x, y, groupId, backgroundColor),
 | 
			
		||||
  ];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const chartTypeBar = (
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  const max = Math.max(...spreadsheet.values);
 | 
			
		||||
  const groupId = randomId();
 | 
			
		||||
  const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
 | 
			
		||||
 | 
			
		||||
  const bars = spreadsheet.values.map((value, index) => {
 | 
			
		||||
    const barHeight = (value / max) * BAR_HEIGHT;
 | 
			
		||||
    return newElement({
 | 
			
		||||
      backgroundColor,
 | 
			
		||||
      groupIds: [groupId],
 | 
			
		||||
      ...commonProps,
 | 
			
		||||
      type: "rectangle",
 | 
			
		||||
      x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
 | 
			
		||||
      y: y - barHeight - BAR_GAP,
 | 
			
		||||
      width: BAR_WIDTH,
 | 
			
		||||
      height: barHeight,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return [
 | 
			
		||||
    ...bars,
 | 
			
		||||
    ...chartBaseElements(
 | 
			
		||||
      spreadsheet,
 | 
			
		||||
      x,
 | 
			
		||||
      y,
 | 
			
		||||
      groupId,
 | 
			
		||||
      backgroundColor,
 | 
			
		||||
      process.env.NODE_ENV === ENV.DEVELOPMENT,
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const chartTypeLine = (
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  const max = Math.max(...spreadsheet.values);
 | 
			
		||||
  const groupId = randomId();
 | 
			
		||||
  const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
 | 
			
		||||
 | 
			
		||||
  let index = 0;
 | 
			
		||||
  const points = [];
 | 
			
		||||
  for (const value of spreadsheet.values) {
 | 
			
		||||
    const cx = index * (BAR_WIDTH + BAR_GAP);
 | 
			
		||||
    const cy = -(value / max) * BAR_HEIGHT;
 | 
			
		||||
    points.push([cx, cy]);
 | 
			
		||||
    index++;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const maxX = Math.max(...points.map((element) => element[0]));
 | 
			
		||||
  const maxY = Math.max(...points.map((element) => element[1]));
 | 
			
		||||
  const minX = Math.min(...points.map((element) => element[0]));
 | 
			
		||||
  const minY = Math.min(...points.map((element) => element[1]));
 | 
			
		||||
 | 
			
		||||
  const line = newLinearElement({
 | 
			
		||||
    backgroundColor,
 | 
			
		||||
    groupIds: [groupId],
 | 
			
		||||
    ...commonProps,
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x: x + BAR_GAP + BAR_WIDTH / 2,
 | 
			
		||||
    y: y - BAR_GAP,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    height: maxY - minY,
 | 
			
		||||
    width: maxX - minX,
 | 
			
		||||
    strokeWidth: 2,
 | 
			
		||||
    points: points as any,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const dots = spreadsheet.values.map((value, index) => {
 | 
			
		||||
    const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
 | 
			
		||||
    const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
 | 
			
		||||
    return newElement({
 | 
			
		||||
      backgroundColor,
 | 
			
		||||
      groupIds: [groupId],
 | 
			
		||||
      ...commonProps,
 | 
			
		||||
      fillStyle: "solid",
 | 
			
		||||
      strokeWidth: 2,
 | 
			
		||||
      type: "ellipse",
 | 
			
		||||
      x: x + cx + BAR_WIDTH / 2,
 | 
			
		||||
      y: y + cy - BAR_GAP * 2,
 | 
			
		||||
      width: BAR_GAP,
 | 
			
		||||
      height: BAR_GAP,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const lines = spreadsheet.values.map((value, index) => {
 | 
			
		||||
    const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
 | 
			
		||||
    const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
 | 
			
		||||
    return newLinearElement({
 | 
			
		||||
      backgroundColor,
 | 
			
		||||
      groupIds: [groupId],
 | 
			
		||||
      ...commonProps,
 | 
			
		||||
      type: "line",
 | 
			
		||||
      x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
 | 
			
		||||
      y: y - cy,
 | 
			
		||||
      startArrowhead: null,
 | 
			
		||||
      endArrowhead: null,
 | 
			
		||||
      height: cy,
 | 
			
		||||
      strokeStyle: "dotted",
 | 
			
		||||
      opacity: GRID_OPACITY,
 | 
			
		||||
      points: [
 | 
			
		||||
        [0, 0],
 | 
			
		||||
        [0, cy],
 | 
			
		||||
      ],
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return [
 | 
			
		||||
    ...chartBaseElements(
 | 
			
		||||
      spreadsheet,
 | 
			
		||||
      x,
 | 
			
		||||
      y,
 | 
			
		||||
      groupId,
 | 
			
		||||
      backgroundColor,
 | 
			
		||||
      process.env.NODE_ENV === ENV.DEVELOPMENT,
 | 
			
		||||
    ),
 | 
			
		||||
    line,
 | 
			
		||||
    ...lines,
 | 
			
		||||
    ...dots,
 | 
			
		||||
  ];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const renderSpreadsheet = (
 | 
			
		||||
  chartType: string,
 | 
			
		||||
  spreadsheet: Spreadsheet,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
): ChartElements => {
 | 
			
		||||
  if (chartType === "line") {
 | 
			
		||||
    return chartTypeLine(spreadsheet, x, y);
 | 
			
		||||
  }
 | 
			
		||||
  return chartTypeBar(spreadsheet, x, y);
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,22 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AppState, Zoom } from "../types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import {
 | 
			
		||||
  hasBackground,
 | 
			
		||||
  hasStroke,
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  hasText,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  hasBackground,
 | 
			
		||||
  hasStroke,
 | 
			
		||||
  hasText,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { AppState, Zoom } from "../types";
 | 
			
		||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
export const SelectedShapeActions = ({
 | 
			
		||||
  appState,
 | 
			
		||||
@@ -164,9 +163,9 @@ export const ShapesSwitcher = ({
 | 
			
		||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
      const label = t(`toolBar.${value}`);
 | 
			
		||||
      const letter = typeof key === "string" ? key : key[0];
 | 
			
		||||
      const shortcut = `${capitalizeString(letter)} ${t(
 | 
			
		||||
        "shortcutsDialog.or",
 | 
			
		||||
      )} ${index + 1}`;
 | 
			
		||||
      const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
 | 
			
		||||
        index + 1
 | 
			
		||||
      }`;
 | 
			
		||||
      return (
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          className="Shape"
 | 
			
		||||
@@ -181,7 +180,6 @@ export const ShapesSwitcher = ({
 | 
			
		||||
          aria-keyshortcuts={shortcut}
 | 
			
		||||
          data-testid={value}
 | 
			
		||||
          onChange={() => {
 | 
			
		||||
            trackEvent(EVENT_SHAPE, value, "toolbar");
 | 
			
		||||
            setAppState({
 | 
			
		||||
              elementType: value,
 | 
			
		||||
              multiElement: null,
 | 
			
		||||
@@ -203,9 +201,6 @@ export const ShapesSwitcher = ({
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 9`}
 | 
			
		||||
      aria-label={capitalizeString(t("toolBar.library"))}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        if (!isLibraryOpen) {
 | 
			
		||||
          trackEvent(EVENT_DIALOG, "library");
 | 
			
		||||
        }
 | 
			
		||||
        setAppState({ isLibraryOpen: !isLibraryOpen });
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
 
 | 
			
		||||
@@ -1,182 +1,184 @@
 | 
			
		||||
import { Point, simplify } from "points-on-curve";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
import rough from "roughjs/bin/rough";
 | 
			
		||||
import { RoughCanvas } from "roughjs/bin/canvas";
 | 
			
		||||
import { simplify, Point } from "points-on-curve";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  newElement,
 | 
			
		||||
  newTextElement,
 | 
			
		||||
  duplicateElement,
 | 
			
		||||
  isInvisiblySmallElement,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
  textWysiwyg,
 | 
			
		||||
  getCommonBounds,
 | 
			
		||||
  getCursorForResizingElement,
 | 
			
		||||
  getPerfectElementSize,
 | 
			
		||||
  getNormalizedDimensions,
 | 
			
		||||
  newLinearElement,
 | 
			
		||||
  transformElements,
 | 
			
		||||
  getElementWithTransformHandleType,
 | 
			
		||||
  getResizeOffsetXY,
 | 
			
		||||
  getResizeArrowDirection,
 | 
			
		||||
  getTransformHandleTypeFromCoords,
 | 
			
		||||
  isNonDeletedElement,
 | 
			
		||||
  updateTextElement,
 | 
			
		||||
  dragSelectedElements,
 | 
			
		||||
  getDragOffsetXY,
 | 
			
		||||
  dragNewElement,
 | 
			
		||||
  hitTest,
 | 
			
		||||
  isHittingElementBoundingBoxWithoutHittingElement,
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import {
 | 
			
		||||
  getElementsWithinSelection,
 | 
			
		||||
  isOverScrollBars,
 | 
			
		||||
  getElementsAtPosition,
 | 
			
		||||
  getElementContainingPosition,
 | 
			
		||||
  getNormalizedZoom,
 | 
			
		||||
  getSelectedElements,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
  calculateScrollCenter,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { loadFromBlob, exportCanvas } from "../data";
 | 
			
		||||
 | 
			
		||||
import { renderScene } from "../renderer";
 | 
			
		||||
import {
 | 
			
		||||
  AppState,
 | 
			
		||||
  GestureEvent,
 | 
			
		||||
  Gesture,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  SceneData,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  NonDeleted,
 | 
			
		||||
  ExcalidrawGenericElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawBindableElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
 | 
			
		||||
import { distance2d, isPathALoop, getGridPoint } from "../math";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  isWritableElement,
 | 
			
		||||
  isInputLike,
 | 
			
		||||
  isToolIcon,
 | 
			
		||||
  debounce,
 | 
			
		||||
  distance,
 | 
			
		||||
  resetCursor,
 | 
			
		||||
  viewportCoordsToSceneCoords,
 | 
			
		||||
  sceneCoordsToViewportCoords,
 | 
			
		||||
  setCursorForShape,
 | 
			
		||||
  tupleToCoors,
 | 
			
		||||
  ResolvablePromise,
 | 
			
		||||
  resolvablePromise,
 | 
			
		||||
  withBatchedUpdates,
 | 
			
		||||
} from "../utils";
 | 
			
		||||
import {
 | 
			
		||||
  KEYS,
 | 
			
		||||
  isArrowKey,
 | 
			
		||||
  getResizeCenterPointKey,
 | 
			
		||||
  getResizeWithSidesSameLengthKey,
 | 
			
		||||
  getRotateWithDiscreteAngleKey,
 | 
			
		||||
  CODES,
 | 
			
		||||
} from "../keys";
 | 
			
		||||
 | 
			
		||||
import { findShapeByKey } from "../shapes";
 | 
			
		||||
import { createHistory, SceneHistory } from "../history";
 | 
			
		||||
 | 
			
		||||
import ContextMenu from "./ContextMenu";
 | 
			
		||||
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import rough from "roughjs/bin/rough";
 | 
			
		||||
import "../actions";
 | 
			
		||||
import {
 | 
			
		||||
  actionAddToLibrary,
 | 
			
		||||
  actionBringForward,
 | 
			
		||||
  actionBringToFront,
 | 
			
		||||
  actionCopy,
 | 
			
		||||
  actionCopyAsPng,
 | 
			
		||||
  actionCopyAsSvg,
 | 
			
		||||
  actionCopyStyles,
 | 
			
		||||
  actionCut,
 | 
			
		||||
  actionDeleteSelected,
 | 
			
		||||
  actionDuplicateSelection,
 | 
			
		||||
  actionFinalize,
 | 
			
		||||
  actionGroup,
 | 
			
		||||
  actionPasteStyles,
 | 
			
		||||
  actionSelectAll,
 | 
			
		||||
  actionSendBackward,
 | 
			
		||||
  actionSendToBack,
 | 
			
		||||
  actionToggleGridMode,
 | 
			
		||||
  actionToggleStats,
 | 
			
		||||
  actionToggleZenMode,
 | 
			
		||||
  actionUngroup,
 | 
			
		||||
} from "../actions";
 | 
			
		||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { actions } from "../actions/register";
 | 
			
		||||
 | 
			
		||||
import { ActionResult } from "../actions/types";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import { t, getLanguage } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  copyToClipboard,
 | 
			
		||||
  parseClipboard,
 | 
			
		||||
  probablySupportsClipboardBlob,
 | 
			
		||||
  probablySupportsClipboardWriteText,
 | 
			
		||||
} from "../clipboard";
 | 
			
		||||
import { normalizeScroll } from "../scene";
 | 
			
		||||
import { getCenter, getDistance } from "../gesture";
 | 
			
		||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  APP_NAME,
 | 
			
		||||
  CURSOR_TYPE,
 | 
			
		||||
  DEFAULT_VERTICAL_ALIGN,
 | 
			
		||||
  DRAGGING_THRESHOLD,
 | 
			
		||||
  ELEMENT_SHIFT_TRANSLATE_AMOUNT,
 | 
			
		||||
  ELEMENT_TRANSLATE_AMOUNT,
 | 
			
		||||
  POINTER_BUTTON,
 | 
			
		||||
  DRAGGING_THRESHOLD,
 | 
			
		||||
  TEXT_TO_CENTER_SNAP_THRESHOLD,
 | 
			
		||||
  LINE_CONFIRM_THRESHOLD,
 | 
			
		||||
  EVENT,
 | 
			
		||||
  ENV,
 | 
			
		||||
  CANVAS_ONLY_ACTIONS,
 | 
			
		||||
  DEFAULT_VERTICAL_ALIGN,
 | 
			
		||||
  GRID_SIZE,
 | 
			
		||||
  EVENT,
 | 
			
		||||
  LINE_CONFIRM_THRESHOLD,
 | 
			
		||||
  MIME_TYPES,
 | 
			
		||||
  POINTER_BUTTON,
 | 
			
		||||
  TAP_TWICE_TIMEOUT,
 | 
			
		||||
  TEXT_TO_CENTER_SNAP_THRESHOLD,
 | 
			
		||||
  TOUCH_CTX_MENU_TIMEOUT,
 | 
			
		||||
  APP_NAME,
 | 
			
		||||
  ZOOM_STEP,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
 | 
			
		||||
import LayerUI from "./LayerUI";
 | 
			
		||||
import { ScrollBars, SceneState } from "../scene/types";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { invalidateShapeForElement } from "../renderer/renderElement";
 | 
			
		||||
import {
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isLinearElementType,
 | 
			
		||||
  isBindingElement,
 | 
			
		||||
  isBindingElementType,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { actionFinalize, actionDeleteSelected } from "../actions";
 | 
			
		||||
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import {
 | 
			
		||||
  getSelectedGroupIds,
 | 
			
		||||
  isSelectedViaGroup,
 | 
			
		||||
  selectGroupsForSelectedElements,
 | 
			
		||||
  isElementInGroup,
 | 
			
		||||
  getSelectedGroupIdForElement,
 | 
			
		||||
  getElementsInGroup,
 | 
			
		||||
  editGroupForSelectedElement,
 | 
			
		||||
} from "../groups";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import Scene from "../scene/Scene";
 | 
			
		||||
import {
 | 
			
		||||
  getHoveredElementForBinding,
 | 
			
		||||
  maybeBindLinearElement,
 | 
			
		||||
  getEligibleElementsForBinding,
 | 
			
		||||
  bindOrUnbindSelectedElements,
 | 
			
		||||
  unbindLinearElements,
 | 
			
		||||
  fixBindingsAfterDuplication,
 | 
			
		||||
  fixBindingsAfterDeletion,
 | 
			
		||||
  isLinearElementSimpleAndAlreadyBound,
 | 
			
		||||
  isBindingEnabled,
 | 
			
		||||
  updateBoundElements,
 | 
			
		||||
  shouldEnableBindingForPointerEvent,
 | 
			
		||||
} from "../element/binding";
 | 
			
		||||
import { MaybeTransformHandleType } from "../element/transformHandles";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { renderSpreadsheet } from "../charts";
 | 
			
		||||
import { loadFromBlob } from "../data";
 | 
			
		||||
import { isValidLibrary } from "../data/json";
 | 
			
		||||
import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import { restore } from "../data/restore";
 | 
			
		||||
import {
 | 
			
		||||
  EVENT_DIALOG,
 | 
			
		||||
  EVENT_LIBRARY,
 | 
			
		||||
  EVENT_SHAPE,
 | 
			
		||||
  trackEvent,
 | 
			
		||||
} from "../analytics";
 | 
			
		||||
  dragNewElement,
 | 
			
		||||
  dragSelectedElements,
 | 
			
		||||
  duplicateElement,
 | 
			
		||||
  getCommonBounds,
 | 
			
		||||
  getCursorForResizingElement,
 | 
			
		||||
  getDragOffsetXY,
 | 
			
		||||
  getElementWithTransformHandleType,
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
  getNormalizedDimensions,
 | 
			
		||||
  getPerfectElementSize,
 | 
			
		||||
  getResizeArrowDirection,
 | 
			
		||||
  getResizeOffsetXY,
 | 
			
		||||
  getTransformHandleTypeFromCoords,
 | 
			
		||||
  hitTest,
 | 
			
		||||
  isHittingElementBoundingBoxWithoutHittingElement,
 | 
			
		||||
  isInvisiblySmallElement,
 | 
			
		||||
  isNonDeletedElement,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
  newElement,
 | 
			
		||||
  newLinearElement,
 | 
			
		||||
  newTextElement,
 | 
			
		||||
  textWysiwyg,
 | 
			
		||||
  transformElements,
 | 
			
		||||
  updateTextElement,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import {
 | 
			
		||||
  bindOrUnbindSelectedElements,
 | 
			
		||||
  fixBindingsAfterDeletion,
 | 
			
		||||
  fixBindingsAfterDuplication,
 | 
			
		||||
  getEligibleElementsForBinding,
 | 
			
		||||
  getHoveredElementForBinding,
 | 
			
		||||
  isBindingEnabled,
 | 
			
		||||
  isLinearElementSimpleAndAlreadyBound,
 | 
			
		||||
  maybeBindLinearElement,
 | 
			
		||||
  shouldEnableBindingForPointerEvent,
 | 
			
		||||
  unbindLinearElements,
 | 
			
		||||
  updateBoundElements,
 | 
			
		||||
} from "../element/binding";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { MaybeTransformHandleType } from "../element/transformHandles";
 | 
			
		||||
import {
 | 
			
		||||
  isBindingElement,
 | 
			
		||||
  isBindingElementType,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isLinearElementType,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawBindableElement,
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawGenericElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  NonDeleted,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getCenter, getDistance } from "../gesture";
 | 
			
		||||
import {
 | 
			
		||||
  editGroupForSelectedElement,
 | 
			
		||||
  getElementsInGroup,
 | 
			
		||||
  getSelectedGroupIdForElement,
 | 
			
		||||
  getSelectedGroupIds,
 | 
			
		||||
  isElementInGroup,
 | 
			
		||||
  isSelectedViaGroup,
 | 
			
		||||
  selectGroupsForSelectedElements,
 | 
			
		||||
} from "../groups";
 | 
			
		||||
import { createHistory, SceneHistory } from "../history";
 | 
			
		||||
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
  CODES,
 | 
			
		||||
  getResizeCenterPointKey,
 | 
			
		||||
  getResizeWithSidesSameLengthKey,
 | 
			
		||||
  getRotateWithDiscreteAngleKey,
 | 
			
		||||
  isArrowKey,
 | 
			
		||||
  KEYS,
 | 
			
		||||
} from "../keys";
 | 
			
		||||
import { distance2d, getGridPoint, isPathALoop } from "../math";
 | 
			
		||||
import { renderScene } from "../renderer";
 | 
			
		||||
import { invalidateShapeForElement } from "../renderer/renderElement";
 | 
			
		||||
import {
 | 
			
		||||
  calculateScrollCenter,
 | 
			
		||||
  getElementContainingPosition,
 | 
			
		||||
  getElementsAtPosition,
 | 
			
		||||
  getElementsWithinSelection,
 | 
			
		||||
  getNormalizedZoom,
 | 
			
		||||
  getSelectedElements,
 | 
			
		||||
  isOverScrollBars,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import Scene from "../scene/Scene";
 | 
			
		||||
import { SceneState, ScrollBars } from "../scene/types";
 | 
			
		||||
import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { findShapeByKey } from "../shapes";
 | 
			
		||||
import {
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  Gesture,
 | 
			
		||||
  GestureEvent,
 | 
			
		||||
  SceneData,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  debounce,
 | 
			
		||||
  distance,
 | 
			
		||||
  isInputLike,
 | 
			
		||||
  isToolIcon,
 | 
			
		||||
  isWritableElement,
 | 
			
		||||
  resetCursor,
 | 
			
		||||
  ResolvablePromise,
 | 
			
		||||
  resolvablePromise,
 | 
			
		||||
  sceneCoordsToViewportCoords,
 | 
			
		||||
  setCursorForShape,
 | 
			
		||||
  tupleToCoors,
 | 
			
		||||
  viewportCoordsToSceneCoords,
 | 
			
		||||
  withBatchedUpdates,
 | 
			
		||||
} from "../utils";
 | 
			
		||||
import { isMobile } from "../is-mobile";
 | 
			
		||||
import ContextMenu from "./ContextMenu";
 | 
			
		||||
import LayerUI from "./LayerUI";
 | 
			
		||||
import { Stats } from "./Stats";
 | 
			
		||||
import { Toast } from "./Toast";
 | 
			
		||||
 | 
			
		||||
const { history } = createHistory();
 | 
			
		||||
 | 
			
		||||
@@ -266,6 +268,7 @@ export type ExcalidrawImperativeAPI = {
 | 
			
		||||
  };
 | 
			
		||||
  setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
 | 
			
		||||
  getSceneElements: InstanceType<typeof App>["getSceneElements"];
 | 
			
		||||
  getAppState: () => InstanceType<typeof App>["state"];
 | 
			
		||||
  readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
 | 
			
		||||
  ready: true;
 | 
			
		||||
};
 | 
			
		||||
@@ -316,6 +319,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
        },
 | 
			
		||||
        setScrollToCenter: this.setScrollToCenter,
 | 
			
		||||
        getSceneElements: this.getSceneElements,
 | 
			
		||||
        getAppState: () => this.state,
 | 
			
		||||
      } as const;
 | 
			
		||||
      if (typeof excalidrawRef === "function") {
 | 
			
		||||
        excalidrawRef(api);
 | 
			
		||||
@@ -330,6 +334,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      this.syncActionResult,
 | 
			
		||||
      () => this.state,
 | 
			
		||||
      () => this.scene.getElementsIncludingDeleted(),
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
    this.actionManager.registerAll(actions);
 | 
			
		||||
 | 
			
		||||
@@ -346,7 +351,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      offsetLeft,
 | 
			
		||||
    } = this.state;
 | 
			
		||||
 | 
			
		||||
    const { onCollabButtonClick, onExportToBackend } = this.props;
 | 
			
		||||
    const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
 | 
			
		||||
    const canvasScale = window.devicePixelRatio;
 | 
			
		||||
 | 
			
		||||
    const canvasWidth = canvasDOMWidth * canvasScale;
 | 
			
		||||
@@ -374,7 +379,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
          elements={this.scene.getElements()}
 | 
			
		||||
          onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
          onLockToggle={this.toggleLock}
 | 
			
		||||
          onInsertShape={(elements) =>
 | 
			
		||||
          onInsertElements={(elements) =>
 | 
			
		||||
            this.addElementsFromPasteOrLibrary(
 | 
			
		||||
              elements,
 | 
			
		||||
              DEFAULT_PASTE_X,
 | 
			
		||||
@@ -383,9 +388,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
          }
 | 
			
		||||
          zenModeEnabled={zenModeEnabled}
 | 
			
		||||
          toggleZenMode={this.toggleZenMode}
 | 
			
		||||
          lng={getLanguage().lng}
 | 
			
		||||
          langCode={getLanguage().code}
 | 
			
		||||
          isCollaborating={this.props.isCollaborating || false}
 | 
			
		||||
          onExportToBackend={onExportToBackend}
 | 
			
		||||
          renderCustomFooter={renderFooter}
 | 
			
		||||
        />
 | 
			
		||||
        {this.state.showStats && (
 | 
			
		||||
          <Stats
 | 
			
		||||
@@ -394,6 +400,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
            onClose={this.toggleStats}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {this.state.toastMessage !== null && (
 | 
			
		||||
          <Toast
 | 
			
		||||
            message={this.state.toastMessage}
 | 
			
		||||
            clearToast={this.clearToast}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <main>
 | 
			
		||||
          <canvas
 | 
			
		||||
            id="canvas"
 | 
			
		||||
@@ -517,7 +529,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
        )
 | 
			
		||||
      ) {
 | 
			
		||||
        await Library.importLibrary(blob);
 | 
			
		||||
        trackEvent(EVENT_LIBRARY, "import");
 | 
			
		||||
        this.setState({
 | 
			
		||||
          isLibraryOpen: true,
 | 
			
		||||
        });
 | 
			
		||||
@@ -752,6 +763,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
 | 
			
		||||
    if (prevProps.langCode !== this.props.langCode) {
 | 
			
		||||
      this.updateLanguage();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      prevProps.width !== this.props.width ||
 | 
			
		||||
      prevProps.height !== this.props.height ||
 | 
			
		||||
@@ -875,7 +890,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
 | 
			
		||||
    history.record(this.state, this.scene.getElementsIncludingDeleted());
 | 
			
		||||
 | 
			
		||||
    this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
 | 
			
		||||
    // Do not notify consumers if we're still loading the scene. Among other
 | 
			
		||||
    // potential issues, this fixes a case where the tab isn't focused during
 | 
			
		||||
    // init, which would trigger onChange with empty elements, which would then
 | 
			
		||||
    // override whatever is in localStorage currently.
 | 
			
		||||
    if (!this.state.isLoading) {
 | 
			
		||||
      this.props.onChange?.(
 | 
			
		||||
        this.scene.getElementsIncludingDeleted(),
 | 
			
		||||
        this.state,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Copy/paste
 | 
			
		||||
@@ -905,43 +929,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    copyToClipboard(this.scene.getElements(), this.state);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private copyToClipboardAsPng = async () => {
 | 
			
		||||
    const elements = this.scene.getElements();
 | 
			
		||||
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, this.state);
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
        "clipboard",
 | 
			
		||||
        selectedElements.length ? selectedElements : elements,
 | 
			
		||||
        this.state,
 | 
			
		||||
        this.canvas!,
 | 
			
		||||
        this.state,
 | 
			
		||||
      );
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      this.setState({ errorMessage: error.message });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private copyToClipboardAsSvg = async () => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      this.scene.getElements(),
 | 
			
		||||
      this.state,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
        "clipboard-svg",
 | 
			
		||||
        selectedElements.length ? selectedElements : this.scene.getElements(),
 | 
			
		||||
        this.state,
 | 
			
		||||
        this.canvas!,
 | 
			
		||||
        this.state,
 | 
			
		||||
      );
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      this.setState({ errorMessage: error.message });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private static resetTapTwice() {
 | 
			
		||||
    didTapTwice = false;
 | 
			
		||||
  }
 | 
			
		||||
@@ -1004,9 +991,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      if (data.errorMessage) {
 | 
			
		||||
        this.setState({ errorMessage: data.errorMessage });
 | 
			
		||||
      } else if (data.spreadsheet) {
 | 
			
		||||
        this.addElementsFromPasteOrLibrary(
 | 
			
		||||
          renderSpreadsheet(data.spreadsheet, cursorX, cursorY),
 | 
			
		||||
        );
 | 
			
		||||
        this.setState({
 | 
			
		||||
          pasteDialog: {
 | 
			
		||||
            data: data.spreadsheet,
 | 
			
		||||
            shown: true,
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
      } else if (data.elements) {
 | 
			
		||||
        this.addElementsFromPasteOrLibrary(data.elements);
 | 
			
		||||
      } else if (data.text) {
 | 
			
		||||
@@ -1036,7 +1026,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    const dy = y - elementsCenterY;
 | 
			
		||||
    const groupIdMap = new Map();
 | 
			
		||||
 | 
			
		||||
    const [gridX, gridY] = getGridPoint(dx, dy, this.state.showGrid);
 | 
			
		||||
    const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
 | 
			
		||||
 | 
			
		||||
    const oldIdToDuplicatedId = new Map();
 | 
			
		||||
    const newElements = clipboardElements.map((element) => {
 | 
			
		||||
@@ -1131,7 +1121,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
 | 
			
		||||
  toggleLock = () => {
 | 
			
		||||
    this.setState((prevState) => {
 | 
			
		||||
      trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
 | 
			
		||||
      return {
 | 
			
		||||
        elementLocked: !prevState.elementLocked,
 | 
			
		||||
        elementType: prevState.elementLocked
 | 
			
		||||
@@ -1142,24 +1131,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  toggleZenMode = () => {
 | 
			
		||||
    this.setState({
 | 
			
		||||
      zenModeEnabled: !this.state.zenModeEnabled,
 | 
			
		||||
    });
 | 
			
		||||
    this.actionManager.executeAction(actionToggleZenMode);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  toggleGridMode = () => {
 | 
			
		||||
    this.setState({
 | 
			
		||||
      showGrid: !this.state.showGrid,
 | 
			
		||||
    });
 | 
			
		||||
    this.actionManager.executeAction(actionToggleGridMode);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  toggleStats = () => {
 | 
			
		||||
    if (!this.state.showStats) {
 | 
			
		||||
      trackEvent(EVENT_DIALOG, "stats");
 | 
			
		||||
      trackEvent("dialog", "stats");
 | 
			
		||||
    }
 | 
			
		||||
    this.setState({
 | 
			
		||||
      showStats: !this.state.showStats,
 | 
			
		||||
    });
 | 
			
		||||
    this.actionManager.executeAction(actionToggleStats);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
 | 
			
		||||
@@ -1172,6 +1155,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  clearToast = () => {
 | 
			
		||||
    this.setState({ toastMessage: null });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  public updateScene = withBatchedUpdates((sceneData: SceneData) => {
 | 
			
		||||
    if (sceneData.commitToHistory) {
 | 
			
		||||
      history.resumeRecording();
 | 
			
		||||
@@ -1241,42 +1228,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
 | 
			
		||||
    if (event.key === KEYS.QUESTION_MARK) {
 | 
			
		||||
      this.setState({
 | 
			
		||||
        showShortcutsDialog: true,
 | 
			
		||||
        showHelpDialog: true,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z) {
 | 
			
		||||
      this.toggleZenMode();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE) {
 | 
			
		||||
      this.toggleGridMode();
 | 
			
		||||
    }
 | 
			
		||||
    if (event[KEYS.CTRL_OR_CMD]) {
 | 
			
		||||
      this.setState({ isBindingEnabled: false });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (event.code === CODES.C && event.altKey && event.shiftKey) {
 | 
			
		||||
      this.copyToClipboardAsPng();
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.actionManager.handleKeyDown(event)) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (event.code === CODES.NINE) {
 | 
			
		||||
      if (!this.state.isLibraryOpen) {
 | 
			
		||||
        trackEvent(EVENT_DIALOG, "library");
 | 
			
		||||
      }
 | 
			
		||||
      this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (isArrowKey(event.key)) {
 | 
			
		||||
      const step =
 | 
			
		||||
        (this.state.showGrid &&
 | 
			
		||||
          (event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : GRID_SIZE)) ||
 | 
			
		||||
        (this.state.gridSize &&
 | 
			
		||||
          (event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) ||
 | 
			
		||||
        (event.shiftKey
 | 
			
		||||
          ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
 | 
			
		||||
          : ELEMENT_TRANSLATE_AMOUNT);
 | 
			
		||||
@@ -1354,7 +1325,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    ) {
 | 
			
		||||
      const shape = findShapeByKey(event.key);
 | 
			
		||||
      if (shape) {
 | 
			
		||||
        trackEvent(EVENT_SHAPE, shape, "shortcut");
 | 
			
		||||
        this.selectShapeTool(shape);
 | 
			
		||||
      } else if (event.key === KEYS.Q) {
 | 
			
		||||
        this.toggleLock();
 | 
			
		||||
@@ -1738,7 +1708,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    resetCursor();
 | 
			
		||||
 | 
			
		||||
    if (!event[KEYS.CTRL_OR_CMD]) {
 | 
			
		||||
      trackEvent(EVENT_SHAPE, "text", "double-click");
 | 
			
		||||
      this.startTextEditing({
 | 
			
		||||
        sceneX,
 | 
			
		||||
        sceneY,
 | 
			
		||||
@@ -1775,8 +1744,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      const scaleFactor = distance / gesture.initialDistance;
 | 
			
		||||
 | 
			
		||||
      this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
 | 
			
		||||
        scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
 | 
			
		||||
        scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
 | 
			
		||||
        scrollX: scrollX + deltaX / zoom.value,
 | 
			
		||||
        scrollY: scrollY + deltaY / zoom.value,
 | 
			
		||||
        zoom: getNewZoom(
 | 
			
		||||
          getNormalizedZoom(initialScale * scaleFactor),
 | 
			
		||||
          zoom,
 | 
			
		||||
@@ -1819,7 +1788,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
        scenePointerX,
 | 
			
		||||
        scenePointerY,
 | 
			
		||||
        this.state.editingLinearElement,
 | 
			
		||||
        this.state.showGrid,
 | 
			
		||||
        this.state.gridSize,
 | 
			
		||||
      );
 | 
			
		||||
      if (editingLinearElement !== this.state.editingLinearElement) {
 | 
			
		||||
        this.setState({ editingLinearElement });
 | 
			
		||||
@@ -2187,12 +2156,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.setState({
 | 
			
		||||
        scrollX: normalizeScroll(
 | 
			
		||||
          this.state.scrollX - deltaX / this.state.zoom.value,
 | 
			
		||||
        ),
 | 
			
		||||
        scrollY: normalizeScroll(
 | 
			
		||||
          this.state.scrollY - deltaY / this.state.zoom.value,
 | 
			
		||||
        ),
 | 
			
		||||
        scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
 | 
			
		||||
        scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
    const teardown = withBatchedUpdates(
 | 
			
		||||
@@ -2249,7 +2214,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    return {
 | 
			
		||||
      origin,
 | 
			
		||||
      originInGrid: tupleToCoors(
 | 
			
		||||
        getGridPoint(origin.x, origin.y, this.state.showGrid),
 | 
			
		||||
        getGridPoint(origin.x, origin.y, this.state.gridSize),
 | 
			
		||||
      ),
 | 
			
		||||
      scrollbars: isOverScrollBars(
 | 
			
		||||
        currentScrollBars,
 | 
			
		||||
@@ -2462,8 +2427,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
          // otherwise, it will trigger selection based on current
 | 
			
		||||
          // state of the box
 | 
			
		||||
          if (!this.state.selectedElementIds[hitElement.id]) {
 | 
			
		||||
            // if we are currently editing a group, treat all selections outside of the group
 | 
			
		||||
            // as exiting editing mode.
 | 
			
		||||
            // if we are currently editing a group, exiting editing mode and deselect the group.
 | 
			
		||||
            if (
 | 
			
		||||
              this.state.editingGroupId &&
 | 
			
		||||
              !isElementInGroup(hitElement, this.state.editingGroupId)
 | 
			
		||||
@@ -2473,7 +2437,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
                selectedGroupIds: {},
 | 
			
		||||
                editingGroupId: null,
 | 
			
		||||
              });
 | 
			
		||||
              return true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Add hit element to selection. At this point if we're not holding
 | 
			
		||||
@@ -2607,7 +2570,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      const [gridX, gridY] = getGridPoint(
 | 
			
		||||
        pointerDownState.origin.x,
 | 
			
		||||
        pointerDownState.origin.y,
 | 
			
		||||
        elementType === "draw" ? false : this.state.showGrid,
 | 
			
		||||
        elementType === "draw" ? null : this.state.gridSize,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      /* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
 | 
			
		||||
@@ -2669,7 +2632,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    const [gridX, gridY] = getGridPoint(
 | 
			
		||||
      pointerDownState.origin.x,
 | 
			
		||||
      pointerDownState.origin.y,
 | 
			
		||||
      this.state.showGrid,
 | 
			
		||||
      this.state.gridSize,
 | 
			
		||||
    );
 | 
			
		||||
    const element = newElement({
 | 
			
		||||
      type: elementType,
 | 
			
		||||
@@ -2758,7 +2721,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      const [gridX, gridY] = getGridPoint(
 | 
			
		||||
        pointerCoords.x,
 | 
			
		||||
        pointerCoords.y,
 | 
			
		||||
        this.state.showGrid,
 | 
			
		||||
        this.state.gridSize,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // for arrows/lines, don't start dragging until a given threshold
 | 
			
		||||
@@ -2830,7 +2793,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
          const [dragX, dragY] = getGridPoint(
 | 
			
		||||
            pointerCoords.x - pointerDownState.drag.offset.x,
 | 
			
		||||
            pointerCoords.y - pointerDownState.drag.offset.y,
 | 
			
		||||
            this.state.showGrid,
 | 
			
		||||
            this.state.gridSize,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          const [dragDistanceX, dragDistanceY] = [
 | 
			
		||||
@@ -2882,7 +2845,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
                const [originDragX, originDragY] = getGridPoint(
 | 
			
		||||
                  pointerDownState.origin.x - pointerDownState.drag.offset.x,
 | 
			
		||||
                  pointerDownState.origin.y - pointerDownState.drag.offset.y,
 | 
			
		||||
                  this.state.showGrid,
 | 
			
		||||
                  this.state.gridSize,
 | 
			
		||||
                );
 | 
			
		||||
                mutateElement(duplicatedElement, {
 | 
			
		||||
                  x: duplicatedElement.x + (originDragX - dragX),
 | 
			
		||||
@@ -3008,9 +2971,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      const x = event.clientX;
 | 
			
		||||
      const dx = x - pointerDownState.lastCoords.x;
 | 
			
		||||
      this.setState({
 | 
			
		||||
        scrollX: normalizeScroll(
 | 
			
		||||
          this.state.scrollX - dx / this.state.zoom.value,
 | 
			
		||||
        ),
 | 
			
		||||
        scrollX: this.state.scrollX - dx / this.state.zoom.value,
 | 
			
		||||
      });
 | 
			
		||||
      pointerDownState.lastCoords.x = x;
 | 
			
		||||
      return true;
 | 
			
		||||
@@ -3020,9 +2981,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      const y = event.clientY;
 | 
			
		||||
      const dy = y - pointerDownState.lastCoords.y;
 | 
			
		||||
      this.setState({
 | 
			
		||||
        scrollY: normalizeScroll(
 | 
			
		||||
          this.state.scrollY - dy / this.state.zoom.value,
 | 
			
		||||
        ),
 | 
			
		||||
        scrollY: this.state.scrollY - dy / this.state.zoom.value,
 | 
			
		||||
      });
 | 
			
		||||
      pointerDownState.lastCoords.y = y;
 | 
			
		||||
      return true;
 | 
			
		||||
@@ -3140,7 +3099,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
          this.setState({ suggestedBindings: [], startBoundElement: null });
 | 
			
		||||
          if (!elementLocked) {
 | 
			
		||||
          if (!elementLocked && elementType !== "draw") {
 | 
			
		||||
            resetCursor();
 | 
			
		||||
            this.setState((prevState) => ({
 | 
			
		||||
              draggingElement: null,
 | 
			
		||||
@@ -3287,7 +3246,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!elementLocked && draggingElement) {
 | 
			
		||||
      if (!elementLocked && elementType !== "draw" && draggingElement) {
 | 
			
		||||
        this.setState((prevState) => ({
 | 
			
		||||
          selectedElementIds: {
 | 
			
		||||
            ...prevState.selectedElementIds,
 | 
			
		||||
@@ -3311,7 +3270,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!elementLocked) {
 | 
			
		||||
      if (!elementLocked && elementType !== "draw") {
 | 
			
		||||
        resetCursor();
 | 
			
		||||
        this.setState({
 | 
			
		||||
          draggingElement: null,
 | 
			
		||||
@@ -3542,7 +3501,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      const [gridX, gridY] = getGridPoint(
 | 
			
		||||
        pointerCoords.x,
 | 
			
		||||
        pointerCoords.y,
 | 
			
		||||
        this.state.showGrid,
 | 
			
		||||
        this.state.gridSize,
 | 
			
		||||
      );
 | 
			
		||||
      dragNewElement(
 | 
			
		||||
        draggingElement,
 | 
			
		||||
@@ -3580,15 +3539,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    const [resizeX, resizeY] = getGridPoint(
 | 
			
		||||
      pointerCoords.x - pointerDownState.resize.offset.x,
 | 
			
		||||
      pointerCoords.y - pointerDownState.resize.offset.y,
 | 
			
		||||
      this.state.showGrid,
 | 
			
		||||
      this.state.gridSize,
 | 
			
		||||
    );
 | 
			
		||||
    if (
 | 
			
		||||
      transformElements(
 | 
			
		||||
        pointerDownState,
 | 
			
		||||
        transformHandleType,
 | 
			
		||||
        (newTransformHandle) => {
 | 
			
		||||
          pointerDownState.resize.handleType = newTransformHandle;
 | 
			
		||||
        },
 | 
			
		||||
        selectedElements,
 | 
			
		||||
        pointerDownState.resize.arrowDirection,
 | 
			
		||||
        getRotateWithDiscreteAngleKey(event),
 | 
			
		||||
@@ -3618,46 +3574,56 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      this.state,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const maybeGroupAction = actionGroup.contextItemPredicate!(
 | 
			
		||||
      this.actionManager.getElementsIncludingDeleted(),
 | 
			
		||||
      this.actionManager.getAppState(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const maybeUngroupAction = actionUngroup.contextItemPredicate!(
 | 
			
		||||
      this.actionManager.getElementsIncludingDeleted(),
 | 
			
		||||
      this.actionManager.getAppState(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const separator = "separator";
 | 
			
		||||
 | 
			
		||||
    const _isMobile = isMobile();
 | 
			
		||||
 | 
			
		||||
    const elements = this.scene.getElements();
 | 
			
		||||
    const element = this.getElementAtPosition(x, y);
 | 
			
		||||
    if (!element) {
 | 
			
		||||
      ContextMenu.push({
 | 
			
		||||
        options: [
 | 
			
		||||
          navigator.clipboard && {
 | 
			
		||||
            shortcutName: "paste",
 | 
			
		||||
            label: t("labels.paste"),
 | 
			
		||||
            action: () => this.pasteFromClipboard(null),
 | 
			
		||||
          },
 | 
			
		||||
          _isMobile &&
 | 
			
		||||
            navigator.clipboard && {
 | 
			
		||||
              name: "paste",
 | 
			
		||||
              perform: (elements, appStates) => {
 | 
			
		||||
                this.pasteFromClipboard(null);
 | 
			
		||||
                return {
 | 
			
		||||
                  commitToHistory: false,
 | 
			
		||||
                };
 | 
			
		||||
              },
 | 
			
		||||
              contextItemLabel: "labels.paste",
 | 
			
		||||
            },
 | 
			
		||||
          _isMobile && navigator.clipboard && separator,
 | 
			
		||||
          probablySupportsClipboardBlob &&
 | 
			
		||||
            elements.length > 0 && {
 | 
			
		||||
              shortcutName: "copyAsPng",
 | 
			
		||||
              label: t("labels.copyAsPng"),
 | 
			
		||||
              action: this.copyToClipboardAsPng,
 | 
			
		||||
            },
 | 
			
		||||
            elements.length > 0 &&
 | 
			
		||||
            actionCopyAsPng,
 | 
			
		||||
          probablySupportsClipboardWriteText &&
 | 
			
		||||
            elements.length > 0 && {
 | 
			
		||||
              shortcutName: "copyAsSvg",
 | 
			
		||||
              label: t("labels.copyAsSvg"),
 | 
			
		||||
              action: this.copyToClipboardAsSvg,
 | 
			
		||||
            },
 | 
			
		||||
          ...this.actionManager.getContextMenuItems((action) =>
 | 
			
		||||
            CANVAS_ONLY_ACTIONS.includes(action.name),
 | 
			
		||||
          ),
 | 
			
		||||
          {
 | 
			
		||||
            checked: this.state.showGrid,
 | 
			
		||||
            shortcutName: "gridMode",
 | 
			
		||||
            label: t("labels.gridMode"),
 | 
			
		||||
            action: this.toggleGridMode,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            checked: this.state.showStats,
 | 
			
		||||
            shortcutName: "stats",
 | 
			
		||||
            label: t("stats.title"),
 | 
			
		||||
            action: this.toggleStats,
 | 
			
		||||
          },
 | 
			
		||||
            elements.length > 0 &&
 | 
			
		||||
            actionCopyAsSvg,
 | 
			
		||||
          ((probablySupportsClipboardBlob && elements.length > 0) ||
 | 
			
		||||
            (probablySupportsClipboardWriteText && elements.length > 0)) &&
 | 
			
		||||
            separator,
 | 
			
		||||
          actionSelectAll,
 | 
			
		||||
          separator,
 | 
			
		||||
          actionToggleGridMode,
 | 
			
		||||
          actionToggleZenMode,
 | 
			
		||||
          actionToggleStats,
 | 
			
		||||
        ],
 | 
			
		||||
        top: clientY,
 | 
			
		||||
        left: clientX,
 | 
			
		||||
        actionManager: this.actionManager,
 | 
			
		||||
        appState: this.state,
 | 
			
		||||
      });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
@@ -3668,37 +3634,43 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
 | 
			
		||||
    ContextMenu.push({
 | 
			
		||||
      options: [
 | 
			
		||||
        {
 | 
			
		||||
          shortcutName: "cut",
 | 
			
		||||
          label: t("labels.cut"),
 | 
			
		||||
          action: this.cutAll,
 | 
			
		||||
        },
 | 
			
		||||
        navigator.clipboard && {
 | 
			
		||||
          shortcutName: "copy",
 | 
			
		||||
          label: t("labels.copy"),
 | 
			
		||||
          action: this.copyAll,
 | 
			
		||||
        },
 | 
			
		||||
        navigator.clipboard && {
 | 
			
		||||
          shortcutName: "paste",
 | 
			
		||||
          label: t("labels.paste"),
 | 
			
		||||
          action: () => this.pasteFromClipboard(null),
 | 
			
		||||
        },
 | 
			
		||||
        probablySupportsClipboardBlob && {
 | 
			
		||||
          shortcutName: "copyAsPng",
 | 
			
		||||
          label: t("labels.copyAsPng"),
 | 
			
		||||
          action: this.copyToClipboardAsPng,
 | 
			
		||||
        },
 | 
			
		||||
        probablySupportsClipboardWriteText && {
 | 
			
		||||
          shortcutName: "copyAsSvg",
 | 
			
		||||
          label: t("labels.copyAsSvg"),
 | 
			
		||||
          action: this.copyToClipboardAsSvg,
 | 
			
		||||
        },
 | 
			
		||||
        ...this.actionManager.getContextMenuItems(
 | 
			
		||||
          (action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
 | 
			
		||||
        ),
 | 
			
		||||
        _isMobile && actionCut,
 | 
			
		||||
        _isMobile && navigator.clipboard && actionCopy,
 | 
			
		||||
        _isMobile &&
 | 
			
		||||
          navigator.clipboard && {
 | 
			
		||||
            name: "paste",
 | 
			
		||||
            perform: (elements, appStates) => {
 | 
			
		||||
              this.pasteFromClipboard(null);
 | 
			
		||||
              return {
 | 
			
		||||
                commitToHistory: false,
 | 
			
		||||
              };
 | 
			
		||||
            },
 | 
			
		||||
            contextItemLabel: "labels.paste",
 | 
			
		||||
          },
 | 
			
		||||
        _isMobile && separator,
 | 
			
		||||
        probablySupportsClipboardBlob && actionCopyAsPng,
 | 
			
		||||
        probablySupportsClipboardWriteText && actionCopyAsSvg,
 | 
			
		||||
        separator,
 | 
			
		||||
        actionCopyStyles,
 | 
			
		||||
        actionPasteStyles,
 | 
			
		||||
        separator,
 | 
			
		||||
        maybeGroupAction && actionGroup,
 | 
			
		||||
        maybeUngroupAction && actionUngroup,
 | 
			
		||||
        (maybeGroupAction || maybeUngroupAction) && separator,
 | 
			
		||||
        actionAddToLibrary,
 | 
			
		||||
        separator,
 | 
			
		||||
        actionSendBackward,
 | 
			
		||||
        actionBringForward,
 | 
			
		||||
        actionSendToBack,
 | 
			
		||||
        actionBringToFront,
 | 
			
		||||
        separator,
 | 
			
		||||
        actionDuplicateSelection,
 | 
			
		||||
        actionDeleteSelected,
 | 
			
		||||
      ],
 | 
			
		||||
      top: clientY,
 | 
			
		||||
      left: clientX,
 | 
			
		||||
      actionManager: this.actionManager,
 | 
			
		||||
      appState: this.state,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -3729,9 +3701,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
        }, 1000);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let newZoom = this.state.zoom.value - delta / 100;
 | 
			
		||||
      // increase zoom steps the more zoomed-in we are (applies to >100% only)
 | 
			
		||||
      newZoom += Math.log10(Math.max(1, this.state.zoom.value)) * -sign;
 | 
			
		||||
      // round to nearest step
 | 
			
		||||
      newZoom = Math.round(newZoom * ZOOM_STEP * 100) / (ZOOM_STEP * 100);
 | 
			
		||||
 | 
			
		||||
      this.setState(({ zoom, offsetLeft, offsetTop }) => ({
 | 
			
		||||
        zoom: getNewZoom(
 | 
			
		||||
          getNormalizedZoom(zoom.value - delta / 100),
 | 
			
		||||
          getNormalizedZoom(newZoom),
 | 
			
		||||
          zoom,
 | 
			
		||||
          { left: offsetLeft, top: offsetTop },
 | 
			
		||||
          {
 | 
			
		||||
@@ -3754,14 +3732,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
    if (event.shiftKey) {
 | 
			
		||||
      this.setState(({ zoom, scrollX }) => ({
 | 
			
		||||
        // on Mac, shift+wheel tends to result in deltaX
 | 
			
		||||
        scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value),
 | 
			
		||||
        scrollX: scrollX - (deltaY || deltaX) / zoom.value,
 | 
			
		||||
      }));
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.setState(({ zoom, scrollX, scrollY }) => ({
 | 
			
		||||
      scrollX: normalizeScroll(scrollX - deltaX / zoom.value),
 | 
			
		||||
      scrollY: normalizeScroll(scrollY - deltaY / zoom.value),
 | 
			
		||||
      scrollX: scrollX - deltaX / zoom.value,
 | 
			
		||||
      scrollY: scrollY - deltaY / zoom.value,
 | 
			
		||||
    }));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -3821,7 +3799,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
 | 
			
		||||
    this.setState({ shouldCacheIgnoreZoom: false });
 | 
			
		||||
    if (!this.unmounted) {
 | 
			
		||||
      this.setState({ shouldCacheIgnoreZoom: false });
 | 
			
		||||
    }
 | 
			
		||||
  }, 300);
 | 
			
		||||
 | 
			
		||||
  private getCanvasOffsets(offsets?: {
 | 
			
		||||
@@ -3853,6 +3833,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 | 
			
		||||
      offsetTop: typeof offsets?.offsetTop === "number" ? offsets.offsetTop : 0,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async updateLanguage() {
 | 
			
		||||
    const currentLang =
 | 
			
		||||
      languages.find((lang) => lang.code === this.props.langCode) ||
 | 
			
		||||
      defaultLang;
 | 
			
		||||
    await setLanguage(currentLang);
 | 
			
		||||
    this.setAppState({});
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Avatar {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { DarkModeToggle } from "./DarkModeToggle";
 | 
			
		||||
 | 
			
		||||
@@ -19,8 +18,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
 | 
			
		||||
      <DarkModeToggle
 | 
			
		||||
        value={appState.appearance}
 | 
			
		||||
        onChange={(appearance) => {
 | 
			
		||||
          // TODO: track the theme on the first load too
 | 
			
		||||
          trackEvent(EVENT_CHANGE, "theme", appearance);
 | 
			
		||||
          setAppState({ appearance });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 
 | 
			
		||||
@@ -14,11 +14,11 @@ export const ButtonIconCycle = <T extends any>({
 | 
			
		||||
}) => {
 | 
			
		||||
  const current = options.find((op) => op.value === value);
 | 
			
		||||
 | 
			
		||||
  function cycle() {
 | 
			
		||||
  const cycle = () => {
 | 
			
		||||
    const index = options.indexOf(current!);
 | 
			
		||||
    const next = (index + 1) % options.length;
 | 
			
		||||
    onChange(options[next].value);
 | 
			
		||||
  }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <label key={group} className={clsx({ active: current!.value !== null })}>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .CollabButton.is-collaborating {
 | 
			
		||||
    background-color: var(--button-special-active-background-color);
 | 
			
		||||
    background-color: var(--button-special-active-bg-color);
 | 
			
		||||
 | 
			
		||||
    .ToolIcon__icon svg {
 | 
			
		||||
      color: var(--icon-green-fill-color);
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,6 @@ import useIsMobile from "../is-mobile";
 | 
			
		||||
import { users } from "./icons";
 | 
			
		||||
 | 
			
		||||
import "./CollabButton.scss";
 | 
			
		||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
const CollabButton = ({
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
@@ -23,10 +22,7 @@ const CollabButton = ({
 | 
			
		||||
        className={clsx("CollabButton", {
 | 
			
		||||
          "is-collaborating": isCollaborating,
 | 
			
		||||
        })}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          trackEvent(EVENT_DIALOG, "collaboration");
 | 
			
		||||
          onClick();
 | 
			
		||||
        }}
 | 
			
		||||
        onClick={onClick}
 | 
			
		||||
        icon={users}
 | 
			
		||||
        type="button"
 | 
			
		||||
        title={t("buttons.roomDialog")}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .color-picker {
 | 
			
		||||
    background: var(--popup-background-color);
 | 
			
		||||
    border: 0px solid transparentize($oc-white, 0.75);
 | 
			
		||||
    box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
 | 
			
		||||
    background: var(--popup-bg-color);
 | 
			
		||||
    border: 0 solid transparentize($oc-white, 0.75);
 | 
			
		||||
    box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
 | 
			
		||||
@@ -24,11 +24,11 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-triangle {
 | 
			
		||||
    width: 0px;
 | 
			
		||||
    height: 0px;
 | 
			
		||||
    width: 0;
 | 
			
		||||
    height: 0;
 | 
			
		||||
    border-style: solid;
 | 
			
		||||
    border-width: 0px 9px 10px;
 | 
			
		||||
    border-color: transparent transparent var(--popup-background-color);
 | 
			
		||||
    border-width: 0 9px 10px;
 | 
			
		||||
    border-color: transparent transparent var(--popup-bg-color);
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: -10px;
 | 
			
		||||
 | 
			
		||||
@@ -84,12 +84,12 @@
 | 
			
		||||
 | 
			
		||||
  .color-picker-transparent {
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
 | 
			
		||||
    box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0px;
 | 
			
		||||
    right: 0px;
 | 
			
		||||
    bottom: 0px;
 | 
			
		||||
    left: 0px;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-transparent,
 | 
			
		||||
@@ -104,11 +104,11 @@
 | 
			
		||||
    width: 1.875rem;
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      border-radius: 4px 0px 0px 4px;
 | 
			
		||||
      border-radius: 4px 0 0 4px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      border-radius: 0px 4px 4px 0px;
 | 
			
		||||
      border-radius: 0 4px 4px 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    color: var(--input-label-color);
 | 
			
		||||
@@ -144,7 +144,7 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-input-container:focus-within .color-picker-hash::after {
 | 
			
		||||
    background: var(--input-background-color);
 | 
			
		||||
    background: var(--input-bg-color);
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      right: -2px;
 | 
			
		||||
@@ -163,19 +163,19 @@
 | 
			
		||||
    width: 12ch; /* length of `transparent` + 1 */
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    background-color: var(--input-background-color);
 | 
			
		||||
    background-color: var(--input-bg-color);
 | 
			
		||||
    color: var(--text-color-primary);
 | 
			
		||||
    border: 0px;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    outline: none;
 | 
			
		||||
    height: 1.75em;
 | 
			
		||||
    box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
 | 
			
		||||
    box-shadow: var(--input-border-color) 0 0 0 1px inset;
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      border-radius: 0px 4px 4px 0px;
 | 
			
		||||
      border-radius: 0 4px 4px 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      border-radius: 4px 0px 0px 4px;
 | 
			
		||||
      border-radius: 4px 0 0 4px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    float: left;
 | 
			
		||||
@@ -218,7 +218,7 @@
 | 
			
		||||
      left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media #{$media-query} {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,18 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .context-menu {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
 | 
			
		||||
    box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    list-style: none;
 | 
			
		||||
    user-select: none;
 | 
			
		||||
    margin: -0.25rem 0 0 0.125rem;
 | 
			
		||||
    padding: 0.25rem 0;
 | 
			
		||||
    background-color: var(--popup-secondary-background-color);
 | 
			
		||||
    padding: 0.5rem 0;
 | 
			
		||||
    background-color: var(--popup-secondary-bg-color);
 | 
			
		||||
    border: 1px solid var(--button-gray-3);
 | 
			
		||||
    cursor: default;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .context-menu button {
 | 
			
		||||
@@ -42,29 +43,30 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.dangerous {
 | 
			
		||||
      div:nth-child(1) {
 | 
			
		||||
      .context-menu-option__label {
 | 
			
		||||
        color: $oc-red-7;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    div:nth-child(1) {
 | 
			
		||||
    .context-menu-option__label {
 | 
			
		||||
      justify-self: start;
 | 
			
		||||
      margin-inline-end: 20px;
 | 
			
		||||
    }
 | 
			
		||||
    div:nth-child(2) {
 | 
			
		||||
    .context-menu-option__shortcut {
 | 
			
		||||
      justify-self: end;
 | 
			
		||||
      opacity: 0.6;
 | 
			
		||||
      font-family: inherit;
 | 
			
		||||
      font-size: 0.7rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .context-menu-option:hover {
 | 
			
		||||
    color: var(--popup-background-color);
 | 
			
		||||
    color: var(--popup-bg-color);
 | 
			
		||||
    background-color: var(--select-highlight-color);
 | 
			
		||||
 | 
			
		||||
    &.dangerous {
 | 
			
		||||
      div:nth-child(1) {
 | 
			
		||||
        color: var(--popup-background-color);
 | 
			
		||||
      .context-menu-option__label {
 | 
			
		||||
        color: var(--popup-bg-color);
 | 
			
		||||
      }
 | 
			
		||||
      background-color: $oc-red-6;
 | 
			
		||||
    }
 | 
			
		||||
@@ -73,4 +75,23 @@
 | 
			
		||||
  .context-menu-option:focus {
 | 
			
		||||
    z-index: 1;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .context-menu-option {
 | 
			
		||||
      display: block;
 | 
			
		||||
 | 
			
		||||
      .context-menu-option__label {
 | 
			
		||||
        margin-inline-end: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .context-menu-option__shortcut {
 | 
			
		||||
        display: none;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .context-menu-option-separator {
 | 
			
		||||
    border: none;
 | 
			
		||||
    border-top: 1px solid $oc-gray-5;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,28 +2,36 @@ import React from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { Popover } from "./Popover";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import "./ContextMenu.scss";
 | 
			
		||||
import {
 | 
			
		||||
  getShortcutFromShortcutName,
 | 
			
		||||
  ShortcutName,
 | 
			
		||||
} from "../actions/shortcuts";
 | 
			
		||||
import { Action } from "../actions/types";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
type ContextMenuOption = {
 | 
			
		||||
  checked?: boolean;
 | 
			
		||||
  shortcutName: ShortcutName;
 | 
			
		||||
  label: string;
 | 
			
		||||
  action(): void;
 | 
			
		||||
};
 | 
			
		||||
type ContextMenuOption = "separator" | Action;
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
type ContextMenuProps = {
 | 
			
		||||
  options: ContextMenuOption[];
 | 
			
		||||
  onCloseRequest?(): void;
 | 
			
		||||
  top: number;
 | 
			
		||||
  left: number;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
 | 
			
		||||
const ContextMenu = ({
 | 
			
		||||
  options,
 | 
			
		||||
  onCloseRequest,
 | 
			
		||||
  top,
 | 
			
		||||
  left,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
}: ContextMenuProps) => {
 | 
			
		||||
  const isDarkTheme = !!document
 | 
			
		||||
    .querySelector(".excalidraw")
 | 
			
		||||
    ?.classList.contains("Appearance_dark");
 | 
			
		||||
@@ -43,23 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
 | 
			
		||||
          className="context-menu"
 | 
			
		||||
          onContextMenu={(event) => event.preventDefault()}
 | 
			
		||||
        >
 | 
			
		||||
          {options.map(({ action, checked, shortcutName, label }, idx) => (
 | 
			
		||||
            <li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
 | 
			
		||||
              <button
 | 
			
		||||
                className={`context-menu-option 
 | 
			
		||||
                ${shortcutName === "delete" ? "dangerous" : ""}
 | 
			
		||||
                ${checked ? "checkmark" : ""}`}
 | 
			
		||||
                onClick={action}
 | 
			
		||||
              >
 | 
			
		||||
                <div>{label}</div>
 | 
			
		||||
                <div>
 | 
			
		||||
                  {shortcutName
 | 
			
		||||
                    ? getShortcutFromShortcutName(shortcutName)
 | 
			
		||||
                    : ""}
 | 
			
		||||
                </div>
 | 
			
		||||
              </button>
 | 
			
		||||
            </li>
 | 
			
		||||
          ))}
 | 
			
		||||
          {options.map((option, idx) => {
 | 
			
		||||
            if (option === "separator") {
 | 
			
		||||
              return <hr key={idx} className="context-menu-option-separator" />;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const actionName = option.name;
 | 
			
		||||
            const label = option.contextItemLabel
 | 
			
		||||
              ? t(option.contextItemLabel)
 | 
			
		||||
              : "";
 | 
			
		||||
            return (
 | 
			
		||||
              <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
			
		||||
                <button
 | 
			
		||||
                  className={clsx("context-menu-option", {
 | 
			
		||||
                    dangerous: actionName === "deleteSelectedElements",
 | 
			
		||||
                    checkmark: option.checked?.(appState),
 | 
			
		||||
                  })}
 | 
			
		||||
                  onClick={() => actionManager.executeAction(option)}
 | 
			
		||||
                >
 | 
			
		||||
                  <div className="context-menu-option__label">{label}</div>
 | 
			
		||||
                  <kbd className="context-menu-option__shortcut">
 | 
			
		||||
                    {actionName
 | 
			
		||||
                      ? getShortcutFromShortcutName(actionName as ShortcutName)
 | 
			
		||||
                      : ""}
 | 
			
		||||
                  </kbd>
 | 
			
		||||
                </button>
 | 
			
		||||
              </li>
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
        </ul>
 | 
			
		||||
      </Popover>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -78,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => {
 | 
			
		||||
 | 
			
		||||
type ContextMenuParams = {
 | 
			
		||||
  options: (ContextMenuOption | false | null | undefined)[];
 | 
			
		||||
  top: number;
 | 
			
		||||
  left: number;
 | 
			
		||||
  top: ContextMenuProps["top"];
 | 
			
		||||
  left: ContextMenuProps["left"];
 | 
			
		||||
  actionManager: ContextMenuProps["actionManager"];
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleClose = () => {
 | 
			
		||||
@@ -101,6 +122,8 @@ export default {
 | 
			
		||||
          left={params.left}
 | 
			
		||||
          options={options}
 | 
			
		||||
          onCloseRequest={handleClose}
 | 
			
		||||
          actionManager={params.actionManager}
 | 
			
		||||
          appState={params.appState}
 | 
			
		||||
        />,
 | 
			
		||||
        getContextMenuNode(),
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,21 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Dialog {
 | 
			
		||||
    user-select: text;
 | 
			
		||||
    cursor: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Dialog__title {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
    grid-template-columns: 1fr calc(var(--space-factor) * 7);
 | 
			
		||||
    grid-gap: var(--metric);
 | 
			
		||||
    padding: calc(var(--space-factor) * 2);
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    font-variant: small-caps;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Dialog__titleContent {
 | 
			
		||||
@@ -18,7 +27,11 @@
 | 
			
		||||
    margin: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$media-query} {
 | 
			
		||||
  .Dialog__content {
 | 
			
		||||
    padding: 0 16px 16px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .Dialog {
 | 
			
		||||
      --metric: calc(var(--space-factor) * 4);
 | 
			
		||||
      --inset-left: #{"max(var(--metric), var(--sal))"};
 | 
			
		||||
@@ -30,14 +43,9 @@
 | 
			
		||||
          var(--space-factor) * 7
 | 
			
		||||
        );
 | 
			
		||||
      position: sticky;
 | 
			
		||||
      top: calc(-1 * var(--metric));
 | 
			
		||||
      margin: calc(-1 * var(--inset-right));
 | 
			
		||||
      margin-top: calc(-1 * var(--metric));
 | 
			
		||||
      margin-bottom: var(--metric);
 | 
			
		||||
      top: 0;
 | 
			
		||||
      padding: calc(var(--space-factor) * 2);
 | 
			
		||||
      padding-left: var(--inset-left);
 | 
			
		||||
      padding-right: var(--inset-right);
 | 
			
		||||
      background: var(--bg-color-island);
 | 
			
		||||
      background: var(--island-bg-color);
 | 
			
		||||
      font-size: 1.25em;
 | 
			
		||||
 | 
			
		||||
      box-sizing: border-box;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,30 +1,23 @@
 | 
			
		||||
import React, { useCallback, useEffect, useState } from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { Modal } from "./Modal";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import React, { useEffect } from "react";
 | 
			
		||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { back, close } from "./icons";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
 | 
			
		||||
import "./Dialog.scss";
 | 
			
		||||
 | 
			
		||||
const useRefState = <T,>() => {
 | 
			
		||||
  const [refValue, setRefValue] = useState<T | null>(null);
 | 
			
		||||
  const refCallback = useCallback((value: T) => {
 | 
			
		||||
    setRefValue(value);
 | 
			
		||||
  }, []);
 | 
			
		||||
  return [refValue, refCallback] as const;
 | 
			
		||||
};
 | 
			
		||||
import { back, close } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import { Modal } from "./Modal";
 | 
			
		||||
 | 
			
		||||
export const Dialog = (props: {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  maxWidth?: number;
 | 
			
		||||
  small?: boolean;
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  title: React.ReactNode;
 | 
			
		||||
  autofocus?: boolean;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
 | 
			
		||||
  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!islandNode) {
 | 
			
		||||
@@ -33,7 +26,7 @@ export const Dialog = (props: {
 | 
			
		||||
 | 
			
		||||
    const focusableElements = queryFocusableElements(islandNode);
 | 
			
		||||
 | 
			
		||||
    if (focusableElements.length > 0) {
 | 
			
		||||
    if (focusableElements.length > 0 && props.autofocus !== false) {
 | 
			
		||||
      // If there's an element other than close, focus it.
 | 
			
		||||
      (focusableElements[1] || focusableElements[0]).focus();
 | 
			
		||||
    }
 | 
			
		||||
@@ -62,7 +55,7 @@ export const Dialog = (props: {
 | 
			
		||||
    islandNode.addEventListener("keydown", handleKeyDown);
 | 
			
		||||
 | 
			
		||||
    return () => islandNode.removeEventListener("keydown", handleKeyDown);
 | 
			
		||||
  }, [islandNode]);
 | 
			
		||||
  }, [islandNode, props.autofocus]);
 | 
			
		||||
 | 
			
		||||
  const queryFocusableElements = (node: HTMLElement) => {
 | 
			
		||||
    const focusableElements = node.querySelectorAll<HTMLElement>(
 | 
			
		||||
@@ -76,10 +69,10 @@ export const Dialog = (props: {
 | 
			
		||||
    <Modal
 | 
			
		||||
      className={clsx("Dialog", props.className)}
 | 
			
		||||
      labelledBy="dialog-title"
 | 
			
		||||
      maxWidth={props.maxWidth}
 | 
			
		||||
      maxWidth={props.small ? 550 : 800}
 | 
			
		||||
      onCloseRequest={props.onCloseRequest}
 | 
			
		||||
    >
 | 
			
		||||
      <Island padding={4} ref={setIslandNode}>
 | 
			
		||||
      <Island ref={setIslandNode}>
 | 
			
		||||
        <h2 id="dialog-title" className="Dialog__title">
 | 
			
		||||
          <span className="Dialog__titleContent">{props.title}</span>
 | 
			
		||||
          <button
 | 
			
		||||
@@ -90,7 +83,7 @@ export const Dialog = (props: {
 | 
			
		||||
            {useIsMobile() ? back : close}
 | 
			
		||||
          </button>
 | 
			
		||||
        </h2>
 | 
			
		||||
        {props.children}
 | 
			
		||||
        <div className="Dialog__content">{props.children}</div>
 | 
			
		||||
      </Island>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,7 @@ export const ErrorDialog = ({
 | 
			
		||||
    <>
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog
 | 
			
		||||
          maxWidth={500}
 | 
			
		||||
          small
 | 
			
		||||
          onCloseRequest={handleClose}
 | 
			
		||||
          title={t("errorDialog.title")}
 | 
			
		||||
        >
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .ExportDialog__preview {
 | 
			
		||||
@@ -37,7 +37,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media (max-width: 550px) {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .ExportDialog {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
@@ -51,9 +51,7 @@
 | 
			
		||||
    .ExportDialog__actions > * {
 | 
			
		||||
      margin-bottom: calc(var(--space-factor) * 3);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$media-query} {
 | 
			
		||||
    .ExportDialog__preview canvas {
 | 
			
		||||
      max-height: 30vh;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
 | 
			
		||||
import { probablySupportsClipboardBlob } from "../clipboard";
 | 
			
		||||
import { canvasToBlob } from "../data/blob";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
@@ -251,7 +250,6 @@ export const ExportDialog = ({
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          trackEvent(EVENT_DIALOG, "export");
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
@@ -262,11 +260,7 @@ export const ExportDialog = ({
 | 
			
		||||
        ref={triggerButton}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog
 | 
			
		||||
          maxWidth={800}
 | 
			
		||||
          onCloseRequest={handleClose}
 | 
			
		||||
          title={t("buttons.export")}
 | 
			
		||||
        >
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
 | 
			
		||||
          <ExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import { EVENT_EXIT, trackEvent } from "../analytics";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
// https://github.com/tholman/github-corners
 | 
			
		||||
export const GitHubCorner = React.memo(
 | 
			
		||||
@@ -17,9 +16,6 @@ export const GitHubCorner = React.memo(
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
        aria-label="GitHub repository"
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          trackEvent(EVENT_EXIT, "github");
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <path
 | 
			
		||||
          d="M0 0l115 115h15l12 27 108 108V0z"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,28 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .ShortcutsDialog-island {
 | 
			
		||||
  .HelpDialog h3 {
 | 
			
		||||
    border-bottom: 1px solid var(--button-gray-2);
 | 
			
		||||
    padding-bottom: 4px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .HelpDialog--island {
 | 
			
		||||
    border: 1px solid var(--button-gray-2);
 | 
			
		||||
    margin-bottom: 16px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ShortcutsDialog-island-title {
 | 
			
		||||
  .HelpDialog--island-title {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 4px;
 | 
			
		||||
    background-color: var(--button-gray-1);
 | 
			
		||||
    text-align: center;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ShorcutsDialog-shortcut {
 | 
			
		||||
  .HelpDialog--shortcut {
 | 
			
		||||
    border-top: 1px solid var(--button-gray-2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ShorcutsDialog-key {
 | 
			
		||||
  .HelpDialog--key {
 | 
			
		||||
    word-break: keep-all;
 | 
			
		||||
    border: 1px solid var(--button-gray-2);
 | 
			
		||||
    padding: 2px 8px;
 | 
			
		||||
@@ -29,14 +34,23 @@
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    font-family: inherit;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ShortcutsDialog-footer {
 | 
			
		||||
  .HelpDialog--header {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    justify-content: space-evenly;
 | 
			
		||||
    border-top: 1px solid var(--button-gray-2);
 | 
			
		||||
    margin-top: 8px;
 | 
			
		||||
    padding-top: 16px;
 | 
			
		||||
    margin-bottom: 32px;
 | 
			
		||||
    padding-bottom: 16px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .HelpDialog--btn {
 | 
			
		||||
    border: 1px solid var(--link-color);
 | 
			
		||||
    padding: 8px 32px;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
  }
 | 
			
		||||
  .HelpDialog--btn:hover {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										355
									
								
								src/components/HelpDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,355 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { isDarwin, isWindows } from "../keys";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import "./HelpDialog.scss";
 | 
			
		||||
 | 
			
		||||
const Header = () => (
 | 
			
		||||
  <div className="HelpDialog--header">
 | 
			
		||||
    <a
 | 
			
		||||
      className="HelpDialog--btn"
 | 
			
		||||
      href="https://github.com/excalidraw/excalidraw#documentation"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
    >
 | 
			
		||||
      {t("helpDialog.documentation")}
 | 
			
		||||
    </a>
 | 
			
		||||
    <a
 | 
			
		||||
      className="HelpDialog--btn"
 | 
			
		||||
      href="https://blog.excalidraw.com"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
    >
 | 
			
		||||
      {t("helpDialog.blog")}
 | 
			
		||||
    </a>
 | 
			
		||||
    <a
 | 
			
		||||
      className="HelpDialog--btn"
 | 
			
		||||
      href="https://github.com/excalidraw/excalidraw/issues"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
    >
 | 
			
		||||
      {t("helpDialog.github")}
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Section = (props: { title: string; children: React.ReactNode }) => (
 | 
			
		||||
  <>
 | 
			
		||||
    <h3>{props.title}</h3>
 | 
			
		||||
    {props.children}
 | 
			
		||||
  </>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Columns = (props: { children: React.ReactNode }) => (
 | 
			
		||||
  <div
 | 
			
		||||
    style={{
 | 
			
		||||
      display: "flex",
 | 
			
		||||
      flexDirection: "row",
 | 
			
		||||
      flexWrap: "wrap",
 | 
			
		||||
      justifyContent: "space-between",
 | 
			
		||||
    }}
 | 
			
		||||
  >
 | 
			
		||||
    {props.children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Column = (props: { children: React.ReactNode }) => (
 | 
			
		||||
  <div style={{ width: "49%" }}>{props.children}</div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const ShortcutIsland = (props: {
 | 
			
		||||
  caption: string;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}) => (
 | 
			
		||||
  <div className="HelpDialog--island">
 | 
			
		||||
    <h3 className="HelpDialog--island-title">{props.caption}</h3>
 | 
			
		||||
    {props.children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Shortcut = (props: {
 | 
			
		||||
  label: string;
 | 
			
		||||
  shortcuts: string[];
 | 
			
		||||
  isOr: boolean;
 | 
			
		||||
}) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="HelpDialog--shortcut">
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          margin: "0",
 | 
			
		||||
          padding: "4px 8px",
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            lineHeight: 1.4,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {props.label}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            flex: "0 0 auto",
 | 
			
		||||
            justifyContent: "flex-end",
 | 
			
		||||
            marginInlineStart: "auto",
 | 
			
		||||
            minWidth: "30%",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {props.shortcuts.map((shortcut, index) => (
 | 
			
		||||
            <React.Fragment key={index}>
 | 
			
		||||
              <ShortcutKey>{shortcut}</ShortcutKey>
 | 
			
		||||
              {props.isOr &&
 | 
			
		||||
                index !== props.shortcuts.length - 1 &&
 | 
			
		||||
                t("helpDialog.or")}
 | 
			
		||||
            </React.Fragment>
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
Shortcut.defaultProps = {
 | 
			
		||||
  isOr: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ShortcutKey = (props: { children: React.ReactNode }) => (
 | 
			
		||||
  <kbd className="HelpDialog--key" {...props} />
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    if (onClose) {
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Dialog
 | 
			
		||||
        onCloseRequest={handleClose}
 | 
			
		||||
        title={t("helpDialog.title")}
 | 
			
		||||
        className={"HelpDialog"}
 | 
			
		||||
      >
 | 
			
		||||
        <Header />
 | 
			
		||||
        <Section title={t("helpDialog.shortcuts")}>
 | 
			
		||||
          <Columns>
 | 
			
		||||
            <Column>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.shapes")}>
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.selection")}
 | 
			
		||||
                  shortcuts={["V", "1"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.rectangle")}
 | 
			
		||||
                  shortcuts={["R", "2"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.draw")}
 | 
			
		||||
                  shortcuts={["Shift+P", "7"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.textNewLine")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey("Enter"),
 | 
			
		||||
                    getShortcutKey("Shift+Enter"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.textFinish")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey("Esc"),
 | 
			
		||||
                    getShortcutKey("CtrlOrCmd+Enter"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.curvedArrow")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    "A",
 | 
			
		||||
                    t("helpDialog.click"),
 | 
			
		||||
                    t("helpDialog.click"),
 | 
			
		||||
                    t("helpDialog.click"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                  isOr={false}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.curvedLine")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    "L",
 | 
			
		||||
                    t("helpDialog.click"),
 | 
			
		||||
                    t("helpDialog.click"),
 | 
			
		||||
                    t("helpDialog.click"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                  isOr={false}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.preventBinding")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.view")}>
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.zoomIn")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd++")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.zoomOut")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.resetZoom")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.zoomToFit")}
 | 
			
		||||
                  shortcuts={["Shift+1"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.zoomToSelection")}
 | 
			
		||||
                  shortcuts={["Shift+2"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.zenMode")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+Z")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.gridMode")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
            <Column>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.editor")}>
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.selectAll")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.multiSelect")}
 | 
			
		||||
                  shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.moveCanvas")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey(`Space+${t("helpDialog.drag")}`),
 | 
			
		||||
                    getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
 | 
			
		||||
                  ]}
 | 
			
		||||
                  isOr={true}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.cut")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.copy")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.paste")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.copyAsPng")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Shift+Alt+C")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.copyStyles")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.pasteStyles")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.delete")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Del")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.sendToBack")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    isDarwin
 | 
			
		||||
                      ? getShortcutKey("CtrlOrCmd+Alt+[")
 | 
			
		||||
                      : getShortcutKey("CtrlOrCmd+Shift+["),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.bringToFront")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    isDarwin
 | 
			
		||||
                      ? getShortcutKey("CtrlOrCmd+Alt+]")
 | 
			
		||||
                      : getShortcutKey("CtrlOrCmd+Shift+]"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.sendBackward")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.bringForward")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.alignTop")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.alignBottom")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.alignLeft")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.alignRight")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.duplicateSelection")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey("CtrlOrCmd+D"),
 | 
			
		||||
                    getShortcutKey(`Alt+${t("helpDialog.drag")}`),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.undo")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("buttons.redo")}
 | 
			
		||||
                  shortcuts={
 | 
			
		||||
                    isWindows
 | 
			
		||||
                      ? [
 | 
			
		||||
                          getShortcutKey("CtrlOrCmd+Y"),
 | 
			
		||||
                          getShortcutKey("CtrlOrCmd+Shift+Z"),
 | 
			
		||||
                        ]
 | 
			
		||||
                      : [getShortcutKey("CtrlOrCmd+Shift+Z")]
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.group")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.ungroup")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
          </Columns>
 | 
			
		||||
        </Section>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { questionCircle } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
type HelpIconProps = {
 | 
			
		||||
  title?: string;
 | 
			
		||||
@@ -7,19 +8,8 @@ type HelpIconProps = {
 | 
			
		||||
  onClick?(): void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ICON = (
 | 
			
		||||
  <svg
 | 
			
		||||
    width="30"
 | 
			
		||||
    height="22"
 | 
			
		||||
    viewBox="0 0 512 512"
 | 
			
		||||
    xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
  >
 | 
			
		||||
    <path d="M528 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h480c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM128 180v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm288 0v-40c0-6.627-5.373-12-12-12H172c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h232c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12z" />
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const HelpIcon = (props: HelpIconProps) => (
 | 
			
		||||
  <label title={`${props.title} — ?`} className="help-icon">
 | 
			
		||||
    <div onClick={props.onClick}>{ICON}</div>
 | 
			
		||||
    <div onClick={props.onClick}>{questionCircle}</div>
 | 
			
		||||
  </label>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,7 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
// this is loosely based on the longest hint text
 | 
			
		||||
$wide-viewport-width: 1000px;
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .HintViewer {
 | 
			
		||||
@@ -16,17 +19,14 @@
 | 
			
		||||
    color: $oc-gray-6;
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
 | 
			
		||||
    @media (min-width: 1200px) {
 | 
			
		||||
      white-space: pre;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media #{$media-query} {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      position: static;
 | 
			
		||||
      padding-right: 2em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    > span {
 | 
			
		||||
      padding: 0.2rem 0.4rem;
 | 
			
		||||
      background-color: var(--overlay-background-color);
 | 
			
		||||
      background-color: var(--overlay-bg-color);
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .picker-container {
 | 
			
		||||
@@ -8,9 +8,9 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .picker {
 | 
			
		||||
    background: var(--popup-background-color);
 | 
			
		||||
    border: 0px solid transparentize($oc-white, 0.75);
 | 
			
		||||
    box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
 | 
			
		||||
    background: var(--popup-bg-color);
 | 
			
		||||
    border: 0 solid transparentize($oc-white, 0.75);
 | 
			
		||||
    box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
  }
 | 
			
		||||
@@ -56,8 +56,8 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .picker-triangle {
 | 
			
		||||
    width: 0px;
 | 
			
		||||
    height: 0px;
 | 
			
		||||
    width: 0;
 | 
			
		||||
    height: 0;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    top: -10px;
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
@@ -73,7 +73,7 @@
 | 
			
		||||
      content: "";
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      border-style: solid;
 | 
			
		||||
      border-width: 0px 9px 10px;
 | 
			
		||||
      border-width: 0 9px 10px;
 | 
			
		||||
      border-color: transparent transparent transparentize($oc-black, 0.9);
 | 
			
		||||
      top: -1px;
 | 
			
		||||
    }
 | 
			
		||||
@@ -82,8 +82,8 @@
 | 
			
		||||
      content: "";
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      border-style: solid;
 | 
			
		||||
      border-width: 0px 9px 10px;
 | 
			
		||||
      border-color: transparent transparent var(--popup-background-color);
 | 
			
		||||
      border-width: 0 9px 10px;
 | 
			
		||||
      border-color: transparent transparent var(--popup-bg-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -102,6 +102,7 @@
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    bottom: 2px;
 | 
			
		||||
    font-size: 0.7em;
 | 
			
		||||
    color: var(--keybinding-color);
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      right: 2px;
 | 
			
		||||
@@ -110,7 +111,7 @@
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
    @media #{$media-query} {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,29 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { setLanguageFirstTime } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
  defaultLang,
 | 
			
		||||
  Language,
 | 
			
		||||
  languages,
 | 
			
		||||
  setLanguageFirstTime,
 | 
			
		||||
} from "../i18n";
 | 
			
		||||
 | 
			
		||||
export class InitializeApp extends React.Component<
 | 
			
		||||
  any,
 | 
			
		||||
  { isLoading: boolean }
 | 
			
		||||
> {
 | 
			
		||||
interface Props {
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
}
 | 
			
		||||
interface State {
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
}
 | 
			
		||||
export class InitializeApp extends React.Component<Props, State> {
 | 
			
		||||
  public state: { isLoading: boolean } = {
 | 
			
		||||
    isLoading: true,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async componentDidMount() {
 | 
			
		||||
    await setLanguageFirstTime();
 | 
			
		||||
    const currentLang =
 | 
			
		||||
      languages.find((lang) => lang.code === this.props.langCode) ||
 | 
			
		||||
      defaultLang;
 | 
			
		||||
    await setLanguageFirstTime(currentLang);
 | 
			
		||||
    this.setState({
 | 
			
		||||
      isLoading: false,
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Island {
 | 
			
		||||
    --padding: 0;
 | 
			
		||||
    background-color: var(--bg-color-island);
 | 
			
		||||
    background-color: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: saturate(100%) blur(10px);
 | 
			
		||||
    box-shadow: var(--shadow-island);
 | 
			
		||||
    border-radius: var(--border-radius-m);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    padding: calc(var(--padding) * var(--space-factor));
 | 
			
		||||
    position: relative;
 | 
			
		||||
    transition: box-shadow 0.5s ease-in-out;
 | 
			
		||||
 
 | 
			
		||||
@@ -7,11 +7,23 @@
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    .browse-libraries {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      right: 12px;
 | 
			
		||||
      top: 16px;
 | 
			
		||||
      white-space: nowrap;
 | 
			
		||||
    .layer-ui__library-header {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      margin: 2px 0;
 | 
			
		||||
 | 
			
		||||
      button {
 | 
			
		||||
        // 2px from the left to account for focus border of left-most button
 | 
			
		||||
        margin: 0 2px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      a {
 | 
			
		||||
        margin-left: auto;
 | 
			
		||||
        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
 | 
			
		||||
        padding-right: 18px;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -59,7 +71,7 @@
 | 
			
		||||
    &__footer {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      z-index: 100;
 | 
			
		||||
      bottom: 0px;
 | 
			
		||||
      bottom: 0;
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] & {
 | 
			
		||||
        right: 0;
 | 
			
		||||
@@ -80,7 +92,7 @@
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] &.transition-right {
 | 
			
		||||
        transform: translate(999px, 0px);
 | 
			
		||||
        transform: translate(999px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="rtl"] &.transition-left {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +1,46 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import React, {
 | 
			
		||||
  RefObject,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
  RefObject,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useCallback,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { showSelectedShapeActions } from "../element";
 | 
			
		||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
 | 
			
		||||
import { exportCanvas } from "../data";
 | 
			
		||||
 | 
			
		||||
import { AppState, LibraryItems, LibraryItem } from "../types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { FixedSideContainer } from "./FixedSideContainer";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { ExportDialog, ExportCB } from "./ExportDialog";
 | 
			
		||||
import { LanguageList } from "./LanguageList";
 | 
			
		||||
import { t, languages, setLanguage } from "../i18n";
 | 
			
		||||
import { HintViewer } from "./HintViewer";
 | 
			
		||||
import { CLASSES } from "../constants";
 | 
			
		||||
import { exportCanvas } from "../data";
 | 
			
		||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import { showSelectedShapeActions } from "../element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { Language, t } from "../i18n";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
 | 
			
		||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
 | 
			
		||||
import { ExportType } from "../scene/types";
 | 
			
		||||
import { MobileMenu } from "./MobileMenu";
 | 
			
		||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
import { AppState, LibraryItem, LibraryItems } from "../types";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import CollabButton from "./CollabButton";
 | 
			
		||||
import { ErrorDialog } from "./ErrorDialog";
 | 
			
		||||
import { ShortcutsDialog } from "./ShortcutsDialog";
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { CLASSES } from "../constants";
 | 
			
		||||
import { shield, exportFile, load } from "./icons";
 | 
			
		||||
import { ExportCB, ExportDialog } from "./ExportDialog";
 | 
			
		||||
import { FixedSideContainer } from "./FixedSideContainer";
 | 
			
		||||
import { GitHubCorner } from "./GitHubCorner";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
 | 
			
		||||
import { HintViewer } from "./HintViewer";
 | 
			
		||||
import { exportFile, load, shield } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import "./LayerUI.scss";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { MobileMenu } from "./MobileMenu";
 | 
			
		||||
import { PasteChartDialog } from "./PasteChartDialog";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
import { HelpDialog } from "./HelpDialog";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import {
 | 
			
		||||
  EVENT_ACTION,
 | 
			
		||||
  EVENT_EXIT,
 | 
			
		||||
  EVENT_LIBRARY,
 | 
			
		||||
  trackEvent,
 | 
			
		||||
} from "../analytics";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
 | 
			
		||||
interface LayerUIProps {
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
@@ -60,16 +50,17 @@ interface LayerUIProps {
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  onCollabButtonClick?: () => void;
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
 | 
			
		||||
  zenModeEnabled: boolean;
 | 
			
		||||
  toggleZenMode: () => void;
 | 
			
		||||
  lng: string;
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  onExportToBackend?: (
 | 
			
		||||
    exportedElements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    canvas: HTMLCanvasElement | null,
 | 
			
		||||
  ) => void;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
@@ -123,59 +114,45 @@ const LibraryMenuItems = ({
 | 
			
		||||
  let addedPendingElements = false;
 | 
			
		||||
 | 
			
		||||
  rows.push(
 | 
			
		||||
    <>
 | 
			
		||||
      <a
 | 
			
		||||
        className="browse-libraries"
 | 
			
		||||
        href="https://libraries.excalidraw.com"
 | 
			
		||||
        target="_excalidraw_libraries"
 | 
			
		||||
    <div className="layer-ui__library-header">
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        key="import"
 | 
			
		||||
        type="button"
 | 
			
		||||
        title={t("buttons.load")}
 | 
			
		||||
        aria-label={t("buttons.load")}
 | 
			
		||||
        icon={load}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          trackEvent(EVENT_EXIT, "libraries");
 | 
			
		||||
          importLibraryFromJSON()
 | 
			
		||||
            .then(() => {
 | 
			
		||||
              // Maybe we should close and open the menu so that the items get updated.
 | 
			
		||||
              // But for now we just close the menu.
 | 
			
		||||
              setAppState({ isLibraryOpen: false });
 | 
			
		||||
            })
 | 
			
		||||
            .catch(muteFSAbortError)
 | 
			
		||||
            .catch((error) => {
 | 
			
		||||
              setAppState({ errorMessage: error.message });
 | 
			
		||||
            });
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
      />
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        key="export"
 | 
			
		||||
        type="button"
 | 
			
		||||
        title={t("buttons.export")}
 | 
			
		||||
        aria-label={t("buttons.export")}
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          saveLibraryAsJSON()
 | 
			
		||||
            .catch(muteFSAbortError)
 | 
			
		||||
            .catch((error) => {
 | 
			
		||||
              setAppState({ errorMessage: error.message });
 | 
			
		||||
            });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
 | 
			
		||||
        {t("labels.libraries")}
 | 
			
		||||
      </a>
 | 
			
		||||
 | 
			
		||||
      <Stack.Row
 | 
			
		||||
        align="center"
 | 
			
		||||
        gap={1}
 | 
			
		||||
        key={"actions"}
 | 
			
		||||
        style={{ padding: "2px" }}
 | 
			
		||||
      >
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          key="import"
 | 
			
		||||
          type="button"
 | 
			
		||||
          title={t("buttons.load")}
 | 
			
		||||
          aria-label={t("buttons.load")}
 | 
			
		||||
          icon={load}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            importLibraryFromJSON()
 | 
			
		||||
              .then(() => {
 | 
			
		||||
                // Maybe we should close and open the menu so that the items get updated.
 | 
			
		||||
                // But for now we just close the menu.
 | 
			
		||||
                setAppState({ isLibraryOpen: false });
 | 
			
		||||
              })
 | 
			
		||||
              .catch(muteFSAbortError)
 | 
			
		||||
              .catch((error) => {
 | 
			
		||||
                setAppState({ errorMessage: error.message });
 | 
			
		||||
              });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          key="export"
 | 
			
		||||
          type="button"
 | 
			
		||||
          title={t("buttons.export")}
 | 
			
		||||
          aria-label={t("buttons.export")}
 | 
			
		||||
          icon={exportFile}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            saveLibraryAsJSON()
 | 
			
		||||
              .catch(muteFSAbortError)
 | 
			
		||||
              .catch((error) => {
 | 
			
		||||
                setAppState({ errorMessage: error.message });
 | 
			
		||||
              });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </Stack.Row>
 | 
			
		||||
    </>,
 | 
			
		||||
    </div>,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  for (let row = 0; row < numRows; row++) {
 | 
			
		||||
@@ -274,7 +251,6 @@ const LibraryMenu = ({
 | 
			
		||||
    const items = await Library.loadLibrary();
 | 
			
		||||
    const nextItems = items.filter((_, index) => index !== indexToRemove);
 | 
			
		||||
    Library.saveLibrary(nextItems);
 | 
			
		||||
    trackEvent(EVENT_LIBRARY, "remove");
 | 
			
		||||
    setLibraryItems(nextItems);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
@@ -283,7 +259,6 @@ const LibraryMenu = ({
 | 
			
		||||
      const items = await Library.loadLibrary();
 | 
			
		||||
      const nextItems = [...items, elements];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      trackEvent(EVENT_LIBRARY, "add");
 | 
			
		||||
      Library.saveLibrary(nextItems);
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
@@ -318,11 +293,12 @@ const LayerUI = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  onInsertElements,
 | 
			
		||||
  zenModeEnabled,
 | 
			
		||||
  toggleZenMode,
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
}: LayerUIProps) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
@@ -334,9 +310,6 @@ const LayerUI = ({
 | 
			
		||||
      href="https://blog.excalidraw.com/end-to-end-encryption/"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        trackEvent(EVENT_EXIT, "e2ee shield");
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
 | 
			
		||||
        {shield}
 | 
			
		||||
@@ -456,7 +429,7 @@ const LayerUI = ({
 | 
			
		||||
    <LibraryMenu
 | 
			
		||||
      pendingElements={getSelectedElements(elements, appState)}
 | 
			
		||||
      onClickOutside={closeLibrary}
 | 
			
		||||
      onInsertShape={onInsertShape}
 | 
			
		||||
      onInsertShape={onInsertElements}
 | 
			
		||||
      onAddToLibrary={deselectItems}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
    />
 | 
			
		||||
@@ -558,14 +531,7 @@ const LayerUI = ({
 | 
			
		||||
          "transition-right disable-pointerEvents": zenModeEnabled,
 | 
			
		||||
        })}
 | 
			
		||||
      >
 | 
			
		||||
        <LanguageList
 | 
			
		||||
          onChange={async (lng) => {
 | 
			
		||||
            await setLanguage(lng);
 | 
			
		||||
            setAppState({});
 | 
			
		||||
          }}
 | 
			
		||||
          languages={languages}
 | 
			
		||||
          floating
 | 
			
		||||
        />
 | 
			
		||||
        {renderCustomFooter?.(false)}
 | 
			
		||||
        {actionManager.renderAction("toggleShortcuts")}
 | 
			
		||||
      </div>
 | 
			
		||||
      <button
 | 
			
		||||
@@ -580,7 +546,6 @@ const LayerUI = ({
 | 
			
		||||
        <button
 | 
			
		||||
          className="scroll-back-to-content"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            trackEvent(EVENT_ACTION, "scroll to content");
 | 
			
		||||
            setAppState({
 | 
			
		||||
              ...calculateScrollCenter(elements, appState, canvas),
 | 
			
		||||
            });
 | 
			
		||||
@@ -592,21 +557,8 @@ const LayerUI = ({
 | 
			
		||||
    </footer>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return isMobile ? (
 | 
			
		||||
    <MobileMenu
 | 
			
		||||
      appState={appState}
 | 
			
		||||
      elements={elements}
 | 
			
		||||
      actionManager={actionManager}
 | 
			
		||||
      libraryMenu={libraryMenu}
 | 
			
		||||
      exportButton={renderExportDialog()}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
      onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
      onLockToggle={onLockToggle}
 | 
			
		||||
      canvas={canvas}
 | 
			
		||||
      isCollaborating={isCollaborating}
 | 
			
		||||
    />
 | 
			
		||||
  ) : (
 | 
			
		||||
    <div className="layer-ui__wrapper">
 | 
			
		||||
  const dialogs = (
 | 
			
		||||
    <>
 | 
			
		||||
      {appState.isLoading && <LoadingMessage />}
 | 
			
		||||
      {appState.errorMessage && (
 | 
			
		||||
        <ErrorDialog
 | 
			
		||||
@@ -614,11 +566,44 @@ const LayerUI = ({
 | 
			
		||||
          onClose={() => setAppState({ errorMessage: null })}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.showShortcutsDialog && (
 | 
			
		||||
        <ShortcutsDialog
 | 
			
		||||
          onClose={() => setAppState({ showShortcutsDialog: false })}
 | 
			
		||||
      {appState.showHelpDialog && (
 | 
			
		||||
        <HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.pasteDialog.shown && (
 | 
			
		||||
        <PasteChartDialog
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          appState={appState}
 | 
			
		||||
          onInsertChart={onInsertElements}
 | 
			
		||||
          onClose={() =>
 | 
			
		||||
            setAppState({
 | 
			
		||||
              pasteDialog: { shown: false, data: null },
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return isMobile ? (
 | 
			
		||||
    <>
 | 
			
		||||
      {dialogs}
 | 
			
		||||
      <MobileMenu
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        libraryMenu={libraryMenu}
 | 
			
		||||
        exportButton={renderExportDialog()}
 | 
			
		||||
        setAppState={setAppState}
 | 
			
		||||
        onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
        onLockToggle={onLockToggle}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
        isCollaborating={isCollaborating}
 | 
			
		||||
        renderCustomFooter={renderCustomFooter}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <div className="layer-ui__wrapper">
 | 
			
		||||
      {dialogs}
 | 
			
		||||
      {renderFixedSideContainer()}
 | 
			
		||||
      {renderBottomAppMenu()}
 | 
			
		||||
      {
 | 
			
		||||
@@ -641,8 +626,6 @@ const LayerUI = ({
 | 
			
		||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
 | 
			
		||||
  const getNecessaryObj = (appState: AppState): Partial<AppState> => {
 | 
			
		||||
    const {
 | 
			
		||||
      cursorX,
 | 
			
		||||
      cursorY,
 | 
			
		||||
      suggestedBindings,
 | 
			
		||||
      startBoundElement: boundElement,
 | 
			
		||||
      ...ret
 | 
			
		||||
@@ -653,9 +636,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
 | 
			
		||||
  const nextAppState = getNecessaryObj(next.appState);
 | 
			
		||||
 | 
			
		||||
  const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    prev.lng === next.lng &&
 | 
			
		||||
    prev.langCode === next.langCode &&
 | 
			
		||||
    prev.elements === next.elements &&
 | 
			
		||||
    keys.every((key) => prevAppState[key] === nextAppState[key])
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
import React, { useRef, useEffect, useState } from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { close } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
 | 
			
		||||
// fa-plus
 | 
			
		||||
const PLUS_ICON = (
 | 
			
		||||
@@ -38,7 +38,7 @@ export const LibraryUnit = ({
 | 
			
		||||
    }
 | 
			
		||||
    const svg = exportToSvg(elementsToRender, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: "#fff",
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
    for (const child of ref.current!.children) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,8 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { t, setLanguage } from "../i18n";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { LanguageList } from "./LanguageList";
 | 
			
		||||
import { showSelectedShapeActions } from "../element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { FixedSideContainer } from "./FixedSideContainer";
 | 
			
		||||
@@ -15,10 +14,8 @@ import { Section } from "./Section";
 | 
			
		||||
import CollabButton from "./CollabButton";
 | 
			
		||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import { EVENT_ACTION, trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
type MobileMenuProps = {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
@@ -31,6 +28,7 @@ type MobileMenuProps = {
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const MobileMenu = ({
 | 
			
		||||
@@ -44,9 +42,9 @@ export const MobileMenu = ({
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
  canvas,
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
}: MobileMenuProps) => (
 | 
			
		||||
  <>
 | 
			
		||||
    {appState.isLoading && <LoadingMessage />}
 | 
			
		||||
    <FixedSideContainer side="top">
 | 
			
		||||
      <Section heading="shapes">
 | 
			
		||||
        {(heading) => (
 | 
			
		||||
@@ -104,15 +102,7 @@ export const MobileMenu = ({
 | 
			
		||||
                  appState={appState}
 | 
			
		||||
                  setAppState={setAppState}
 | 
			
		||||
                />
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                  <legend>{t("labels.language")}</legend>
 | 
			
		||||
                  <LanguageList
 | 
			
		||||
                    onChange={async (lng) => {
 | 
			
		||||
                      await setLanguage(lng);
 | 
			
		||||
                      setAppState({});
 | 
			
		||||
                    }}
 | 
			
		||||
                  />
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                {renderCustomFooter?.(true)}
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                  <legend>{t("labels.collaborators")}</legend>
 | 
			
		||||
                  <UserList mobile>
 | 
			
		||||
@@ -158,7 +148,6 @@ export const MobileMenu = ({
 | 
			
		||||
            <button
 | 
			
		||||
              className="scroll-back-to-content"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                trackEvent(EVENT_ACTION, "scroll to content");
 | 
			
		||||
                setAppState({
 | 
			
		||||
                  ...calculateScrollCenter(elements, appState, canvas),
 | 
			
		||||
                });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Modal {
 | 
			
		||||
@@ -30,18 +30,26 @@
 | 
			
		||||
    z-index: 2;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    max-width: var(--max-width);
 | 
			
		||||
    max-height: 100%;
 | 
			
		||||
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: translateY(10px);
 | 
			
		||||
    animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    overflow-y: auto;
 | 
			
		||||
 | 
			
		||||
    // for modals, reset blurry bg
 | 
			
		||||
    background: var(--bg-color-island);
 | 
			
		||||
    background: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: none;
 | 
			
		||||
 | 
			
		||||
    @media #{$media-query} {
 | 
			
		||||
    border: 1px solid var(--dialog-border);
 | 
			
		||||
    box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
 | 
			
		||||
    border-radius: 6px;
 | 
			
		||||
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      max-width: 100%;
 | 
			
		||||
      border: 0;
 | 
			
		||||
      border-radius: 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -68,13 +76,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Modal__close--floating {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: calc(var(--space-factor) * 5);
 | 
			
		||||
    top: calc(var(--space-factor) * 5);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$media-query} {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .Modal {
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -36,11 +36,7 @@ export const Modal = (props: {
 | 
			
		||||
      <div className="Modal__background" onClick={props.onCloseRequest}></div>
 | 
			
		||||
      <div
 | 
			
		||||
        className="Modal__content"
 | 
			
		||||
        style={{
 | 
			
		||||
          "--max-width": `${props.maxWidth}px`,
 | 
			
		||||
          maxHeight: "100%",
 | 
			
		||||
          overflowY: "scroll",
 | 
			
		||||
        }}
 | 
			
		||||
        style={{ "--max-width": `${props.maxWidth}px` }}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </div>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/PasteChartDialog.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,46 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .PasteChartDialog {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      .Island {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    .container {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: space-around;
 | 
			
		||||
      flex-wrap: wrap;
 | 
			
		||||
      @media #{$is-mobile-query} {
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    .ChartPreview {
 | 
			
		||||
      margin: 8px;
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      width: 192px;
 | 
			
		||||
      height: 128px;
 | 
			
		||||
      border-radius: 2px;
 | 
			
		||||
      padding: 1px;
 | 
			
		||||
      border: 1px solid $oc-gray-4;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      background: transparent;
 | 
			
		||||
      div {
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
      }
 | 
			
		||||
      svg {
 | 
			
		||||
        max-height: 120px;
 | 
			
		||||
        max-width: 186px;
 | 
			
		||||
      }
 | 
			
		||||
      &:hover {
 | 
			
		||||
        padding: 0;
 | 
			
		||||
        border: 2px solid $oc-blue-5;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										124
									
								
								src/components/PasteChartDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,124 @@
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import React, { useLayoutEffect, useRef, useState } from "react";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
 | 
			
		||||
import { ChartType } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import { AppState, LibraryItem } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import "./PasteChartDialog.scss";
 | 
			
		||||
 | 
			
		||||
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
 | 
			
		||||
 | 
			
		||||
const ChartPreviewBtn = (props: {
 | 
			
		||||
  spreadsheet: Spreadsheet | null;
 | 
			
		||||
  chartType: ChartType;
 | 
			
		||||
  selected: boolean;
 | 
			
		||||
  onClick: OnInsertChart;
 | 
			
		||||
}) => {
 | 
			
		||||
  const previewRef = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  const [chartElements, setChartElements] = useState<ChartElements | null>(
 | 
			
		||||
    null,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    if (!props.spreadsheet) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const elements = renderSpreadsheet(
 | 
			
		||||
      props.chartType,
 | 
			
		||||
      props.spreadsheet,
 | 
			
		||||
      0,
 | 
			
		||||
      0,
 | 
			
		||||
    );
 | 
			
		||||
    setChartElements(elements);
 | 
			
		||||
 | 
			
		||||
    const svg = exportToSvg(elements, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const previewNode = previewRef.current!;
 | 
			
		||||
 | 
			
		||||
    previewNode.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
    if (props.selected) {
 | 
			
		||||
      (previewNode.parentNode as HTMLDivElement).focus();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      previewNode.removeChild(svg);
 | 
			
		||||
    };
 | 
			
		||||
  }, [props.spreadsheet, props.chartType, props.selected]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      className="ChartPreview"
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        if (chartElements) {
 | 
			
		||||
          props.onClick(props.chartType, chartElements);
 | 
			
		||||
        }
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <div ref={previewRef} />
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const PasteChartDialog = ({
 | 
			
		||||
  setAppState,
 | 
			
		||||
  appState,
 | 
			
		||||
  onClose,
 | 
			
		||||
  onInsertChart,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  onInsertChart: (elements: LibraryItem) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    if (onClose) {
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
 | 
			
		||||
  const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
 | 
			
		||||
    onInsertChart(elements);
 | 
			
		||||
    trackEvent("magic", "chart", chartType);
 | 
			
		||||
    setAppState({
 | 
			
		||||
      currentChartType: chartType,
 | 
			
		||||
      pasteDialog: {
 | 
			
		||||
        shown: false,
 | 
			
		||||
        data: null,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog
 | 
			
		||||
      small
 | 
			
		||||
      onCloseRequest={handleClose}
 | 
			
		||||
      title={t("labels.pasteCharts")}
 | 
			
		||||
      className={"PasteChartDialog"}
 | 
			
		||||
      autofocus={false}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={"container"}>
 | 
			
		||||
        <ChartPreviewBtn
 | 
			
		||||
          chartType="bar"
 | 
			
		||||
          spreadsheet={appState.pasteDialog.data}
 | 
			
		||||
          selected={appState.currentChartType === "bar"}
 | 
			
		||||
          onClick={handleChartClick}
 | 
			
		||||
        />
 | 
			
		||||
        <ChartPreviewBtn
 | 
			
		||||
          chartType="line"
 | 
			
		||||
          spreadsheet={appState.pasteDialog.data}
 | 
			
		||||
          selected={appState.currentChartType === "line"}
 | 
			
		||||
          onClick={handleChartClick}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,342 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { isDarwin } from "../keys";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import "./ShortcutsDialog.scss";
 | 
			
		||||
import { EVENT_EXIT, trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
const Columns = (props: { children: React.ReactNode }) => (
 | 
			
		||||
  <div
 | 
			
		||||
    style={{
 | 
			
		||||
      display: "flex",
 | 
			
		||||
      flexDirection: "row",
 | 
			
		||||
      flexWrap: "wrap",
 | 
			
		||||
      justifyContent: "space-between",
 | 
			
		||||
    }}
 | 
			
		||||
  >
 | 
			
		||||
    {props.children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Column = (props: { children: React.ReactNode }) => (
 | 
			
		||||
  <div style={{ width: "49%" }}>{props.children}</div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const ShortcutIsland = (props: {
 | 
			
		||||
  caption: string;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}) => (
 | 
			
		||||
  <div className="ShortcutsDialog-island">
 | 
			
		||||
    <h3 className="ShortcutsDialog-island-title">{props.caption}</h3>
 | 
			
		||||
    {props.children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Shortcut = (props: {
 | 
			
		||||
  label: string;
 | 
			
		||||
  shortcuts: string[];
 | 
			
		||||
  isOr: boolean;
 | 
			
		||||
}) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ShorcutsDialog-shortcut">
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          margin: "0",
 | 
			
		||||
          padding: "4px 8px",
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            lineHeight: 1.4,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {props.label}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            flex: "0 0 auto",
 | 
			
		||||
            justifyContent: "flex-end",
 | 
			
		||||
            marginInlineStart: "auto",
 | 
			
		||||
            minWidth: "30%",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {props.shortcuts.map((shortcut, index) => (
 | 
			
		||||
            <React.Fragment key={index}>
 | 
			
		||||
              <ShortcutKey>{shortcut}</ShortcutKey>
 | 
			
		||||
              {props.isOr &&
 | 
			
		||||
                index !== props.shortcuts.length - 1 &&
 | 
			
		||||
                t("shortcutsDialog.or")}
 | 
			
		||||
            </React.Fragment>
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
Shortcut.defaultProps = {
 | 
			
		||||
  isOr: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ShortcutKey = (props: { children: React.ReactNode }) => (
 | 
			
		||||
  <span className="ShorcutsDialog-key" {...props} />
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Footer = () => (
 | 
			
		||||
  <div className="ShortcutsDialog-footer">
 | 
			
		||||
    <a
 | 
			
		||||
      href="https://blog.excalidraw.com"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        trackEvent(EVENT_EXIT, "blog");
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {t("shortcutsDialog.blog")}
 | 
			
		||||
    </a>
 | 
			
		||||
    <a
 | 
			
		||||
      href="https://howto.excalidraw.com"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        trackEvent(EVENT_EXIT, "guides");
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {t("shortcutsDialog.howto")}
 | 
			
		||||
    </a>
 | 
			
		||||
    <a
 | 
			
		||||
      href="https://github.com/excalidraw/excalidraw/issues"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        trackEvent(EVENT_EXIT, "issues");
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {t("shortcutsDialog.github")}
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    if (onClose) {
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Dialog
 | 
			
		||||
        maxWidth={900}
 | 
			
		||||
        onCloseRequest={handleClose}
 | 
			
		||||
        title={t("shortcutsDialog.title")}
 | 
			
		||||
      >
 | 
			
		||||
        <Columns>
 | 
			
		||||
          <Column>
 | 
			
		||||
            <ShortcutIsland caption={t("shortcutsDialog.shapes")}>
 | 
			
		||||
              <Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
 | 
			
		||||
              <Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
 | 
			
		||||
              <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
 | 
			
		||||
              <Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
 | 
			
		||||
              <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
			
		||||
              <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("toolBar.draw")}
 | 
			
		||||
                shortcuts={["Shift+P", "7"]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.textNewLine")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  getShortcutKey("Enter"),
 | 
			
		||||
                  getShortcutKey("Shift+Enter"),
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.textFinish")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  getShortcutKey("Esc"),
 | 
			
		||||
                  getShortcutKey("CtrlOrCmd+Enter"),
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.curvedArrow")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  "A",
 | 
			
		||||
                  t("shortcutsDialog.click"),
 | 
			
		||||
                  t("shortcutsDialog.click"),
 | 
			
		||||
                  t("shortcutsDialog.click"),
 | 
			
		||||
                ]}
 | 
			
		||||
                isOr={false}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.curvedLine")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  "L",
 | 
			
		||||
                  t("shortcutsDialog.click"),
 | 
			
		||||
                  t("shortcutsDialog.click"),
 | 
			
		||||
                  t("shortcutsDialog.click"),
 | 
			
		||||
                ]}
 | 
			
		||||
                isOr={false}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.preventBinding")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd")]}
 | 
			
		||||
              />
 | 
			
		||||
            </ShortcutIsland>
 | 
			
		||||
            <ShortcutIsland caption={t("shortcutsDialog.view")}>
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("buttons.zoomIn")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd++")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("buttons.zoomOut")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("buttons.resetZoom")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.zoomToFit")}
 | 
			
		||||
                shortcuts={["Shift+1"]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("shortcutsDialog.zoomToSelection")}
 | 
			
		||||
                shortcuts={["Shift+2"]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("buttons.zenMode")}
 | 
			
		||||
                shortcuts={[getShortcutKey("Alt+Z")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.gridMode")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
 | 
			
		||||
              />
 | 
			
		||||
            </ShortcutIsland>
 | 
			
		||||
          </Column>
 | 
			
		||||
          <Column>
 | 
			
		||||
            <ShortcutIsland caption={t("shortcutsDialog.editor")}>
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.selectAll")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.multiSelect")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  getShortcutKey(`Shift+${t("shortcutsDialog.click")}`),
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.moveCanvas")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  getShortcutKey(`Space+${t("shortcutsDialog.drag")}`),
 | 
			
		||||
                  getShortcutKey(`Wheel+${t("shortcutsDialog.drag")}`),
 | 
			
		||||
                ]}
 | 
			
		||||
                isOr={true}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.cut")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.copy")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.paste")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.copyAsPng")}
 | 
			
		||||
                shortcuts={[getShortcutKey("Shift+Alt+C")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.copyStyles")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.pasteStyles")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.delete")}
 | 
			
		||||
                shortcuts={[getShortcutKey("Del")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.sendToBack")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  isDarwin
 | 
			
		||||
                    ? getShortcutKey("CtrlOrCmd+Alt+[")
 | 
			
		||||
                    : getShortcutKey("CtrlOrCmd+Shift+["),
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.bringToFront")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  isDarwin
 | 
			
		||||
                    ? getShortcutKey("CtrlOrCmd+Alt+]")
 | 
			
		||||
                    : getShortcutKey("CtrlOrCmd+Shift+]"),
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.sendBackward")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.bringForward")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.alignTop")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.alignBottom")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.alignLeft")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.alignRight")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.duplicateSelection")}
 | 
			
		||||
                shortcuts={[
 | 
			
		||||
                  getShortcutKey("CtrlOrCmd+D"),
 | 
			
		||||
                  getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("buttons.undo")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("buttons.redo")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.group")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
 | 
			
		||||
              />
 | 
			
		||||
              <Shortcut
 | 
			
		||||
                label={t("labels.ungroup")}
 | 
			
		||||
                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
 | 
			
		||||
              />
 | 
			
		||||
            </ShortcutIsland>
 | 
			
		||||
          </Column>
 | 
			
		||||
        </Columns>
 | 
			
		||||
        <Footer />
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,51 +1,53 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.Stats {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 64px;
 | 
			
		||||
  right: 12px;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  z-index: 999;
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Stats {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    top: 64px;
 | 
			
		||||
    right: 12px;
 | 
			
		||||
    font-size: 12px;
 | 
			
		||||
    z-index: 999;
 | 
			
		||||
 | 
			
		||||
  h3 {
 | 
			
		||||
    margin: 0 24px 8px 0;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
  }
 | 
			
		||||
    h3 {
 | 
			
		||||
      margin: 0 24px 8px 0;
 | 
			
		||||
      white-space: nowrap;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  .close {
 | 
			
		||||
    float: right;
 | 
			
		||||
    height: 16px;
 | 
			
		||||
    width: 16px;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    svg {
 | 
			
		||||
    .close {
 | 
			
		||||
      float: right;
 | 
			
		||||
      height: 16px;
 | 
			
		||||
      width: 16px;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    table {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      th {
 | 
			
		||||
        border-bottom: 1px solid var(--input-border-color);
 | 
			
		||||
        padding: 4px;
 | 
			
		||||
      }
 | 
			
		||||
      tr {
 | 
			
		||||
        td:nth-child(2) {
 | 
			
		||||
          min-width: 24px;
 | 
			
		||||
          text-align: right;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  table {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    th {
 | 
			
		||||
      border-bottom: 1px solid var(--input-border-color);
 | 
			
		||||
      padding: 4px;
 | 
			
		||||
    }
 | 
			
		||||
    tr {
 | 
			
		||||
      td:nth-child(2) {
 | 
			
		||||
        min-width: 24px;
 | 
			
		||||
        text-align: right;
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      left: 12px;
 | 
			
		||||
      right: initial;
 | 
			
		||||
 | 
			
		||||
      h3 {
 | 
			
		||||
        margin: 0 0 8px 24px;
 | 
			
		||||
      }
 | 
			
		||||
      .close {
 | 
			
		||||
        float: left;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  :root[dir="rtl"] & {
 | 
			
		||||
    left: 12px;
 | 
			
		||||
    right: initial;
 | 
			
		||||
 | 
			
		||||
    h3 {
 | 
			
		||||
      margin: 0 0 8px 24px;
 | 
			
		||||
    }
 | 
			
		||||
    .close {
 | 
			
		||||
      float: left;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -85,7 +85,6 @@ export const Stats = (props: {
 | 
			
		||||
              <td>{t("stats.total")}</td>
 | 
			
		||||
              <td>{nFormatter(storageSizes.total, 1)}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
 | 
			
		||||
            {selectedElements.length === 1 && (
 | 
			
		||||
              <tr>
 | 
			
		||||
                <th colSpan={2}>{t("stats.element")}</th>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "../css/_variables.scss";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .TextInput {
 | 
			
		||||
@@ -9,11 +9,11 @@
 | 
			
		||||
    padding: 0.75rem;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    border-radius: var(--space-factor);
 | 
			
		||||
    background-color: var(--input-background-color);
 | 
			
		||||
    background-color: var(--input-bg-color);
 | 
			
		||||
 | 
			
		||||
    &:not(:focus) {
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: var(--input-hover-background-color);
 | 
			
		||||
        background-color: var(--input-hover-bg-color);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										32
									
								
								src/components/Toast.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,32 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Toast {
 | 
			
		||||
    animation: fade-in 0.5s;
 | 
			
		||||
    background-color: var(--button-gray-1);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    bottom: 10px;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    cursor: default;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    margin-left: -150px;
 | 
			
		||||
    padding: 4px 0;
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    width: 300px;
 | 
			
		||||
    z-index: 999999;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Toast__message {
 | 
			
		||||
    color: var(--popup-text-color);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @keyframes fade-in {
 | 
			
		||||
    from {
 | 
			
		||||
      opacity: 0;
 | 
			
		||||
    }
 | 
			
		||||
    to {
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/components/Toast.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,34 @@
 | 
			
		||||
import React, { useCallback, useEffect, useRef } from "react";
 | 
			
		||||
import { TOAST_TIMEOUT } from "../constants";
 | 
			
		||||
import "./Toast.scss";
 | 
			
		||||
 | 
			
		||||
export const Toast = ({
 | 
			
		||||
  message,
 | 
			
		||||
  clearToast,
 | 
			
		||||
}: {
 | 
			
		||||
  message: string;
 | 
			
		||||
  clearToast: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const timerRef = useRef<number>(0);
 | 
			
		||||
 | 
			
		||||
  const scheduleTimeout = useCallback(
 | 
			
		||||
    () =>
 | 
			
		||||
      (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
 | 
			
		||||
    [clearToast],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    scheduleTimeout();
 | 
			
		||||
    return () => clearTimeout(timerRef.current);
 | 
			
		||||
  }, [scheduleTimeout, message]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className="Toast"
 | 
			
		||||
      onMouseEnter={() => clearTimeout(timerRef?.current)}
 | 
			
		||||
      onMouseLeave={scheduleTimeout}
 | 
			
		||||
    >
 | 
			
		||||
      <p className="Toast__message">{message}</p>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
@import "../css/variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .ToolIcon {
 | 
			
		||||
@@ -142,6 +142,7 @@
 | 
			
		||||
    user-select: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // shrink shape icons on small viewports to make them fit
 | 
			
		||||
  @media (max-width: 425px) {
 | 
			
		||||
    .Shape .ToolIcon__icon {
 | 
			
		||||
      width: 2rem;
 | 
			
		||||
@@ -153,6 +154,8 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // move the lock button out of the way on small viewports
 | 
			
		||||
  // it begins to collide with the GitHub icon before we switch to mobile mode
 | 
			
		||||
  @media (max-width: 760px) {
 | 
			
		||||
    .ToolIcon.ToolIcon__lock {
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
@@ -162,6 +165,7 @@
 | 
			
		||||
 | 
			
		||||
      margin-left: 0;
 | 
			
		||||
      border-radius: 20px 0 0 20px;
 | 
			
		||||
      z-index: 1;
 | 
			
		||||
 | 
			
		||||
      background-color: var(--button-gray-1);
 | 
			
		||||
 | 
			
		||||
@@ -189,7 +193,7 @@
 | 
			
		||||
    margin-left: 5px;
 | 
			
		||||
    margin-top: 1px;
 | 
			
		||||
 | 
			
		||||
    @media #{$media-query} {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "../css/_variables";
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Tooltip {
 | 
			
		||||
    position: relative;
 | 
			
		||||
 
 | 
			
		||||
@@ -2,50 +2,50 @@ import { FontFamily } from "./element/types";
 | 
			
		||||
 | 
			
		||||
export const APP_NAME = "Excalidraw";
 | 
			
		||||
 | 
			
		||||
export const DRAGGING_THRESHOLD = 10; // 10px
 | 
			
		||||
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
 | 
			
		||||
export const DRAGGING_THRESHOLD = 10;
 | 
			
		||||
export const LINE_CONFIRM_THRESHOLD = 10;
 | 
			
		||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
 | 
			
		||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
 | 
			
		||||
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
 | 
			
		||||
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
 | 
			
		||||
export const CURSOR_TYPE = {
 | 
			
		||||
  TEXT: "text",
 | 
			
		||||
  AUTO: "",
 | 
			
		||||
  CROSSHAIR: "crosshair",
 | 
			
		||||
  GRABBING: "grabbing",
 | 
			
		||||
  POINTER: "pointer",
 | 
			
		||||
  MOVE: "move",
 | 
			
		||||
  AUTO: "",
 | 
			
		||||
  POINTER: "pointer",
 | 
			
		||||
  TEXT: "text",
 | 
			
		||||
};
 | 
			
		||||
export const POINTER_BUTTON = {
 | 
			
		||||
  MAIN: 0,
 | 
			
		||||
  WHEEL: 1,
 | 
			
		||||
  SECONDARY: 2,
 | 
			
		||||
  TOUCH: -1,
 | 
			
		||||
  WHEEL: 1,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export enum EVENT {
 | 
			
		||||
  BEFORE_UNLOAD = "beforeunload",
 | 
			
		||||
  BLUR = "blur",
 | 
			
		||||
  COPY = "copy",
 | 
			
		||||
  PASTE = "paste",
 | 
			
		||||
  CUT = "cut",
 | 
			
		||||
  DRAG_OVER = "dragover",
 | 
			
		||||
  DROP = "drop",
 | 
			
		||||
  GESTURE_CHANGE = "gesturechange",
 | 
			
		||||
  GESTURE_END = "gestureend",
 | 
			
		||||
  GESTURE_START = "gesturestart",
 | 
			
		||||
  HASHCHANGE = "hashchange",
 | 
			
		||||
  KEYDOWN = "keydown",
 | 
			
		||||
  KEYUP = "keyup",
 | 
			
		||||
  MOUSE_MOVE = "mousemove",
 | 
			
		||||
  RESIZE = "resize",
 | 
			
		||||
  UNLOAD = "unload",
 | 
			
		||||
  BLUR = "blur",
 | 
			
		||||
  DRAG_OVER = "dragover",
 | 
			
		||||
  DROP = "drop",
 | 
			
		||||
  GESTURE_END = "gestureend",
 | 
			
		||||
  BEFORE_UNLOAD = "beforeunload",
 | 
			
		||||
  GESTURE_START = "gesturestart",
 | 
			
		||||
  GESTURE_CHANGE = "gesturechange",
 | 
			
		||||
  PASTE = "paste",
 | 
			
		||||
  POINTER_MOVE = "pointermove",
 | 
			
		||||
  POINTER_UP = "pointerup",
 | 
			
		||||
  RESIZE = "resize",
 | 
			
		||||
  STATE_CHANGE = "statechange",
 | 
			
		||||
  WHEEL = "wheel",
 | 
			
		||||
  TOUCH_START = "touchstart",
 | 
			
		||||
  TOUCH_END = "touchend",
 | 
			
		||||
  HASHCHANGE = "hashchange",
 | 
			
		||||
  TOUCH_START = "touchstart",
 | 
			
		||||
  UNLOAD = "unload",
 | 
			
		||||
  WHEEL = "wheel",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ENV = {
 | 
			
		||||
@@ -66,14 +66,15 @@ export const FONT_FAMILY = {
 | 
			
		||||
 | 
			
		||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_FONT_SIZE = 20;
 | 
			
		||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
 | 
			
		||||
export const DEFAULT_FONT_SIZE = 20;
 | 
			
		||||
export const DEFAULT_TEXT_ALIGN = "left";
 | 
			
		||||
export const DEFAULT_VERSION = "{version}";
 | 
			
		||||
export const DEFAULT_VERTICAL_ALIGN = "top";
 | 
			
		||||
 | 
			
		||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
 | 
			
		||||
 | 
			
		||||
export const GRID_SIZE = 20;
 | 
			
		||||
export const GRID_SIZE = 20; // TODO make it configurable?
 | 
			
		||||
 | 
			
		||||
export const MIME_TYPES = {
 | 
			
		||||
  excalidraw: "application/vnd.excalidraw+json",
 | 
			
		||||
@@ -84,7 +85,11 @@ export const STORAGE_KEYS = {
 | 
			
		||||
  LOCAL_STORAGE_LIBRARY: "excalidraw-library",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// time in milliseconds
 | 
			
		||||
// Time in milliseconds
 | 
			
		||||
export const TAP_TWICE_TIMEOUT = 300;
 | 
			
		||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
 | 
			
		||||
export const TITLE_TIMEOUT = 10000;
 | 
			
		||||
export const TOAST_TIMEOUT = 5000;
 | 
			
		||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
 | 
			
		||||
export const VERSION_TIMEOUT = 15000;
 | 
			
		||||
 | 
			
		||||
export const ZOOM_STEP = 0.1;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								src/createInverseContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,42 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
export const createInverseContext = <T extends unknown = null>(
 | 
			
		||||
  initialValue: T,
 | 
			
		||||
) => {
 | 
			
		||||
  const Context = React.createContext(initialValue) as React.Context<T> & {
 | 
			
		||||
    _updateProviderValue?: (value: T) => void;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  class InverseConsumer extends React.Component {
 | 
			
		||||
    state = { value: initialValue };
 | 
			
		||||
    constructor(props: any) {
 | 
			
		||||
      super(props);
 | 
			
		||||
      Context._updateProviderValue = (value: T) => this.setState({ value });
 | 
			
		||||
    }
 | 
			
		||||
    render() {
 | 
			
		||||
      return (
 | 
			
		||||
        <Context.Provider value={this.state.value}>
 | 
			
		||||
          {this.props.children}
 | 
			
		||||
        </Context.Provider>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  class InverseProvider extends React.Component<{ value: T }> {
 | 
			
		||||
    componentDidMount() {
 | 
			
		||||
      Context._updateProviderValue?.(this.props.value);
 | 
			
		||||
    }
 | 
			
		||||
    componentDidUpdate() {
 | 
			
		||||
      Context._updateProviderValue?.(this.props.value);
 | 
			
		||||
    }
 | 
			
		||||
    render() {
 | 
			
		||||
      return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    Context,
 | 
			
		||||
    Consumer: InverseConsumer,
 | 
			
		||||
    Provider: InverseProvider,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
 | 
			
		||||
$media-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import "./_variables";
 | 
			
		||||
@import "./variables.module";
 | 
			
		||||
@import "./theme";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
@@ -13,7 +13,7 @@
 | 
			
		||||
  a {
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    color: $oc-blue-7; /* OC Blue 7 */
 | 
			
		||||
    color: var(--link-color);
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      text-decoration: underline;
 | 
			
		||||
@@ -43,10 +43,10 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .FixedSideContainer {
 | 
			
		||||
    padding-top: var(--sat, 0px);
 | 
			
		||||
    padding-right: var(--sar, 0px);
 | 
			
		||||
    padding-bottom: var(--sab, 0px);
 | 
			
		||||
    padding-left: var(--sal, 0px);
 | 
			
		||||
    padding-top: var(--sat, 0);
 | 
			
		||||
    padding-right: var(--sar, 0);
 | 
			
		||||
    padding-bottom: var(--sab, 0);
 | 
			
		||||
    padding-left: var(--sal, 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .panelRow {
 | 
			
		||||
@@ -223,10 +223,10 @@
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    --bar-padding: calc(4 * var(--space-factor));
 | 
			
		||||
    padding-top: #{"max(var(--bar-padding), var(--sat, 0px))"};
 | 
			
		||||
    padding-right: var(--sar, 0px);
 | 
			
		||||
    padding-bottom: var(--sab, 0px);
 | 
			
		||||
    padding-left: var(--sal, 0px);
 | 
			
		||||
    padding-top: #{"max(var(--bar-padding), var(--sat, 0))"};
 | 
			
		||||
    padding-right: var(--sar, 0);
 | 
			
		||||
    padding-bottom: var(--sab, 0);
 | 
			
		||||
    padding-left: var(--sal, 0);
 | 
			
		||||
    z-index: 4;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: flex-end;
 | 
			
		||||
@@ -243,7 +243,7 @@
 | 
			
		||||
      pointer-events: initial;
 | 
			
		||||
 | 
			
		||||
      .panelColumn {
 | 
			
		||||
        padding: 8px 8px 0px 8px;
 | 
			
		||||
        padding: 8px 8px 0 8px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -431,6 +431,7 @@
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    fill: $oc-gray-6;
 | 
			
		||||
    bottom: 14px;
 | 
			
		||||
    width: 1.5rem;
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      right: 14px;
 | 
			
		||||
@@ -441,12 +442,12 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$media-query} {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    aside {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
    .scroll-back-to-content {
 | 
			
		||||
      bottom: calc(80px + var(--sab, 0px));
 | 
			
		||||
      bottom: calc(80px + var(--sab, 0));
 | 
			
		||||
      z-index: -1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,45 +1,43 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
 | 
			
		||||
:root {
 | 
			
		||||
  --bg-color-island: rgba(255, 255, 255, 0.9);
 | 
			
		||||
  --popup-background-color: #{$oc-white};
 | 
			
		||||
  --border-radius-m: 4px;
 | 
			
		||||
  --space-factor: 0.25rem;
 | 
			
		||||
  --appearance-filter: none;
 | 
			
		||||
  --button-destructive-bg-color: #{$oc-red-1};
 | 
			
		||||
  --button-destructive-color: #{$oc-red-9};
 | 
			
		||||
  --button-gray-1: #{$oc-gray-2};
 | 
			
		||||
  --button-gray-2: #{$oc-gray-4};
 | 
			
		||||
  --button-gray-3: #{$oc-gray-5};
 | 
			
		||||
  --input-border-color: #{$oc-gray-3};
 | 
			
		||||
  --input-background-color: #{$oc-white};
 | 
			
		||||
  --input-hover-background-color: #{$oc-gray-1};
 | 
			
		||||
  --input-label-color: #{$oc-gray-7};
 | 
			
		||||
  --button-special-active-bg-color: #{$oc-green-0};
 | 
			
		||||
  --dialog-border: #{$oc-gray-6};
 | 
			
		||||
  --dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
 | 
			
		||||
  --focus-highlight-color: #{$oc-blue-2};
 | 
			
		||||
  --icon-fill-color: #{$oc-black};
 | 
			
		||||
  --icon-green-fill-color: #{$oc-green-9};
 | 
			
		||||
  --input-bg-color: #{$oc-white};
 | 
			
		||||
  --input-border-color: #{$oc-gray-3};
 | 
			
		||||
  --input-hover-bg-color: #{$oc-gray-1};
 | 
			
		||||
  --input-label-color: #{$oc-gray-7};
 | 
			
		||||
  --island-bg-color: #{transparentize($oc-white, 0.12)};
 | 
			
		||||
  --keybinding-color: #{$oc-gray-5};
 | 
			
		||||
  --color-overlay-text-color: #ccc;
 | 
			
		||||
  --sat: env(safe-area-inset-top);
 | 
			
		||||
  --link-color: #{$oc-blue-7};
 | 
			
		||||
  --overlay-bg-color: #{transparentize($oc-white, 0.12)};
 | 
			
		||||
  --popup-bg-color: #{$oc-white};
 | 
			
		||||
  --popup-secondary-bg-color: #{$oc-gray-1};
 | 
			
		||||
  --popup-text-color: #{$oc-black};
 | 
			
		||||
  --popup-text-inverted-color: #{$oc-white};
 | 
			
		||||
  --sab: env(safe-area-inset-bottom);
 | 
			
		||||
  --sal: env(safe-area-inset-left);
 | 
			
		||||
  --sar: env(safe-area-inset-right);
 | 
			
		||||
  --text-color-primary: #{$oc-gray-8};
 | 
			
		||||
  --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
 | 
			
		||||
  --overlay-background-color: #{transparentize($oc-white, 0.12)};
 | 
			
		||||
  --border-radius-m: 4px;
 | 
			
		||||
  --space-factor: 0.25rem;
 | 
			
		||||
  --dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
 | 
			
		||||
  --focus-highlight-color: #{$oc-blue-2};
 | 
			
		||||
  --sat: env(safe-area-inset-top);
 | 
			
		||||
  --select-highlight-color: #{$oc-blue-5};
 | 
			
		||||
  --appearance-filter: none;
 | 
			
		||||
  --button-special-active-background-color: #{$oc-green-0};
 | 
			
		||||
  --button-destructive-color: #{$oc-red-9};
 | 
			
		||||
  --button-destructive-background-color: #{$oc-red-1};
 | 
			
		||||
  --popup-secondary-background-color: #{$oc-gray-1};
 | 
			
		||||
  --popup-text-color: #{$oc-black};
 | 
			
		||||
  --popup-text-inverted-color: #{$oc-white};
 | 
			
		||||
  --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
 | 
			
		||||
  --space-factor: 0.25rem;
 | 
			
		||||
  --text-color-primary: #{$oc-gray-8};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  &.Appearance_dark {
 | 
			
		||||
    background: #000;
 | 
			
		||||
    background: $oc-black;
 | 
			
		||||
 | 
			
		||||
    &.Appearance_dark-background-none {
 | 
			
		||||
      background: none;
 | 
			
		||||
@@ -47,32 +45,29 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.Appearance_dark {
 | 
			
		||||
    --text-color-primary: #{$oc-gray-4};
 | 
			
		||||
    --bg-color-island: #1e1e1e;
 | 
			
		||||
    --popup-background-color: #2c2c2c;
 | 
			
		||||
    --appearance-filter: invert(93%) hue-rotate(180deg);
 | 
			
		||||
    --button-destructive-bg-color: #5a0000;
 | 
			
		||||
    --button-destructive-color: #{$oc-red-3};
 | 
			
		||||
    --button-gray-1: #363636;
 | 
			
		||||
    --button-gray-2: #272727;
 | 
			
		||||
    --button-gray-3: #222;
 | 
			
		||||
    --input-border-color: #2e2e2e;
 | 
			
		||||
    --input-background-color: #121212;
 | 
			
		||||
    --input-hover-background-color: #181818;
 | 
			
		||||
    --input-label-color: #{$oc-gray-2};
 | 
			
		||||
    --icon-fill-color: #{$oc-gray-4};
 | 
			
		||||
    --icon-green-fill-color: #{$oc-green-4};
 | 
			
		||||
    --keybinding-color: #{$oc-gray-6};
 | 
			
		||||
    --color-overlay-text-color: #bbb;
 | 
			
		||||
    --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
 | 
			
		||||
    --overlay-background-color: rgba(30, 30, 30, 0.88);
 | 
			
		||||
    // #{$oc-gray-4}; inlined
 | 
			
		||||
    --button-special-active-bg-color: #204624;
 | 
			
		||||
    --dialog-border: #{$oc-gray-9};
 | 
			
		||||
    --dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
 | 
			
		||||
    --focus-highlight-color: #{$oc-blue-6};
 | 
			
		||||
    --select-highlight-color: #{$oc-blue-4};
 | 
			
		||||
    --appearance-filter: invert(93%) hue-rotate(180deg);
 | 
			
		||||
    --button-special-active-background-color: #204624;
 | 
			
		||||
    --button-destructive-color: #{$oc-red-3};
 | 
			
		||||
    --button-destructive-background-color: #5a0000;
 | 
			
		||||
    --popup-secondary-background-color: #222;
 | 
			
		||||
    --icon-fill-color: #{$oc-gray-4};
 | 
			
		||||
    --icon-green-fill-color: #{$oc-green-4};
 | 
			
		||||
    --input-bg-color: #121212;
 | 
			
		||||
    --input-border-color: #2e2e2e;
 | 
			
		||||
    --input-hover-bg-color: #181818;
 | 
			
		||||
    --input-label-color: #{$oc-gray-2};
 | 
			
		||||
    --island-bg-color: #1e1e1e;
 | 
			
		||||
    --keybinding-color: #{$oc-gray-6};
 | 
			
		||||
    --overlay-bg-color: rgba(30, 30, 30, 0.88);
 | 
			
		||||
    --popup-secondary-bg-color: #222;
 | 
			
		||||
    --popup-text-color: #{$oc-gray-4};
 | 
			
		||||
    --popup-text-inverted-color: #2c2c2c;
 | 
			
		||||
    --select-highlight-color: #{$oc-blue-4};
 | 
			
		||||
    --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								src/css/variables.module.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,8 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
 | 
			
		||||
// Keep up to date with is-mobile.tsx
 | 
			
		||||
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
 | 
			
		||||
 | 
			
		||||
:export {
 | 
			
		||||
  isMobileQuery: unquote($is-mobile-query);
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import { EVENT_IO, trackEvent } from "../analytics";
 | 
			
		||||
import { cleanAppStateForExport } from "../appState";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { clearElementsForExport } from "../element";
 | 
			
		||||
@@ -111,7 +110,6 @@ export const loadFromBlob = async (
 | 
			
		||||
      localAppState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    trackEvent(EVENT_IO, "load", getMimeType(blob));
 | 
			
		||||
    return result;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error.message);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import { fileSave } from "browser-nativefs";
 | 
			
		||||
import { EVENT_IO, trackEvent } from "../analytics";
 | 
			
		||||
import { fileSave } from "browser-fs-access";
 | 
			
		||||
import {
 | 
			
		||||
  copyCanvasToClipboardAsPng,
 | 
			
		||||
  copyTextToSystemClipboard,
 | 
			
		||||
@@ -8,8 +7,8 @@ import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { exportToCanvas, exportToSvg } from "../scene/export";
 | 
			
		||||
import { ExportType } from "../scene/types";
 | 
			
		||||
import { canvasToBlob } from "./blob";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { canvasToBlob } from "./blob";
 | 
			
		||||
import { serializeAsJSON } from "./json";
 | 
			
		||||
 | 
			
		||||
export { loadFromBlob } from "./blob";
 | 
			
		||||
@@ -37,7 +36,7 @@ export const exportCanvas = async (
 | 
			
		||||
  },
 | 
			
		||||
) => {
 | 
			
		||||
  if (elements.length === 0) {
 | 
			
		||||
    return window.alert(t("alerts.cannotExportEmptyCanvas"));
 | 
			
		||||
    throw new Error(t("alerts.cannotExportEmptyCanvas"));
 | 
			
		||||
  }
 | 
			
		||||
  if (type === "svg" || type === "clipboard-svg") {
 | 
			
		||||
    const tempSvg = exportToSvg(elements, {
 | 
			
		||||
@@ -60,10 +59,8 @@ export const exportCanvas = async (
 | 
			
		||||
        fileName: `${name}.svg`,
 | 
			
		||||
        extensions: [".svg"],
 | 
			
		||||
      });
 | 
			
		||||
      trackEvent(EVENT_IO, "export", "svg");
 | 
			
		||||
      return;
 | 
			
		||||
    } else if (type === "clipboard-svg") {
 | 
			
		||||
      trackEvent(EVENT_IO, "export", "clipboard-svg");
 | 
			
		||||
      copyTextToSystemClipboard(tempSvg.outerHTML);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
@@ -95,11 +92,9 @@ export const exportCanvas = async (
 | 
			
		||||
      fileName,
 | 
			
		||||
      extensions: [".png"],
 | 
			
		||||
    });
 | 
			
		||||
    trackEvent(EVENT_IO, "export", "png");
 | 
			
		||||
  } else if (type === "clipboard") {
 | 
			
		||||
    try {
 | 
			
		||||
      await copyCanvasToClipboardAsPng(tempCanvas);
 | 
			
		||||
      trackEvent(EVENT_IO, "export", "clipboard-png");
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
 | 
			
		||||
        throw error;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,11 @@
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { fileOpen, fileSave } from "browser-fs-access";
 | 
			
		||||
import { cleanAppStateForExport } from "../appState";
 | 
			
		||||
 | 
			
		||||
import { fileOpen, fileSave } from "browser-nativefs";
 | 
			
		||||
import { loadFromBlob } from "./blob";
 | 
			
		||||
import { Library } from "./library";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { clearElementsForExport } from "../element";
 | 
			
		||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { loadFromBlob } from "./blob";
 | 
			
		||||
import { Library } from "./library";
 | 
			
		||||
 | 
			
		||||
export const serializeAsJSON = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -84,7 +82,6 @@ export const saveLibraryAsJSON = async () => {
 | 
			
		||||
    description: "Excalidraw library file",
 | 
			
		||||
    extensions: [".excalidrawlib"],
 | 
			
		||||
  });
 | 
			
		||||
  trackEvent(EVENT_LIBRARY, "save");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const importLibraryFromJSON = async () => {
 | 
			
		||||
@@ -93,6 +90,5 @@ export const importLibraryFromJSON = async () => {
 | 
			
		||||
    extensions: [".json", ".excalidrawlib"],
 | 
			
		||||
    mimeTypes: ["application/json"],
 | 
			
		||||
  });
 | 
			
		||||
  trackEvent(EVENT_LIBRARY, "load");
 | 
			
		||||
  Library.importLibrary(blob);
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,6 @@ export {
 | 
			
		||||
export {
 | 
			
		||||
  resizeTest,
 | 
			
		||||
  getCursorForResizingElement,
 | 
			
		||||
  normalizeTransformHandleType,
 | 
			
		||||
  getElementWithTransformHandleType,
 | 
			
		||||
  getTransformHandleTypeFromCoords,
 | 
			
		||||
} from "./resizeTest";
 | 
			
		||||
 
 | 
			
		||||