mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			aakansha-c
			...
			aakansha-c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					016b054288 | 
							
								
								
									
										5
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +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://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,13 +0,0 @@
 | 
				
			|||||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
 | 
					 | 
				
			||||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
 | 
					 | 
				
			||||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
 | 
					 | 
				
			||||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# set this only if using the collaboration workflow we use on excalidraw.com
 | 
					 | 
				
			||||||
REACT_APP_PORTAL_URL=
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
 | 
					 | 
				
			||||||
@@ -1,15 +1 @@
 | 
				
			|||||||
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_LIBRARY_URL=https://libraries.excalidraw.com
 | 
					 | 
				
			||||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
 | 
					 | 
				
			||||||
# Fill to set socket server URL used for collaboration.
 | 
					 | 
				
			||||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
 | 
					 | 
				
			||||||
REACT_APP_WS_SERVER_URL=
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"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"}'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# production-only vars
 | 
					 | 
				
			||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
					REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,4 +5,3 @@ package-lock.json
 | 
				
			|||||||
firebase/
 | 
					firebase/
 | 
				
			||||||
dist/
 | 
					dist/
 | 
				
			||||||
public/workbox
 | 
					public/workbox
 | 
				
			||||||
src/packages/excalidraw/types
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "extends": ["@excalidraw/eslint-config", "react-app"],
 | 
					  "extends": ["@excalidraw/eslint-config", "react-app"],
 | 
				
			||||||
  "rules": {
 | 
					  "rules": {
 | 
				
			||||||
    "import/no-anonymous-default-export": "off",
 | 
					    "import/no-anonymous-default-export": "off"
 | 
				
			||||||
    "no-restricted-globals": "off"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@@ -10,7 +10,6 @@ updates:
 | 
				
			|||||||
      - lipis
 | 
					      - lipis
 | 
				
			||||||
    assignees:
 | 
					    assignees:
 | 
				
			||||||
      - lipis
 | 
					      - lipis
 | 
				
			||||||
    open-pull-requests-limit: 20
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  - package-ecosystem: npm
 | 
					  - package-ecosystem: npm
 | 
				
			||||||
    directory: /src/packages/excalidraw/
 | 
					    directory: /src/packages/excalidraw/
 | 
				
			||||||
@@ -22,7 +21,6 @@ updates:
 | 
				
			|||||||
      - ad1992
 | 
					      - ad1992
 | 
				
			||||||
    assignees:
 | 
					    assignees:
 | 
				
			||||||
      - ad1992
 | 
					      - ad1992
 | 
				
			||||||
    open-pull-requests-limit: 20
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  - package-ecosystem: npm
 | 
					  - package-ecosystem: npm
 | 
				
			||||||
    directory: /src/packages/utils/
 | 
					    directory: /src/packages/utils/
 | 
				
			||||||
@@ -34,4 +32,3 @@ updates:
 | 
				
			|||||||
      - ad1992
 | 
					      - ad1992
 | 
				
			||||||
    assignees:
 | 
					    assignees:
 | 
				
			||||||
      - ad1992
 | 
					      - ad1992
 | 
				
			||||||
    open-pull-requests-limit: 20
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,27 +0,0 @@
 | 
				
			|||||||
name: Auto release @excalidraw/excalidraw-next
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
  push:
 | 
					 | 
				
			||||||
    branches:
 | 
					 | 
				
			||||||
      - master
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
  Auto-release-excalidraw-next:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          fetch-depth: 2
 | 
					 | 
				
			||||||
      - name: Setup Node.js 14.x
 | 
					 | 
				
			||||||
        uses: actions/setup-node@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          node-version: 14.x
 | 
					 | 
				
			||||||
      - name: Set up publish access
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 | 
					 | 
				
			||||||
      - name: Auto release
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          yarn add @actions/core
 | 
					 | 
				
			||||||
          yarn autorelease
 | 
					 | 
				
			||||||
							
								
								
									
										55
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										55
									
								
								.github/workflows/autorelease-preview.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,55 +0,0 @@
 | 
				
			|||||||
name: Auto release preview @excalidraw/excalidraw-preview
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
  issue_comment:
 | 
					 | 
				
			||||||
    types: [created, edited]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
  Auto-release-excalidraw-preview:
 | 
					 | 
				
			||||||
    name: Auto release preview
 | 
					 | 
				
			||||||
    if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - name: React to release comment
 | 
					 | 
				
			||||||
        uses: peter-evans/create-or-update-comment@v1
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 | 
					 | 
				
			||||||
          comment-id: ${{ github.event.comment.id }}
 | 
					 | 
				
			||||||
          reactions: "+1"
 | 
					 | 
				
			||||||
      - name: Get PR SHA
 | 
					 | 
				
			||||||
        id: sha
 | 
					 | 
				
			||||||
        uses: actions/github-script@v4
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          result-encoding: string
 | 
					 | 
				
			||||||
          script: |
 | 
					 | 
				
			||||||
            const { owner, repo, number } = context.issue;
 | 
					 | 
				
			||||||
            const pr = await github.pulls.get({
 | 
					 | 
				
			||||||
              owner,
 | 
					 | 
				
			||||||
              repo,
 | 
					 | 
				
			||||||
              pull_number: number,
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
            return pr.data.head.sha
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          ref: ${{ steps.sha.outputs.result }}
 | 
					 | 
				
			||||||
          fetch-depth: 2
 | 
					 | 
				
			||||||
      - name: Setup Node.js 14.x
 | 
					 | 
				
			||||||
        uses: actions/setup-node@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          node-version: 14.x
 | 
					 | 
				
			||||||
      - name: Set up publish access
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 | 
					 | 
				
			||||||
      - name: Auto release preview
 | 
					 | 
				
			||||||
        id: "autorelease"
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          yarn add @actions/core
 | 
					 | 
				
			||||||
          yarn autorelease preview ${{ github.event.issue.number }}
 | 
					 | 
				
			||||||
      - name: Post comment post release
 | 
					 | 
				
			||||||
        if: always()
 | 
					 | 
				
			||||||
        uses: peter-evans/create-or-update-comment@v1
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 | 
					 | 
				
			||||||
          issue-number: ${{ github.event.issue.number }}
 | 
					 | 
				
			||||||
          body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
 | 
					 | 
				
			||||||
							
								
								
									
										29
									
								
								.github/workflows/build-packages.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/build-packages.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					name: Build packages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					on:
 | 
				
			||||||
 | 
					  push:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - master
 | 
				
			||||||
 | 
					  pull_request:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  packages:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
 | 
					      - name: Setup Node.js 14.x
 | 
				
			||||||
 | 
					        uses: actions/setup-node@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          node-version: 14.x
 | 
				
			||||||
 | 
					      - name: Install dependencies
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          yarn --frozen-lockfile
 | 
				
			||||||
 | 
					          yarn --cwd src/packages/excalidraw
 | 
				
			||||||
 | 
					          yarn --cwd src/packages/utils
 | 
				
			||||||
 | 
					      - name: Build @excalidraw/excalidraw
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          yarn --cwd src/packages/excalidraw run pack
 | 
				
			||||||
 | 
					      - name: Build @excalidraw/utils
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          yarn --cwd src/packages/utils run pack
 | 
				
			||||||
							
								
								
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -11,7 +11,7 @@ jobs:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: docker/build-push-action@v2
 | 
					      - uses: docker/build-push-action@v1
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
					          username: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
					          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -5,11 +5,9 @@
 | 
				
			|||||||
.env.test.local
 | 
					.env.test.local
 | 
				
			||||||
.envrc
 | 
					.envrc
 | 
				
			||||||
.eslintcache
 | 
					.eslintcache
 | 
				
			||||||
.history
 | 
					 | 
				
			||||||
.idea
 | 
					.idea
 | 
				
			||||||
.vercel
 | 
					.vercel
 | 
				
			||||||
.vscode
 | 
					.vscode
 | 
				
			||||||
.yarn
 | 
					 | 
				
			||||||
*.log
 | 
					*.log
 | 
				
			||||||
*.tgz
 | 
					*.tgz
 | 
				
			||||||
build
 | 
					build
 | 
				
			||||||
@@ -22,8 +20,3 @@ package-lock.json
 | 
				
			|||||||
static
 | 
					static
 | 
				
			||||||
yarn-debug.log*
 | 
					yarn-debug.log*
 | 
				
			||||||
yarn-error.log*
 | 
					yarn-error.log*
 | 
				
			||||||
src/packages/excalidraw/types
 | 
					 | 
				
			||||||
src/packages/excalidraw/example/public/bundle.js
 | 
					 | 
				
			||||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
 | 
					 | 
				
			||||||
src/packages/excalidraw/example/public/excalidraw.development.js
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,2 +0,0 @@
 | 
				
			|||||||
#!/bin/sh
 | 
					 | 
				
			||||||
yarn lint-staged
 | 
					 | 
				
			||||||
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
 | 
				
			|||||||
COPY . .
 | 
					COPY . .
 | 
				
			||||||
RUN yarn build:app:docker
 | 
					RUN yarn build:app:docker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
FROM nginx:1.21-alpine
 | 
					FROM nginx:1.17-alpine
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
 | 
					COPY --from=build /opt/node_app/build /usr/share/nginx/html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										69
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								README.md
									
									
									
									
									
								
							@@ -32,10 +32,6 @@ Last but not least, we're thankful to these companies for offering their service
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
 | 
					[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Who's integrating Excalidraw
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Documentation
 | 
					## Documentation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Shortcuts
 | 
					### Shortcuts
 | 
				
			||||||
@@ -74,8 +70,6 @@ The first set of digits is the room. This is visible from the server that’s go
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
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.
 | 
					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.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
> Note: Please ensure that the encryption key is 22 characters long.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Shape libraries
 | 
					## Shape libraries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
 | 
					Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
 | 
				
			||||||
@@ -99,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
 | 
				
			|||||||
#### Requirements
 | 
					#### Requirements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- [Node.js](https://nodejs.org/en/)
 | 
					- [Node.js](https://nodejs.org/en/)
 | 
				
			||||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
 | 
					- [Yarn](https://yarnpkg.com/getting-started/install)
 | 
				
			||||||
- [Git](https://git-scm.com/downloads)
 | 
					- [Git](https://git-scm.com/downloads)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#### Clone the repo
 | 
					#### Clone the repo
 | 
				
			||||||
@@ -108,61 +102,16 @@ These instructions will get you a copy of the project up and running on your loc
 | 
				
			|||||||
git clone https://github.com/excalidraw/excalidraw.git
 | 
					git clone https://github.com/excalidraw/excalidraw.git
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#### Install the dependencies
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```bash
 | 
					 | 
				
			||||||
yarn
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#### Start the server
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```bash
 | 
					 | 
				
			||||||
yarn start
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#### Collaboration
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#### Commands
 | 
					#### Commands
 | 
				
			||||||
 | 
					
 | 
				
			||||||
##### Install the dependencies
 | 
					| Command            | Description                       |
 | 
				
			||||||
 | 
					| ------------------ | --------------------------------- |
 | 
				
			||||||
```
 | 
					| `yarn`             | Install the dependencies          |
 | 
				
			||||||
yarn
 | 
					| `yarn start`       | Run the project                   |
 | 
				
			||||||
```
 | 
					| `yarn fix`         | Reformat all files with Prettier  |
 | 
				
			||||||
 | 
					| `yarn test`        | Run tests                         |
 | 
				
			||||||
##### Run the project
 | 
					| `yarn test:update` | Update test snapshots             |
 | 
				
			||||||
 | 
					| `yarn test:code`   | Test for formatting with Prettier |
 | 
				
			||||||
```
 | 
					 | 
				
			||||||
yarn start
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
##### Reformat all files with Prettier
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
yarn fix
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
##### Run tests
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
yarn test
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
##### Update test snapshots
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
yarn test:update
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
##### Test for formatting with Prettier
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
yarn test:code
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
#### Docker Compose
 | 
					#### Docker Compose
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,8 +2,5 @@
 | 
				
			|||||||
  "firestore": {
 | 
					  "firestore": {
 | 
				
			||||||
    "rules": "firestore.rules",
 | 
					    "rules": "firestore.rules",
 | 
				
			||||||
    "indexes": "firestore.indexes.json"
 | 
					    "indexes": "firestore.indexes.json"
 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  "storage": {
 | 
					 | 
				
			||||||
    "rules": "storage.rules"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +0,0 @@
 | 
				
			|||||||
rules_version = '2';
 | 
					 | 
				
			||||||
service firebase.storage {
 | 
					 | 
				
			||||||
  match /b/{bucket}/o {
 | 
					 | 
				
			||||||
    match /{files}/rooms/{room}/{file} {
 | 
					 | 
				
			||||||
    	allow get, write: if true;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    match /{files}/shareLinks/{shareLink}/{file} {
 | 
					 | 
				
			||||||
    	allow get, write: if true;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										69
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								package.json
									
									
									
									
									
								
							@@ -19,67 +19,60 @@
 | 
				
			|||||||
    ]
 | 
					    ]
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@sentry/browser": "6.2.5",
 | 
					    "@sentry/browser": "6.2.2",
 | 
				
			||||||
    "@sentry/integrations": "6.2.5",
 | 
					    "@sentry/integrations": "6.2.1",
 | 
				
			||||||
    "@testing-library/jest-dom": "5.16.2",
 | 
					    "@testing-library/jest-dom": "5.11.9",
 | 
				
			||||||
    "@testing-library/react": "12.1.2",
 | 
					    "@testing-library/react": "11.2.5",
 | 
				
			||||||
    "@tldraw/vec": "1.4.3",
 | 
					    "@types/jest": "26.0.20",
 | 
				
			||||||
    "@types/jest": "27.4.0",
 | 
					    "@types/react": "17.0.2",
 | 
				
			||||||
    "@types/pica": "5.1.3",
 | 
					    "@types/react-dom": "17.0.1",
 | 
				
			||||||
    "@types/react": "17.0.39",
 | 
					 | 
				
			||||||
    "@types/react-dom": "17.0.11",
 | 
					 | 
				
			||||||
    "@types/socket.io-client": "1.4.36",
 | 
					    "@types/socket.io-client": "1.4.36",
 | 
				
			||||||
    "browser-fs-access": "0.29.1",
 | 
					    "browser-fs-access": "0.14.2",
 | 
				
			||||||
    "clsx": "1.1.1",
 | 
					    "clsx": "1.1.1",
 | 
				
			||||||
    "fake-indexeddb": "3.1.7",
 | 
					    "firebase": "8.2.10",
 | 
				
			||||||
    "firebase": "8.3.3",
 | 
					    "i18next-browser-languagedetector": "6.0.1",
 | 
				
			||||||
    "i18next-browser-languagedetector": "6.1.2",
 | 
					 | 
				
			||||||
    "idb-keyval": "6.0.3",
 | 
					 | 
				
			||||||
    "image-blob-reduce": "3.0.1",
 | 
					 | 
				
			||||||
    "jotai": "1.6.4",
 | 
					 | 
				
			||||||
    "lodash.throttle": "4.1.1",
 | 
					    "lodash.throttle": "4.1.1",
 | 
				
			||||||
    "nanoid": "3.3.3",
 | 
					    "nanoid": "3.1.21",
 | 
				
			||||||
    "open-color": "1.9.1",
 | 
					    "open-color": "1.8.0",
 | 
				
			||||||
    "pako": "1.0.11",
 | 
					    "pako": "1.0.11",
 | 
				
			||||||
    "perfect-freehand": "1.0.16",
 | 
					 | 
				
			||||||
    "png-chunk-text": "1.0.0",
 | 
					    "png-chunk-text": "1.0.0",
 | 
				
			||||||
    "png-chunks-encode": "1.0.0",
 | 
					    "png-chunks-encode": "1.0.0",
 | 
				
			||||||
    "png-chunks-extract": "1.0.0",
 | 
					    "png-chunks-extract": "1.0.0",
 | 
				
			||||||
    "points-on-curve": "0.2.0",
 | 
					    "points-on-curve": "0.2.0",
 | 
				
			||||||
    "pwacompat": "2.0.17",
 | 
					    "pwacompat": "2.0.17",
 | 
				
			||||||
    "react": "17.0.2",
 | 
					    "react": "17.0.1",
 | 
				
			||||||
    "react-dom": "17.0.2",
 | 
					    "react-dom": "17.0.1",
 | 
				
			||||||
    "react-scripts": "4.0.3",
 | 
					    "react-scripts": "4.0.3",
 | 
				
			||||||
    "roughjs": "4.5.2",
 | 
					    "roughjs": "4.3.1",
 | 
				
			||||||
    "sass": "1.49.7",
 | 
					    "sass": "1.32.8",
 | 
				
			||||||
    "socket.io-client": "2.3.1",
 | 
					    "socket.io-client": "2.3.1",
 | 
				
			||||||
    "typescript": "4.5.5"
 | 
					    "typescript": "4.2.3"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
					    "@excalidraw/eslint-config": "1.0.0",
 | 
				
			||||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
					    "@excalidraw/prettier-config": "1.0.2",
 | 
				
			||||||
    "@types/chai": "4.3.0",
 | 
					 | 
				
			||||||
    "@types/lodash.throttle": "4.1.6",
 | 
					    "@types/lodash.throttle": "4.1.6",
 | 
				
			||||||
    "@types/pako": "1.0.3",
 | 
					    "@types/pako": "1.0.1",
 | 
				
			||||||
    "@types/resize-observer-browser": "0.1.6",
 | 
					    "@types/resize-observer-browser": "0.1.5",
 | 
				
			||||||
    "chai": "4.3.6",
 | 
					    "eslint-config-prettier": "8.1.0",
 | 
				
			||||||
    "dotenv": "10.0.0",
 | 
					 | 
				
			||||||
    "eslint-config-prettier": "8.3.0",
 | 
					 | 
				
			||||||
    "eslint-plugin-prettier": "3.3.1",
 | 
					    "eslint-plugin-prettier": "3.3.1",
 | 
				
			||||||
    "husky": "7.0.4",
 | 
					    "firebase-tools": "9.6.1",
 | 
				
			||||||
 | 
					    "husky": "4.3.8",
 | 
				
			||||||
    "jest-canvas-mock": "2.3.1",
 | 
					    "jest-canvas-mock": "2.3.1",
 | 
				
			||||||
    "lint-staged": "12.3.7",
 | 
					    "lint-staged": "10.5.4",
 | 
				
			||||||
    "pepjs": "0.5.3",
 | 
					    "pepjs": "0.5.3",
 | 
				
			||||||
    "prettier": "2.5.1",
 | 
					    "prettier": "2.2.1",
 | 
				
			||||||
    "rewire": "5.0.0"
 | 
					    "rewire": "5.0.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "resolutions": {
 | 
					 | 
				
			||||||
    "@typescript-eslint/typescript-estree": "5.10.2"
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  "engines": {
 | 
					  "engines": {
 | 
				
			||||||
    "node": ">=14.0.0"
 | 
					    "node": ">=14.0.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "homepage": ".",
 | 
					  "homepage": ".",
 | 
				
			||||||
 | 
					  "husky": {
 | 
				
			||||||
 | 
					    "hooks": {
 | 
				
			||||||
 | 
					      "pre-commit": "lint-staged"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  "jest": {
 | 
					  "jest": {
 | 
				
			||||||
    "transformIgnorePatterns": [
 | 
					    "transformIgnorePatterns": [
 | 
				
			||||||
      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
 | 
					      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
 | 
				
			||||||
@@ -101,7 +94,6 @@
 | 
				
			|||||||
    "fix": "yarn fix:other && yarn fix:code",
 | 
					    "fix": "yarn fix:other && yarn fix:code",
 | 
				
			||||||
    "locales-coverage": "node scripts/build-locales-coverage.js",
 | 
					    "locales-coverage": "node scripts/build-locales-coverage.js",
 | 
				
			||||||
    "locales-coverage:description": "node scripts/locales-coverage-description.js",
 | 
					    "locales-coverage:description": "node scripts/locales-coverage-description.js",
 | 
				
			||||||
    "prepare": "husky install",
 | 
					 | 
				
			||||||
    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
 | 
					    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
 | 
				
			||||||
    "start": "react-scripts start",
 | 
					    "start": "react-scripts start",
 | 
				
			||||||
    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
 | 
					    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
 | 
				
			||||||
@@ -111,7 +103,6 @@
 | 
				
			|||||||
    "test:other": "yarn prettier --list-different",
 | 
					    "test:other": "yarn prettier --list-different",
 | 
				
			||||||
    "test:typecheck": "tsc",
 | 
					    "test:typecheck": "tsc",
 | 
				
			||||||
    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
 | 
					    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
 | 
				
			||||||
    "test": "yarn test:app",
 | 
					    "test": "yarn test:app"
 | 
				
			||||||
    "autorelease": "node scripts/autorelease.js"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
										
											Binary file not shown.
										
									
								
							@@ -51,7 +51,8 @@
 | 
				
			|||||||
      name="twitter:description"
 | 
					      name="twitter:description"
 | 
				
			||||||
      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
					      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
 | 
					    <!-- OG tags require absolute url for images -->
 | 
				
			||||||
 | 
					    <meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
 | 
				
			||||||
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
 | 
					    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Excalidraw version -->
 | 
					    <!-- Excalidraw version -->
 | 
				
			||||||
@@ -72,6 +73,12 @@
 | 
				
			|||||||
      crossorigin="anonymous"
 | 
					      crossorigin="anonymous"
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <link
 | 
				
			||||||
 | 
					      href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
 | 
				
			||||||
 | 
					      rel="preconnect"
 | 
				
			||||||
 | 
					      crossorigin="anonymous"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <link
 | 
					    <link
 | 
				
			||||||
      rel="manifest"
 | 
					      rel="manifest"
 | 
				
			||||||
      href="manifest.json"
 | 
					      href="manifest.json"
 | 
				
			||||||
@@ -81,8 +88,6 @@
 | 
				
			|||||||
    <link rel="stylesheet" href="fonts.css" type="text/css" />
 | 
					    <link rel="stylesheet" href="fonts.css" type="text/css" />
 | 
				
			||||||
    <script>
 | 
					    <script>
 | 
				
			||||||
      window.EXCALIDRAW_ASSET_PATH = "/";
 | 
					      window.EXCALIDRAW_ASSET_PATH = "/";
 | 
				
			||||||
      // setting this so that libraries installation reuses this window tab.
 | 
					 | 
				
			||||||
      window.name = "_excalidraw";
 | 
					 | 
				
			||||||
    </script>
 | 
					    </script>
 | 
				
			||||||
    <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
 | 
					    <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
 | 
				
			||||||
    <script
 | 
					    <script
 | 
				
			||||||
@@ -101,17 +106,15 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <!-- FIXME: remove this when we update CRA (fix SW caching) -->
 | 
					    <!-- FIXME: remove this when we update CRA (fix SW caching) -->
 | 
				
			||||||
    <style>
 | 
					    <style>
 | 
				
			||||||
      body,
 | 
					      body {
 | 
				
			||||||
      html {
 | 
					 | 
				
			||||||
        margin: 0;
 | 
					        margin: 0;
 | 
				
			||||||
        --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
 | 
					        --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
 | 
				
			||||||
          Roboto, Helvetica, Arial, sans-serif;
 | 
					          Roboto, Helvetica, Arial, sans-serif;
 | 
				
			||||||
        font-family: var(--ui-font);
 | 
					        font-family: var(--ui-font);
 | 
				
			||||||
        -webkit-text-size-adjust: 100%;
 | 
					        -webkit-text-size-adjust: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        width: 100%;
 | 
					        width: 100vw;
 | 
				
			||||||
        height: 100%;
 | 
					        height: 100vh;
 | 
				
			||||||
        overflow: hidden;
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      .visually-hidden {
 | 
					      .visually-hidden {
 | 
				
			||||||
@@ -121,28 +124,27 @@
 | 
				
			|||||||
        overflow: hidden;
 | 
					        overflow: hidden;
 | 
				
			||||||
        clip: rect(1px, 1px, 1px, 1px);
 | 
					        clip: rect(1px, 1px, 1px, 1px);
 | 
				
			||||||
        white-space: nowrap; /* added line */
 | 
					        white-space: nowrap; /* added line */
 | 
				
			||||||
        user-select: none;
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      #root {
 | 
					      .LoadingMessage {
 | 
				
			||||||
        height: 100%;
 | 
					        position: absolute;
 | 
				
			||||||
        -webkit-touch-callout: none;
 | 
					        top: 0;
 | 
				
			||||||
        -webkit-user-select: none;
 | 
					        right: 0;
 | 
				
			||||||
        -khtml-user-select: none;
 | 
					        bottom: 0;
 | 
				
			||||||
        -moz-user-select: none;
 | 
					        left: 0;
 | 
				
			||||||
        -ms-user-select: none;
 | 
					        z-index: 999;
 | 
				
			||||||
        user-select: none;
 | 
					        display: flex;
 | 
				
			||||||
 | 
					        align-items: center;
 | 
				
			||||||
 | 
					        justify-content: center;
 | 
				
			||||||
 | 
					        pointer-events: none;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      @media screen and (min-width: 1200px) {
 | 
					      .LoadingMessage span {
 | 
				
			||||||
        #root {
 | 
					        background-color: var(--button-gray-1);
 | 
				
			||||||
          -webkit-touch-callout: default;
 | 
					        border-radius: 5px;
 | 
				
			||||||
          -webkit-user-select: auto;
 | 
					        padding: 0.8em 1.2em;
 | 
				
			||||||
          -khtml-user-select: auto;
 | 
					        color: var(--popup-text-color);
 | 
				
			||||||
          -moz-user-select: auto;
 | 
					        font-size: 1.3em;
 | 
				
			||||||
          -ms-user-select: auto;
 | 
					 | 
				
			||||||
          user-select: auto;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    </style>
 | 
					    </style>
 | 
				
			||||||
  </head>
 | 
					  </head>
 | 
				
			||||||
@@ -152,6 +154,10 @@
 | 
				
			|||||||
    <header>
 | 
					    <header>
 | 
				
			||||||
      <h1 class="visually-hidden">Excalidraw</h1>
 | 
					      <h1 class="visually-hidden">Excalidraw</h1>
 | 
				
			||||||
    </header>
 | 
					    </header>
 | 
				
			||||||
    <div id="root"></div>
 | 
					    <div id="root">
 | 
				
			||||||
 | 
					      <div class="LoadingMessage">
 | 
				
			||||||
 | 
					        <span>Loading scene...</span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,6 +26,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
 | 
					  "capture_links": "new_client",
 | 
				
			||||||
  "share_target": {
 | 
					  "share_target": {
 | 
				
			||||||
    "action": "/web-share-target",
 | 
					    "action": "/web-share-target",
 | 
				
			||||||
    "method": "POST",
 | 
					    "method": "POST",
 | 
				
			||||||
@@ -38,37 +39,5 @@
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      ]
 | 
					      ]
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  }
 | 
				
			||||||
  "screenshots": [
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "src": "/screenshots/virtual-whiteboard.png",
 | 
					 | 
				
			||||||
      "type": "image/png",
 | 
					 | 
				
			||||||
      "sizes": "462x945"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "src": "/screenshots/wireframe.png",
 | 
					 | 
				
			||||||
      "type": "image/png",
 | 
					 | 
				
			||||||
      "sizes": "462x945"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "src": "/screenshots/illustration.png",
 | 
					 | 
				
			||||||
      "type": "image/png",
 | 
					 | 
				
			||||||
      "sizes": "462x945"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "src": "/screenshots/shapes.png",
 | 
					 | 
				
			||||||
      "type": "image/png",
 | 
					 | 
				
			||||||
      "sizes": "462x945"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "src": "/screenshots/collaboration.png",
 | 
					 | 
				
			||||||
      "type": "image/png",
 | 
					 | 
				
			||||||
      "sizes": "462x945"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "src": "/screenshots/export.png",
 | 
					 | 
				
			||||||
      "type": "image/png",
 | 
					 | 
				
			||||||
      "sizes": "462x945"
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  ]
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 28 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 25 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 47 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 25 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 27 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 27 KiB  | 
@@ -1,77 +0,0 @@
 | 
				
			|||||||
const fs = require("fs");
 | 
					 | 
				
			||||||
const { exec, execSync } = require("child_process");
 | 
					 | 
				
			||||||
const core = require("@actions/core");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
					 | 
				
			||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
					 | 
				
			||||||
const pkg = require(excalidrawPackage);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const getShortCommitHash = () => {
 | 
					 | 
				
			||||||
  return execSync("git rev-parse --short HEAD").toString().trim();
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const publish = () => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    execSync(`yarn  --frozen-lockfile`);
 | 
					 | 
				
			||||||
    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
 | 
					 | 
				
			||||||
    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
 | 
					 | 
				
			||||||
    execSync(`yarn --cwd ${excalidrawDir} publish`);
 | 
					 | 
				
			||||||
    console.info("Published 🎉");
 | 
					 | 
				
			||||||
    core.setOutput(
 | 
					 | 
				
			||||||
      "result",
 | 
					 | 
				
			||||||
      `**Preview version has been shipped** :rocket:
 | 
					 | 
				
			||||||
    You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    core.setOutput("result", "package couldn't be published :warning:!");
 | 
					 | 
				
			||||||
    console.error(error);
 | 
					 | 
				
			||||||
    process.exit(1);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
// get files changed between prev and head commit
 | 
					 | 
				
			||||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
					 | 
				
			||||||
  if (error || stderr) {
 | 
					 | 
				
			||||||
    console.error(error);
 | 
					 | 
				
			||||||
    core.setOutput("result", ":warning: Package couldn't be published!");
 | 
					 | 
				
			||||||
    process.exit(1);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  const changedFiles = stdout.trim().split("\n");
 | 
					 | 
				
			||||||
  const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const excalidrawPackageFiles = changedFiles.filter((file) => {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      (file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
 | 
					 | 
				
			||||||
      !filesToIgnoreRegex.test(file)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  if (!excalidrawPackageFiles.length) {
 | 
					 | 
				
			||||||
    console.info("Skipping release as no valid diff found");
 | 
					 | 
				
			||||||
    core.setOutput("result", "Skipping release as no valid diff found");
 | 
					 | 
				
			||||||
    process.exit(0);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // update package.json
 | 
					 | 
				
			||||||
  pkg.name = "@excalidraw/excalidraw-next";
 | 
					 | 
				
			||||||
  let version = `${pkg.version}-${getShortCommitHash()}`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // update readme
 | 
					 | 
				
			||||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const isPreview = process.argv.slice(2)[0] === "preview";
 | 
					 | 
				
			||||||
  if (isPreview) {
 | 
					 | 
				
			||||||
    // use pullNumber-commithash as the version for preview
 | 
					 | 
				
			||||||
    const pullRequestNumber = process.argv.slice(3)[0];
 | 
					 | 
				
			||||||
    version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
 | 
					 | 
				
			||||||
    // replace "excalidraw-next" with "excalidraw-preview"
 | 
					 | 
				
			||||||
    pkg.name = "@excalidraw/excalidraw-preview";
 | 
					 | 
				
			||||||
    data = data.replace(/excalidraw-next/g, "excalidraw-preview");
 | 
					 | 
				
			||||||
    data = data.trim();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  pkg.version = version;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
					 | 
				
			||||||
  console.info("Publish in progress...");
 | 
					 | 
				
			||||||
  publish();
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@@ -1,16 +1,11 @@
 | 
				
			|||||||
const { readdirSync, writeFileSync } = require("fs");
 | 
					const { readdirSync, writeFileSync } = require("fs");
 | 
				
			||||||
const files = readdirSync(`${__dirname}/../src/locales`);
 | 
					const files = readdirSync(`${__dirname}/../src/locales`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const flatten = (object = {}, result = {}, extraKey = "") => {
 | 
					const flatten = (object) =>
 | 
				
			||||||
  for (const key in object) {
 | 
					  Object.keys(object).reduce(
 | 
				
			||||||
    if (typeof object[key] !== "object") {
 | 
					    (initial, current) => ({ ...initial, ...object[current] }),
 | 
				
			||||||
      result[extraKey + key] = object[key];
 | 
					    {},
 | 
				
			||||||
    } else {
 | 
					  );
 | 
				
			||||||
      flatten(object[key], result, `${extraKey}${key}.`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return result;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const locales = files.filter(
 | 
					const locales = files.filter(
 | 
				
			||||||
  (file) => file !== "README.md" && file !== "percentages.json",
 | 
					  (file) => file !== "README.md" && file !== "percentages.json",
 | 
				
			||||||
@@ -24,8 +19,10 @@ for (let index = 0; index < locales.length; index++) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const allKeys = Object.keys(data);
 | 
					  const allKeys = Object.keys(data);
 | 
				
			||||||
  const translatedKeys = allKeys.filter((item) => data[item] !== "");
 | 
					  const translatedKeys = allKeys.filter((item) => data[item] !== "");
 | 
				
			||||||
  const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
 | 
					
 | 
				
			||||||
  percentages[currentLocale.replace(".json", "")] = percentage;
 | 
					  const percentage = (100 * translatedKeys.length) / allKeys.length;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
writeFileSync(
 | 
					writeFileSync(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,13 +5,10 @@ const THRESSHOLD = 85;
 | 
				
			|||||||
const crowdinMap = {
 | 
					const crowdinMap = {
 | 
				
			||||||
  "ar-SA": "en-ar",
 | 
					  "ar-SA": "en-ar",
 | 
				
			||||||
  "bg-BG": "en-bg",
 | 
					  "bg-BG": "en-bg",
 | 
				
			||||||
  "bn-BD": "en-bn",
 | 
					 | 
				
			||||||
  "ca-ES": "en-ca",
 | 
					  "ca-ES": "en-ca",
 | 
				
			||||||
  "da-DK": "en-da",
 | 
					 | 
				
			||||||
  "de-DE": "en-de",
 | 
					  "de-DE": "en-de",
 | 
				
			||||||
  "el-GR": "en-el",
 | 
					  "el-GR": "en-el",
 | 
				
			||||||
  "es-ES": "en-es",
 | 
					  "es-ES": "en-es",
 | 
				
			||||||
  "eu-ES": "en-eu",
 | 
					 | 
				
			||||||
  "fa-IR": "en-fa",
 | 
					  "fa-IR": "en-fa",
 | 
				
			||||||
  "fi-FI": "en-fi",
 | 
					  "fi-FI": "en-fi",
 | 
				
			||||||
  "fr-FR": "en-fr",
 | 
					  "fr-FR": "en-fr",
 | 
				
			||||||
@@ -34,28 +31,18 @@ const crowdinMap = {
 | 
				
			|||||||
  "pt-PT": "en-pt",
 | 
					  "pt-PT": "en-pt",
 | 
				
			||||||
  "ro-RO": "en-ro",
 | 
					  "ro-RO": "en-ro",
 | 
				
			||||||
  "ru-RU": "en-ru",
 | 
					  "ru-RU": "en-ru",
 | 
				
			||||||
  "si-LK": "en-silk",
 | 
					 | 
				
			||||||
  "sk-SK": "en-sk",
 | 
					  "sk-SK": "en-sk",
 | 
				
			||||||
  "sv-SE": "en-sv",
 | 
					  "sv-SE": "en-sv",
 | 
				
			||||||
  "ta-IN": "en-ta",
 | 
					 | 
				
			||||||
  "tr-TR": "en-tr",
 | 
					  "tr-TR": "en-tr",
 | 
				
			||||||
  "uk-UA": "en-uk",
 | 
					  "uk-UA": "en-uk",
 | 
				
			||||||
  "zh-CN": "en-zhcn",
 | 
					  "zh-CN": "en-zhcn",
 | 
				
			||||||
  "zh-HK": "en-zhhk",
 | 
					 | 
				
			||||||
  "zh-TW": "en-zhtw",
 | 
					  "zh-TW": "en-zhtw",
 | 
				
			||||||
  "lt-LT": "en-lt",
 | 
					 | 
				
			||||||
  "lv-LV": "en-lv",
 | 
					 | 
				
			||||||
  "cs-CZ": "en-cs",
 | 
					 | 
				
			||||||
  "kk-KZ": "en-kk",
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const flags = {
 | 
					const flags = {
 | 
				
			||||||
  "ar-SA": "🇸🇦",
 | 
					  "ar-SA": "🇸🇦",
 | 
				
			||||||
  "bg-BG": "🇧🇬",
 | 
					  "bg-BG": "🇧🇬",
 | 
				
			||||||
  "bn-BD": "🇧🇩",
 | 
					 | 
				
			||||||
  "ca-ES": "🏳",
 | 
					  "ca-ES": "🏳",
 | 
				
			||||||
  "cs-CZ": "🇨🇿",
 | 
					 | 
				
			||||||
  "da-DK": "🇩🇰",
 | 
					 | 
				
			||||||
  "de-DE": "🇩🇪",
 | 
					  "de-DE": "🇩🇪",
 | 
				
			||||||
  "el-GR": "🇬🇷",
 | 
					  "el-GR": "🇬🇷",
 | 
				
			||||||
  "es-ES": "🇪🇸",
 | 
					  "es-ES": "🇪🇸",
 | 
				
			||||||
@@ -69,10 +56,7 @@ const flags = {
 | 
				
			|||||||
  "it-IT": "🇮🇹",
 | 
					  "it-IT": "🇮🇹",
 | 
				
			||||||
  "ja-JP": "🇯🇵",
 | 
					  "ja-JP": "🇯🇵",
 | 
				
			||||||
  "kab-KAB": "🏳",
 | 
					  "kab-KAB": "🏳",
 | 
				
			||||||
  "kk-KZ": "🇰🇿",
 | 
					 | 
				
			||||||
  "ko-KR": "🇰🇷",
 | 
					  "ko-KR": "🇰🇷",
 | 
				
			||||||
  "lt-LT": "🇱🇹",
 | 
					 | 
				
			||||||
  "lv-LV": "🇱🇻",
 | 
					 | 
				
			||||||
  "my-MM": "🇲🇲",
 | 
					  "my-MM": "🇲🇲",
 | 
				
			||||||
  "nb-NO": "🇳🇴",
 | 
					  "nb-NO": "🇳🇴",
 | 
				
			||||||
  "nl-NL": "🇳🇱",
 | 
					  "nl-NL": "🇳🇱",
 | 
				
			||||||
@@ -84,28 +68,21 @@ const flags = {
 | 
				
			|||||||
  "pt-PT": "🇵🇹",
 | 
					  "pt-PT": "🇵🇹",
 | 
				
			||||||
  "ro-RO": "🇷🇴",
 | 
					  "ro-RO": "🇷🇴",
 | 
				
			||||||
  "ru-RU": "🇷🇺",
 | 
					  "ru-RU": "🇷🇺",
 | 
				
			||||||
  "si-LK": "🇱🇰",
 | 
					 | 
				
			||||||
  "sk-SK": "🇸🇰",
 | 
					  "sk-SK": "🇸🇰",
 | 
				
			||||||
  "sv-SE": "🇸🇪",
 | 
					  "sv-SE": "🇸🇪",
 | 
				
			||||||
  "ta-IN": "🇮🇳",
 | 
					 | 
				
			||||||
  "tr-TR": "🇹🇷",
 | 
					  "tr-TR": "🇹🇷",
 | 
				
			||||||
  "uk-UA": "🇺🇦",
 | 
					  "uk-UA": "🇺🇦",
 | 
				
			||||||
  "zh-CN": "🇨🇳",
 | 
					  "zh-CN": "🇨🇳",
 | 
				
			||||||
  "zh-HK": "🇭🇰",
 | 
					 | 
				
			||||||
  "zh-TW": "🇹🇼",
 | 
					  "zh-TW": "🇹🇼",
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const languages = {
 | 
					const languages = {
 | 
				
			||||||
  "ar-SA": "العربية",
 | 
					  "ar-SA": "العربية",
 | 
				
			||||||
  "bg-BG": "Български",
 | 
					  "bg-BG": "Български",
 | 
				
			||||||
  "bn-BD": "Bengali",
 | 
					 | 
				
			||||||
  "ca-ES": "Català",
 | 
					  "ca-ES": "Català",
 | 
				
			||||||
  "cs-CZ": "Česky",
 | 
					 | 
				
			||||||
  "da-DK": "Dansk",
 | 
					 | 
				
			||||||
  "de-DE": "Deutsch",
 | 
					  "de-DE": "Deutsch",
 | 
				
			||||||
  "el-GR": "Ελληνικά",
 | 
					  "el-GR": "Ελληνικά",
 | 
				
			||||||
  "es-ES": "Español",
 | 
					  "es-ES": "Español",
 | 
				
			||||||
  "eu-ES": "Euskara",
 | 
					 | 
				
			||||||
  "fa-IR": "فارسی",
 | 
					  "fa-IR": "فارسی",
 | 
				
			||||||
  "fi-FI": "Suomi",
 | 
					  "fi-FI": "Suomi",
 | 
				
			||||||
  "fr-FR": "Français",
 | 
					  "fr-FR": "Français",
 | 
				
			||||||
@@ -116,10 +93,7 @@ const languages = {
 | 
				
			|||||||
  "it-IT": "Italiano",
 | 
					  "it-IT": "Italiano",
 | 
				
			||||||
  "ja-JP": "日本語",
 | 
					  "ja-JP": "日本語",
 | 
				
			||||||
  "kab-KAB": "Taqbaylit",
 | 
					  "kab-KAB": "Taqbaylit",
 | 
				
			||||||
  "kk-KZ": "Қазақ тілі",
 | 
					 | 
				
			||||||
  "ko-KR": "한국어",
 | 
					  "ko-KR": "한국어",
 | 
				
			||||||
  "lt-LT": "Lietuvių",
 | 
					 | 
				
			||||||
  "lv-LV": "Latviešu",
 | 
					 | 
				
			||||||
  "my-MM": "Burmese",
 | 
					  "my-MM": "Burmese",
 | 
				
			||||||
  "nb-NO": "Norsk bokmål",
 | 
					  "nb-NO": "Norsk bokmål",
 | 
				
			||||||
  "nl-NL": "Nederlands",
 | 
					  "nl-NL": "Nederlands",
 | 
				
			||||||
@@ -131,14 +105,11 @@ const languages = {
 | 
				
			|||||||
  "pt-PT": "Português",
 | 
					  "pt-PT": "Português",
 | 
				
			||||||
  "ro-RO": "Română",
 | 
					  "ro-RO": "Română",
 | 
				
			||||||
  "ru-RU": "Русский",
 | 
					  "ru-RU": "Русский",
 | 
				
			||||||
  "si-LK": "සිංහල",
 | 
					 | 
				
			||||||
  "sk-SK": "Slovenčina",
 | 
					  "sk-SK": "Slovenčina",
 | 
				
			||||||
  "sv-SE": "Svenska",
 | 
					  "sv-SE": "Svenska",
 | 
				
			||||||
  "ta-IN": "Tamil",
 | 
					 | 
				
			||||||
  "tr-TR": "Türkçe",
 | 
					  "tr-TR": "Türkçe",
 | 
				
			||||||
  "uk-UA": "Українська",
 | 
					  "uk-UA": "Українська",
 | 
				
			||||||
  "zh-CN": "简体中文",
 | 
					  "zh-CN": "简体中文",
 | 
				
			||||||
  "zh-HK": "繁體中文 (香港)",
 | 
					 | 
				
			||||||
  "zh-TW": "繁體中文",
 | 
					  "zh-TW": "繁體中文",
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,39 +0,0 @@
 | 
				
			|||||||
const fs = require("fs");
 | 
					 | 
				
			||||||
const util = require("util");
 | 
					 | 
				
			||||||
const exec = util.promisify(require("child_process").exec);
 | 
					 | 
				
			||||||
const updateReadme = require("./updateReadme");
 | 
					 | 
				
			||||||
const updateChangelog = require("./updateChangelog");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
					 | 
				
			||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const updatePackageVersion = (nextVersion) => {
 | 
					 | 
				
			||||||
  const pkg = require(excalidrawPackage);
 | 
					 | 
				
			||||||
  pkg.version = nextVersion;
 | 
					 | 
				
			||||||
  const content = `${JSON.stringify(pkg, null, 2)}\n`;
 | 
					 | 
				
			||||||
  fs.writeFileSync(excalidrawPackage, content, "utf-8");
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const release = async (nextVersion) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    updateReadme();
 | 
					 | 
				
			||||||
    await updateChangelog(nextVersion);
 | 
					 | 
				
			||||||
    updatePackageVersion(nextVersion);
 | 
					 | 
				
			||||||
    await exec(`git add -u`);
 | 
					 | 
				
			||||||
    await exec(
 | 
					 | 
				
			||||||
      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    /* eslint-disable no-console */
 | 
					 | 
				
			||||||
    console.log("Done!");
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error(error);
 | 
					 | 
				
			||||||
    process.exit(1);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const nextVersion = process.argv.slice(2)[0];
 | 
					 | 
				
			||||||
if (!nextVersion) {
 | 
					 | 
				
			||||||
  console.error("Pass the next version to release!");
 | 
					 | 
				
			||||||
  process.exit(1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
release(nextVersion);
 | 
					 | 
				
			||||||
@@ -1,104 +0,0 @@
 | 
				
			|||||||
const fs = require("fs");
 | 
					 | 
				
			||||||
const util = require("util");
 | 
					 | 
				
			||||||
const exec = util.promisify(require("child_process").exec);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
					 | 
				
			||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
 | 
					 | 
				
			||||||
const pkg = require(excalidrawPackage);
 | 
					 | 
				
			||||||
const lastVersion = pkg.version;
 | 
					 | 
				
			||||||
const existingChangeLog = fs.readFileSync(
 | 
					 | 
				
			||||||
  `${excalidrawDir}/CHANGELOG.md`,
 | 
					 | 
				
			||||||
  "utf8",
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
 | 
					 | 
				
			||||||
const headerForType = {
 | 
					 | 
				
			||||||
  feat: "Features",
 | 
					 | 
				
			||||||
  fix: "Fixes",
 | 
					 | 
				
			||||||
  style: "Styles",
 | 
					 | 
				
			||||||
  refactor: " Refactor",
 | 
					 | 
				
			||||||
  perf: "Performance",
 | 
					 | 
				
			||||||
  build: "Build",
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
const badCommits = [];
 | 
					 | 
				
			||||||
const getCommitHashForLastVersion = async () => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
 | 
					 | 
				
			||||||
    const { stdout } = await exec(
 | 
					 | 
				
			||||||
      `git log --format=format:"%H" --grep=${commitMessage}`,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    return stdout;
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error(error);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const getLibraryCommitsSinceLastRelease = async () => {
 | 
					 | 
				
			||||||
  const commitHash = await getCommitHashForLastVersion();
 | 
					 | 
				
			||||||
  const { stdout } = await exec(
 | 
					 | 
				
			||||||
    `git log --pretty=format:%s ${commitHash}...master`,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  const commitsSinceLastRelease = stdout.split("\n");
 | 
					 | 
				
			||||||
  const commitList = {};
 | 
					 | 
				
			||||||
  supportedTypes.forEach((type) => {
 | 
					 | 
				
			||||||
    commitList[type] = [];
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  commitsSinceLastRelease.forEach((commit) => {
 | 
					 | 
				
			||||||
    const indexOfColon = commit.indexOf(":");
 | 
					 | 
				
			||||||
    const type = commit.slice(0, indexOfColon);
 | 
					 | 
				
			||||||
    if (!supportedTypes.includes(type)) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    const messageWithoutType = commit.slice(indexOfColon + 1).trim();
 | 
					 | 
				
			||||||
    const messageWithCapitalizeFirst =
 | 
					 | 
				
			||||||
      messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
 | 
					 | 
				
			||||||
    const prMatch = commit.match(/\(#([0-9]*)\)/);
 | 
					 | 
				
			||||||
    if (prMatch) {
 | 
					 | 
				
			||||||
      const prNumber = prMatch[1];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // return if the changelog already contains the pr number which would happen for package updates
 | 
					 | 
				
			||||||
      if (existingChangeLog.includes(prNumber)) {
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
 | 
					 | 
				
			||||||
      const messageWithPRLink = messageWithCapitalizeFirst.replace(
 | 
					 | 
				
			||||||
        /\(#[0-9]*\)/,
 | 
					 | 
				
			||||||
        prMarkdown,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      commitList[type].push(messageWithPRLink);
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      badCommits.push(commit);
 | 
					 | 
				
			||||||
      commitList[type].push(messageWithCapitalizeFirst);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  console.info("Bad commits:", badCommits);
 | 
					 | 
				
			||||||
  return commitList;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const updateChangelog = async (nextVersion) => {
 | 
					 | 
				
			||||||
  const commitList = await getLibraryCommitsSinceLastRelease();
 | 
					 | 
				
			||||||
  let changelogForLibrary =
 | 
					 | 
				
			||||||
    "## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
 | 
					 | 
				
			||||||
  supportedTypes.forEach((type) => {
 | 
					 | 
				
			||||||
    if (commitList[type].length) {
 | 
					 | 
				
			||||||
      changelogForLibrary += `### ${headerForType[type]}\n\n`;
 | 
					 | 
				
			||||||
      const commits = commitList[type];
 | 
					 | 
				
			||||||
      commits.forEach((commit) => {
 | 
					 | 
				
			||||||
        changelogForLibrary += `- ${commit}\n\n`;
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  changelogForLibrary += "---\n";
 | 
					 | 
				
			||||||
  const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
 | 
					 | 
				
			||||||
  let updatedContent =
 | 
					 | 
				
			||||||
    existingChangeLog.slice(0, lastVersionIndex) +
 | 
					 | 
				
			||||||
    changelogForLibrary +
 | 
					 | 
				
			||||||
    existingChangeLog.slice(lastVersionIndex);
 | 
					 | 
				
			||||||
  const currentDate = new Date().toISOString().slice(0, 10);
 | 
					 | 
				
			||||||
  const newVersion = `## ${nextVersion} (${currentDate})`;
 | 
					 | 
				
			||||||
  updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
 | 
					 | 
				
			||||||
  fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = updateChangelog;
 | 
					 | 
				
			||||||
@@ -1,27 +0,0 @@
 | 
				
			|||||||
const fs = require("fs");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const updateReadme = () => {
 | 
					 | 
				
			||||||
  const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
 | 
					 | 
				
			||||||
  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // remove note for unstable release
 | 
					 | 
				
			||||||
  data = data.replace(
 | 
					 | 
				
			||||||
    /<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
 | 
					 | 
				
			||||||
    "",
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // replace "excalidraw-next" with "excalidraw"
 | 
					 | 
				
			||||||
  data = data.replace(/excalidraw-next/g, "excalidraw");
 | 
					 | 
				
			||||||
  data = data.trim();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const demoIndex = data.indexOf("### Demo");
 | 
					 | 
				
			||||||
  const excalidrawNextNote =
 | 
					 | 
				
			||||||
    "#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
 | 
					 | 
				
			||||||
  // Add excalidraw next note to try out for unreleased changes
 | 
					 | 
				
			||||||
  data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // update readme
 | 
					 | 
				
			||||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = updateReadme;
 | 
					 | 
				
			||||||
@@ -2,59 +2,20 @@ import { register } from "./register";
 | 
				
			|||||||
import { getSelectedElements } from "../scene";
 | 
					import { getSelectedElements } from "../scene";
 | 
				
			||||||
import { getNonDeletedElements } from "../element";
 | 
					import { getNonDeletedElements } from "../element";
 | 
				
			||||||
import { deepCopyElement } from "../element/newElement";
 | 
					import { deepCopyElement } from "../element/newElement";
 | 
				
			||||||
import { randomId } from "../random";
 | 
					import { Library } from "../data/library";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionAddToLibrary = register({
 | 
					export const actionAddToLibrary = register({
 | 
				
			||||||
  name: "addToLibrary",
 | 
					  name: "addToLibrary",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
  perform: (elements, appState, _, app) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(
 | 
					    const selectedElements = getSelectedElements(
 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					      getNonDeletedElements(elements),
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    if (selectedElements.some((element) => element.type === "image")) {
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        commitToHistory: false,
 | 
					 | 
				
			||||||
        appState: {
 | 
					 | 
				
			||||||
          ...appState,
 | 
					 | 
				
			||||||
          errorMessage: "Support for adding images to the library coming soon!",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return app.library
 | 
					    Library.loadLibrary().then((items) => {
 | 
				
			||||||
      .getLatestLibrary()
 | 
					      Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
 | 
				
			||||||
      .then((items) => {
 | 
					    });
 | 
				
			||||||
        return app.library.setLibrary([
 | 
					    return false;
 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            id: randomId(),
 | 
					 | 
				
			||||||
            status: "unpublished",
 | 
					 | 
				
			||||||
            elements: selectedElements.map(deepCopyElement),
 | 
					 | 
				
			||||||
            created: Date.now(),
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          ...items,
 | 
					 | 
				
			||||||
        ]);
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .then(() => {
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
          commitToHistory: false,
 | 
					 | 
				
			||||||
          appState: {
 | 
					 | 
				
			||||||
            ...appState,
 | 
					 | 
				
			||||||
            toastMessage: t("toast.addedToLibrary"),
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch((error) => {
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
          commitToHistory: false,
 | 
					 | 
				
			||||||
          appState: {
 | 
					 | 
				
			||||||
            ...appState,
 | 
					 | 
				
			||||||
            errorMessage: error.message,
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  contextItemLabel: "labels.addToLibrary",
 | 
					  contextItemLabel: "labels.addToLibrary",
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { alignElements, Alignment } from "../align";
 | 
					import { alignElements, Alignment } from "../align";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AlignBottomIcon,
 | 
					  AlignBottomIcon,
 | 
				
			||||||
@@ -8,13 +9,13 @@ import {
 | 
				
			|||||||
  CenterVerticallyIcon,
 | 
					  CenterVerticallyIcon,
 | 
				
			||||||
} from "../components/icons";
 | 
					} from "../components/icons";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { getNonDeletedElements } from "../element";
 | 
					import { getElementMap, getNonDeletedElements } from "../element";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
					import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
					import { getShortcutKey } from "../utils";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const enableActionGroup = (
 | 
					const enableActionGroup = (
 | 
				
			||||||
@@ -34,16 +35,13 @@ const alignSelectedElements = (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const updatedElements = alignElements(selectedElements, alignment);
 | 
					  const updatedElements = alignElements(selectedElements, alignment);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
					  const updatedElementsMap = getElementMap(updatedElements);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return elements.map(
 | 
					  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
				
			||||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionAlignTop = register({
 | 
					export const actionAlignTop = register({
 | 
				
			||||||
  name: "alignTop",
 | 
					  name: "alignTop",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -73,7 +71,6 @@ export const actionAlignTop = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionAlignBottom = register({
 | 
					export const actionAlignBottom = register({
 | 
				
			||||||
  name: "alignBottom",
 | 
					  name: "alignBottom",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -103,7 +100,6 @@ export const actionAlignBottom = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionAlignLeft = register({
 | 
					export const actionAlignLeft = register({
 | 
				
			||||||
  name: "alignLeft",
 | 
					  name: "alignLeft",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -133,8 +129,6 @@ export const actionAlignLeft = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionAlignRight = register({
 | 
					export const actionAlignRight = register({
 | 
				
			||||||
  name: "alignRight",
 | 
					  name: "alignRight",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -164,8 +158,6 @@ export const actionAlignRight = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionAlignVerticallyCentered = register({
 | 
					export const actionAlignVerticallyCentered = register({
 | 
				
			||||||
  name: "alignVerticallyCentered",
 | 
					  name: "alignVerticallyCentered",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -191,7 +183,6 @@ export const actionAlignVerticallyCentered = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionAlignHorizontallyCentered = register({
 | 
					export const actionAlignHorizontallyCentered = register({
 | 
				
			||||||
  name: "alignHorizontallyCentered",
 | 
					  name: "alignHorizontallyCentered",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,136 +0,0 @@
 | 
				
			|||||||
import { VERTICAL_ALIGN } from "../constants";
 | 
					 | 
				
			||||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
					 | 
				
			||||||
import { mutateElement } from "../element/mutateElement";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  getBoundTextElement,
 | 
					 | 
				
			||||||
  measureText,
 | 
					 | 
				
			||||||
  redrawTextBoundingBox,
 | 
					 | 
				
			||||||
} from "../element/textElement";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  hasBoundTextElement,
 | 
					 | 
				
			||||||
  isTextBindableContainer,
 | 
					 | 
				
			||||||
} from "../element/typeChecks";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  ExcalidrawTextContainer,
 | 
					 | 
				
			||||||
  ExcalidrawTextElement,
 | 
					 | 
				
			||||||
} from "../element/types";
 | 
					 | 
				
			||||||
import { getSelectedElements } from "../scene";
 | 
					 | 
				
			||||||
import { getFontString } from "../utils";
 | 
					 | 
				
			||||||
import { register } from "./register";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionUnbindText = register({
 | 
					 | 
				
			||||||
  name: "unbindText",
 | 
					 | 
				
			||||||
  contextItemLabel: "labels.unbindText",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  contextItemPredicate: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
					 | 
				
			||||||
    return selectedElements.some((element) => hasBoundTextElement(element));
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(
 | 
					 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    selectedElements.forEach((element) => {
 | 
					 | 
				
			||||||
      const boundTextElement = getBoundTextElement(element);
 | 
					 | 
				
			||||||
      if (boundTextElement) {
 | 
					 | 
				
			||||||
        const { width, height, baseline } = measureText(
 | 
					 | 
				
			||||||
          boundTextElement.originalText,
 | 
					 | 
				
			||||||
          getFontString(boundTextElement),
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
					 | 
				
			||||||
          containerId: null,
 | 
					 | 
				
			||||||
          width,
 | 
					 | 
				
			||||||
          height,
 | 
					 | 
				
			||||||
          baseline,
 | 
					 | 
				
			||||||
          text: boundTextElement.originalText,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        mutateElement(element, {
 | 
					 | 
				
			||||||
          boundElements: element.boundElements?.filter(
 | 
					 | 
				
			||||||
            (ele) => ele.id !== boundTextElement.id,
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      elements,
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionBindText = register({
 | 
					 | 
				
			||||||
  name: "bindText",
 | 
					 | 
				
			||||||
  contextItemLabel: "labels.bindText",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  contextItemPredicate: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (selectedElements.length === 2) {
 | 
					 | 
				
			||||||
      const textElement =
 | 
					 | 
				
			||||||
        isTextElement(selectedElements[0]) ||
 | 
					 | 
				
			||||||
        isTextElement(selectedElements[1]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      let bindingContainer;
 | 
					 | 
				
			||||||
      if (isTextBindableContainer(selectedElements[0])) {
 | 
					 | 
				
			||||||
        bindingContainer = selectedElements[0];
 | 
					 | 
				
			||||||
      } else if (isTextBindableContainer(selectedElements[1])) {
 | 
					 | 
				
			||||||
        bindingContainer = selectedElements[1];
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      if (
 | 
					 | 
				
			||||||
        textElement &&
 | 
					 | 
				
			||||||
        bindingContainer &&
 | 
					 | 
				
			||||||
        getBoundTextElement(bindingContainer) === null
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        return true;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return false;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(
 | 
					 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let textElement: ExcalidrawTextElement;
 | 
					 | 
				
			||||||
    let container: ExcalidrawTextContainer;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (
 | 
					 | 
				
			||||||
      isTextElement(selectedElements[0]) &&
 | 
					 | 
				
			||||||
      isTextBindableContainer(selectedElements[1])
 | 
					 | 
				
			||||||
    ) {
 | 
					 | 
				
			||||||
      textElement = selectedElements[0];
 | 
					 | 
				
			||||||
      container = selectedElements[1];
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      textElement = selectedElements[1] as ExcalidrawTextElement;
 | 
					 | 
				
			||||||
      container = selectedElements[0] as ExcalidrawTextContainer;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    mutateElement(textElement, {
 | 
					 | 
				
			||||||
      containerId: container.id,
 | 
					 | 
				
			||||||
      verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    mutateElement(container, {
 | 
					 | 
				
			||||||
      boundElements: (container.boundElements || []).concat({
 | 
					 | 
				
			||||||
        type: "text",
 | 
					 | 
				
			||||||
        id: textElement.id,
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    redrawTextBoundingBox(textElement, container);
 | 
					 | 
				
			||||||
    const updatedElements = elements.slice();
 | 
					 | 
				
			||||||
    const textElementIndex = updatedElements.findIndex(
 | 
					 | 
				
			||||||
      (ele) => ele.id === textElement.id,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    updatedElements.splice(textElementIndex, 1);
 | 
					 | 
				
			||||||
    const containerIndex = updatedElements.findIndex(
 | 
					 | 
				
			||||||
      (ele) => ele.id === container.id,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    updatedElements.splice(containerIndex + 1, 0, textElement);
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      elements: updatedElements,
 | 
					 | 
				
			||||||
      appState: { ...appState, selectedElementIds: { [container.id]: true } },
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@@ -1,48 +1,38 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { getDefaultAppState } from "../appState";
 | 
				
			||||||
import { ColorPicker } from "../components/ColorPicker";
 | 
					import { ColorPicker } from "../components/ColorPicker";
 | 
				
			||||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
 | 
					import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
					import { ZOOM_STEP } from "../constants";
 | 
				
			||||||
import { THEME, ZOOM_STEP } from "../constants";
 | 
					 | 
				
			||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
 | 
					import { getCommonBounds, getNonDeletedElements } from "../element";
 | 
				
			||||||
 | 
					import { newElementWith } from "../element/mutateElement";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
 | 
					import useIsMobile from "../is-mobile";
 | 
				
			||||||
import { CODES, KEYS } from "../keys";
 | 
					import { CODES, KEYS } from "../keys";
 | 
				
			||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
					import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
				
			||||||
import { centerScrollOn } from "../scene/scroll";
 | 
					import { centerScrollOn } from "../scene/scroll";
 | 
				
			||||||
import { getStateForZoom } from "../scene/zoom";
 | 
					import { getNewZoom } from "../scene/zoom";
 | 
				
			||||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
					import { AppState, NormalizedZoomValue } from "../types";
 | 
				
			||||||
import { getShortcutKey } from "../utils";
 | 
					import { getShortcutKey } from "../utils";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { Tooltip } from "../components/Tooltip";
 | 
					 | 
				
			||||||
import { newElementWith } from "../element/mutateElement";
 | 
					 | 
				
			||||||
import { getDefaultAppState, isEraserActive } from "../appState";
 | 
					 | 
				
			||||||
import ClearCanvas from "../components/ClearCanvas";
 | 
					 | 
				
			||||||
import clsx from "clsx";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionChangeViewBackgroundColor = register({
 | 
					export const actionChangeViewBackgroundColor = register({
 | 
				
			||||||
  name: "changeViewBackgroundColor",
 | 
					  name: "changeViewBackgroundColor",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (_, appState, value) => {
 | 
					  perform: (_, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: { ...appState, ...value },
 | 
					      appState: { ...appState, viewBackgroundColor: value },
 | 
				
			||||||
      commitToHistory: !!value.viewBackgroundColor,
 | 
					      commitToHistory: true,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
					  PanelComponent: ({ appState, updateData }) => {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div style={{ position: "relative" }}>
 | 
					      <div style={{ position: "relative" }}>
 | 
				
			||||||
        <ColorPicker
 | 
					        <ColorPicker
 | 
				
			||||||
          label={t("labels.canvasBackground")}
 | 
					          label={t("labels.canvasBackground")}
 | 
				
			||||||
          type="canvasBackground"
 | 
					          type="canvasBackground"
 | 
				
			||||||
          color={appState.viewBackgroundColor}
 | 
					          color={appState.viewBackgroundColor}
 | 
				
			||||||
          onChange={(color) => updateData({ viewBackgroundColor: color })}
 | 
					          onChange={(color) => updateData(color)}
 | 
				
			||||||
          isActive={appState.openPopup === "canvasColorPicker"}
 | 
					 | 
				
			||||||
          setActive={(active) =>
 | 
					 | 
				
			||||||
            updateData({ openPopup: active ? "canvasColorPicker" : null })
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          data-testid="canvas-background-picker"
 | 
					 | 
				
			||||||
          elements={elements}
 | 
					 | 
				
			||||||
          appState={appState}
 | 
					 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
@@ -51,51 +41,54 @@ export const actionChangeViewBackgroundColor = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionClearCanvas = register({
 | 
					export const actionClearCanvas = register({
 | 
				
			||||||
  name: "clearCanvas",
 | 
					  name: "clearCanvas",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					  perform: (elements, appState: AppState) => {
 | 
				
			||||||
  perform: (elements, appState, _, app) => {
 | 
					 | 
				
			||||||
    app.imageCache.clear();
 | 
					 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: elements.map((element) =>
 | 
					      elements: elements.map((element) =>
 | 
				
			||||||
        newElementWith(element, { isDeleted: true }),
 | 
					        newElementWith(element, { isDeleted: true }),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...getDefaultAppState(),
 | 
					        ...getDefaultAppState(),
 | 
				
			||||||
        files: {},
 | 
					 | 
				
			||||||
        theme: appState.theme,
 | 
					        theme: appState.theme,
 | 
				
			||||||
        penMode: appState.penMode,
 | 
					        elementLocked: appState.elementLocked,
 | 
				
			||||||
        penDetected: appState.penDetected,
 | 
					 | 
				
			||||||
        exportBackground: appState.exportBackground,
 | 
					        exportBackground: appState.exportBackground,
 | 
				
			||||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
					        exportEmbedScene: appState.exportEmbedScene,
 | 
				
			||||||
        gridSize: appState.gridSize,
 | 
					        gridSize: appState.gridSize,
 | 
				
			||||||
 | 
					        shouldAddWatermark: appState.shouldAddWatermark,
 | 
				
			||||||
        showStats: appState.showStats,
 | 
					        showStats: appState.showStats,
 | 
				
			||||||
        pasteDialog: appState.pasteDialog,
 | 
					        pasteDialog: appState.pasteDialog,
 | 
				
			||||||
        activeTool:
 | 
					 | 
				
			||||||
          appState.activeTool.type === "image"
 | 
					 | 
				
			||||||
            ? { ...appState.activeTool, type: "selection" }
 | 
					 | 
				
			||||||
            : appState.activeTool,
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: true,
 | 
					      commitToHistory: true,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  PanelComponent: ({ updateData }) => (
 | 
				
			||||||
  PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
 | 
					    <ToolButton
 | 
				
			||||||
 | 
					      type="button"
 | 
				
			||||||
 | 
					      icon={trash}
 | 
				
			||||||
 | 
					      title={t("buttons.clearReset")}
 | 
				
			||||||
 | 
					      aria-label={t("buttons.clearReset")}
 | 
				
			||||||
 | 
					      showAriaLabel={useIsMobile()}
 | 
				
			||||||
 | 
					      onClick={() => {
 | 
				
			||||||
 | 
					        if (window.confirm(t("alerts.clearReset"))) {
 | 
				
			||||||
 | 
					          updateData(null);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionZoomIn = register({
 | 
					export const actionZoomIn = register({
 | 
				
			||||||
  name: "zoomIn",
 | 
					  name: "zoomIn",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					  perform: (_elements, appState) => {
 | 
				
			||||||
  perform: (_elements, appState, _, app) => {
 | 
					    const zoom = getNewZoom(
 | 
				
			||||||
 | 
					      getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
 | 
				
			||||||
 | 
					      appState.zoom,
 | 
				
			||||||
 | 
					      { left: appState.offsetLeft, top: appState.offsetTop },
 | 
				
			||||||
 | 
					      { x: appState.width / 2, y: appState.height / 2 },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        ...getStateForZoom(
 | 
					        zoom,
 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
					 | 
				
			||||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
					 | 
				
			||||||
            nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          appState,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: false,
 | 
					      commitToHistory: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
@@ -109,7 +102,6 @@ export const actionZoomIn = register({
 | 
				
			|||||||
      onClick={() => {
 | 
					      onClick={() => {
 | 
				
			||||||
        updateData(null);
 | 
					        updateData(null);
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
      size="small"
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
@@ -119,19 +111,18 @@ export const actionZoomIn = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionZoomOut = register({
 | 
					export const actionZoomOut = register({
 | 
				
			||||||
  name: "zoomOut",
 | 
					  name: "zoomOut",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					  perform: (_elements, appState) => {
 | 
				
			||||||
  perform: (_elements, appState, _, app) => {
 | 
					    const zoom = getNewZoom(
 | 
				
			||||||
 | 
					      getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
 | 
				
			||||||
 | 
					      appState.zoom,
 | 
				
			||||||
 | 
					      { left: appState.offsetLeft, top: appState.offsetTop },
 | 
				
			||||||
 | 
					      { x: appState.width / 2, y: appState.height / 2 },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        ...getStateForZoom(
 | 
					        zoom,
 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
					 | 
				
			||||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
					 | 
				
			||||||
            nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          appState,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: false,
 | 
					      commitToHistory: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
@@ -145,7 +136,6 @@ export const actionZoomOut = register({
 | 
				
			|||||||
      onClick={() => {
 | 
					      onClick={() => {
 | 
				
			||||||
        updateData(null);
 | 
					        updateData(null);
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
      size="small"
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
@@ -155,38 +145,33 @@ export const actionZoomOut = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionResetZoom = register({
 | 
					export const actionResetZoom = register({
 | 
				
			||||||
  name: "resetZoom",
 | 
					  name: "resetZoom",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					  perform: (_elements, appState) => {
 | 
				
			||||||
  perform: (_elements, appState, _, app) => {
 | 
					 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        ...getStateForZoom(
 | 
					        zoom: getNewZoom(
 | 
				
			||||||
 | 
					          1 as NormalizedZoomValue,
 | 
				
			||||||
 | 
					          appState.zoom,
 | 
				
			||||||
 | 
					          { left: appState.offsetLeft, top: appState.offsetTop },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
					            x: appState.width / 2,
 | 
				
			||||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
					            y: appState.height / 2,
 | 
				
			||||||
            nextZoom: getNormalizedZoom(1),
 | 
					 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          appState,
 | 
					 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: false,
 | 
					      commitToHistory: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ updateData, appState }) => (
 | 
					  PanelComponent: ({ updateData }) => (
 | 
				
			||||||
    <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
 | 
					    <ToolButton
 | 
				
			||||||
      <ToolButton
 | 
					      type="button"
 | 
				
			||||||
        type="button"
 | 
					      icon={resetZoom}
 | 
				
			||||||
        className="reset-zoom-button"
 | 
					      title={t("buttons.resetZoom")}
 | 
				
			||||||
        title={t("buttons.resetZoom")}
 | 
					      aria-label={t("buttons.resetZoom")}
 | 
				
			||||||
        aria-label={t("buttons.resetZoom")}
 | 
					      onClick={() => {
 | 
				
			||||||
        onClick={() => {
 | 
					        updateData(null);
 | 
				
			||||||
          updateData(null);
 | 
					      }}
 | 
				
			||||||
        }}
 | 
					    />
 | 
				
			||||||
        size="small"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        {(appState.zoom.value * 100).toFixed(0)}%
 | 
					 | 
				
			||||||
      </ToolButton>
 | 
					 | 
				
			||||||
    </Tooltip>
 | 
					 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
    (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
 | 
					    (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
 | 
				
			||||||
@@ -225,12 +210,14 @@ const zoomToFitElements = (
 | 
				
			|||||||
      ? getCommonBounds(selectedElements)
 | 
					      ? getCommonBounds(selectedElements)
 | 
				
			||||||
      : getCommonBounds(nonDeletedElements);
 | 
					      : getCommonBounds(nonDeletedElements);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const newZoom = {
 | 
					  const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
 | 
				
			||||||
    value: zoomValueToFitBoundsOnViewport(commonBounds, {
 | 
					    width: appState.width,
 | 
				
			||||||
      width: appState.width,
 | 
					    height: appState.height,
 | 
				
			||||||
      height: appState.height,
 | 
					  });
 | 
				
			||||||
    }),
 | 
					  const newZoom = getNewZoom(zoomValue, appState.zoom, {
 | 
				
			||||||
  };
 | 
					    left: appState.offsetLeft,
 | 
				
			||||||
 | 
					    top: appState.offsetTop,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [x1, y1, x2, y2] = commonBounds;
 | 
					  const [x1, y1, x2, y2] = commonBounds;
 | 
				
			||||||
  const centerX = (x1 + x2) / 2;
 | 
					  const centerX = (x1 + x2) / 2;
 | 
				
			||||||
@@ -254,7 +241,6 @@ const zoomToFitElements = (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionZoomToSelected = register({
 | 
					export const actionZoomToSelected = register({
 | 
				
			||||||
  name: "zoomToSelection",
 | 
					  name: "zoomToSelection",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
 | 
					  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
    event.code === CODES.TWO &&
 | 
					    event.code === CODES.TWO &&
 | 
				
			||||||
@@ -265,7 +251,6 @@ export const actionZoomToSelected = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionZoomToFit = register({
 | 
					export const actionZoomToFit = register({
 | 
				
			||||||
  name: "zoomToFit",
 | 
					  name: "zoomToFit",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
 | 
					  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
    event.code === CODES.ONE &&
 | 
					    event.code === CODES.ONE &&
 | 
				
			||||||
@@ -273,89 +258,3 @@ export const actionZoomToFit = register({
 | 
				
			|||||||
    !event.altKey &&
 | 
					    !event.altKey &&
 | 
				
			||||||
    !event[KEYS.CTRL_OR_CMD],
 | 
					    !event[KEYS.CTRL_OR_CMD],
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionToggleTheme = register({
 | 
					 | 
				
			||||||
  name: "toggleTheme",
 | 
					 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					 | 
				
			||||||
  perform: (_, appState, value) => {
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      appState: {
 | 
					 | 
				
			||||||
        ...appState,
 | 
					 | 
				
			||||||
        theme:
 | 
					 | 
				
			||||||
          value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      commitToHistory: false,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  PanelComponent: ({ appState, updateData }) => (
 | 
					 | 
				
			||||||
    <div style={{ marginInlineStart: "0.25rem" }}>
 | 
					 | 
				
			||||||
      <DarkModeToggle
 | 
					 | 
				
			||||||
        value={appState.theme}
 | 
					 | 
				
			||||||
        onChange={(theme) => {
 | 
					 | 
				
			||||||
          updateData(theme);
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  ),
 | 
					 | 
				
			||||||
  keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionErase = register({
 | 
					 | 
				
			||||||
  name: "eraser",
 | 
					 | 
				
			||||||
  trackEvent: { category: "toolbar" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    const activeTool: any = { ...appState.activeTool };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (appState.activeTool.type !== "eraser") {
 | 
					 | 
				
			||||||
      if (appState.activeTool.type === "custom") {
 | 
					 | 
				
			||||||
        activeTool.lastActiveToolBeforeEraser = {
 | 
					 | 
				
			||||||
          type: "custom",
 | 
					 | 
				
			||||||
          customType: appState.activeTool.customType,
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (isEraserActive(appState)) {
 | 
					 | 
				
			||||||
      if (appState.activeTool.lastActiveToolBeforeEraser) {
 | 
					 | 
				
			||||||
        if (
 | 
					 | 
				
			||||||
          typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
 | 
					 | 
				
			||||||
          appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
 | 
					 | 
				
			||||||
        ) {
 | 
					 | 
				
			||||||
          activeTool.type = "custom";
 | 
					 | 
				
			||||||
          activeTool.customType =
 | 
					 | 
				
			||||||
            appState.activeTool.lastActiveToolBeforeEraser.customType;
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        activeTool.type = "selection";
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      activeTool.type = "eraser";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      appState: {
 | 
					 | 
				
			||||||
        ...appState,
 | 
					 | 
				
			||||||
        selectedElementIds: {},
 | 
					 | 
				
			||||||
        selectedGroupIds: {},
 | 
					 | 
				
			||||||
        activeTool,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  keyTest: (event) => event.key === KEYS.E,
 | 
					 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData, data }) => (
 | 
					 | 
				
			||||||
    <ToolButton
 | 
					 | 
				
			||||||
      type="button"
 | 
					 | 
				
			||||||
      icon={eraser}
 | 
					 | 
				
			||||||
      className={clsx("eraser", { active: isEraserActive(appState) })}
 | 
					 | 
				
			||||||
      title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
 | 
					 | 
				
			||||||
      aria-label={t("toolBar.eraser")}
 | 
					 | 
				
			||||||
      onClick={() => {
 | 
					 | 
				
			||||||
        updateData(null);
 | 
					 | 
				
			||||||
      }}
 | 
					 | 
				
			||||||
      size={data?.size || "medium"}
 | 
					 | 
				
			||||||
    ></ToolButton>
 | 
					 | 
				
			||||||
  ),
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,23 +1,16 @@
 | 
				
			|||||||
import { CODES, KEYS } from "../keys";
 | 
					import { CODES, KEYS } from "../keys";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import {
 | 
					import { copyToClipboard } from "../clipboard";
 | 
				
			||||||
  copyTextToSystemClipboard,
 | 
					 | 
				
			||||||
  copyToClipboard,
 | 
					 | 
				
			||||||
  probablySupportsClipboardWriteText,
 | 
					 | 
				
			||||||
} from "../clipboard";
 | 
					 | 
				
			||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
 | 
					import { actionDeleteSelected } from "./actionDeleteSelected";
 | 
				
			||||||
import { getSelectedElements } from "../scene/selection";
 | 
					import { getSelectedElements } from "../scene/selection";
 | 
				
			||||||
import { exportCanvas } from "../data/index";
 | 
					import { exportCanvas } from "../data/index";
 | 
				
			||||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
					import { getNonDeletedElements } from "../element";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionCopy = register({
 | 
					export const actionCopy = register({
 | 
				
			||||||
  name: "copy",
 | 
					  name: "copy",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
  perform: (elements, appState, _, app) => {
 | 
					    copyToClipboard(getNonDeletedElements(elements), appState);
 | 
				
			||||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    copyToClipboard(selectedElements, appState, app.files);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      commitToHistory: false,
 | 
					      commitToHistory: false,
 | 
				
			||||||
@@ -30,10 +23,9 @@ export const actionCopy = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionCut = register({
 | 
					export const actionCut = register({
 | 
				
			||||||
  name: "cut",
 | 
					  name: "cut",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState, data, app) => {
 | 
					  perform: (elements, appState, data, app) => {
 | 
				
			||||||
    actionCopy.perform(elements, appState, data, app);
 | 
					    actionCopy.perform(elements, appState, data, app);
 | 
				
			||||||
    return actionDeleteSelected.perform(elements, appState);
 | 
					    return actionDeleteSelected.perform(elements, appState, data, app);
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  contextItemLabel: "labels.cut",
 | 
					  contextItemLabel: "labels.cut",
 | 
				
			||||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
 | 
					  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
 | 
				
			||||||
@@ -41,7 +33,6 @@ export const actionCut = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionCopyAsSvg = register({
 | 
					export const actionCopyAsSvg = register({
 | 
				
			||||||
  name: "copyAsSvg",
 | 
					  name: "copyAsSvg",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: async (elements, appState, _data, app) => {
 | 
					  perform: async (elements, appState, _data, app) => {
 | 
				
			||||||
    if (!app.canvas) {
 | 
					    if (!app.canvas) {
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
@@ -51,7 +42,6 @@ export const actionCopyAsSvg = register({
 | 
				
			|||||||
    const selectedElements = getSelectedElements(
 | 
					    const selectedElements = getSelectedElements(
 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					      getNonDeletedElements(elements),
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await exportCanvas(
 | 
					      await exportCanvas(
 | 
				
			||||||
@@ -60,13 +50,13 @@ export const actionCopyAsSvg = register({
 | 
				
			|||||||
          ? selectedElements
 | 
					          ? selectedElements
 | 
				
			||||||
          : getNonDeletedElements(elements),
 | 
					          : getNonDeletedElements(elements),
 | 
				
			||||||
        appState,
 | 
					        appState,
 | 
				
			||||||
        app.files,
 | 
					        app.canvas,
 | 
				
			||||||
        appState,
 | 
					        appState,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        commitToHistory: false,
 | 
					        commitToHistory: false,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error(error);
 | 
					      console.error(error);
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        appState: {
 | 
					        appState: {
 | 
				
			||||||
@@ -82,7 +72,6 @@ export const actionCopyAsSvg = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionCopyAsPng = register({
 | 
					export const actionCopyAsPng = register({
 | 
				
			||||||
  name: "copyAsPng",
 | 
					  name: "copyAsPng",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: async (elements, appState, _data, app) => {
 | 
					  perform: async (elements, appState, _data, app) => {
 | 
				
			||||||
    if (!app.canvas) {
 | 
					    if (!app.canvas) {
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
@@ -92,7 +81,6 @@ export const actionCopyAsPng = register({
 | 
				
			|||||||
    const selectedElements = getSelectedElements(
 | 
					    const selectedElements = getSelectedElements(
 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					      getNonDeletedElements(elements),
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await exportCanvas(
 | 
					      await exportCanvas(
 | 
				
			||||||
@@ -101,7 +89,7 @@ export const actionCopyAsPng = register({
 | 
				
			|||||||
          ? selectedElements
 | 
					          ? selectedElements
 | 
				
			||||||
          : getNonDeletedElements(elements),
 | 
					          : getNonDeletedElements(elements),
 | 
				
			||||||
        appState,
 | 
					        appState,
 | 
				
			||||||
        app.files,
 | 
					        app.canvas,
 | 
				
			||||||
        appState,
 | 
					        appState,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
@@ -118,7 +106,7 @@ export const actionCopyAsPng = register({
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        commitToHistory: false,
 | 
					        commitToHistory: false,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error(error);
 | 
					      console.error(error);
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        appState: {
 | 
					        appState: {
 | 
				
			||||||
@@ -132,35 +120,3 @@ export const actionCopyAsPng = register({
 | 
				
			|||||||
  contextItemLabel: "labels.copyAsPng",
 | 
					  contextItemLabel: "labels.copyAsPng",
 | 
				
			||||||
  keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
 | 
					  keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					 | 
				
			||||||
export const copyText = register({
 | 
					 | 
				
			||||||
  name: "copyText",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(
 | 
					 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const text = selectedElements
 | 
					 | 
				
			||||||
      .reduce((acc: string[], element) => {
 | 
					 | 
				
			||||||
        if (isTextElement(element)) {
 | 
					 | 
				
			||||||
          acc.push(element.text);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return acc;
 | 
					 | 
				
			||||||
      }, [])
 | 
					 | 
				
			||||||
      .join("\n\n");
 | 
					 | 
				
			||||||
    copyTextToSystemClipboard(text);
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      commitToHistory: false,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  contextItemPredicate: (elements, appState) => {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      probablySupportsClipboardWriteText &&
 | 
					 | 
				
			||||||
      getSelectedElements(elements, appState, true).some(isTextElement)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  contextItemLabel: "labels.copyText",
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import { isSomeElementSelected } from "../scene";
 | 
					import { isSomeElementSelected } from "../scene";
 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
import { trash } from "../components/icons";
 | 
					import { trash } from "../components/icons";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
@@ -11,7 +12,6 @@ import { newElementWith } from "../element/mutateElement";
 | 
				
			|||||||
import { getElementsInGroup } from "../groups";
 | 
					import { getElementsInGroup } from "../groups";
 | 
				
			||||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
					import { LinearElementEditor } from "../element/linearElementEditor";
 | 
				
			||||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
					import { fixBindingsAfterDeletion } from "../element/binding";
 | 
				
			||||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const deleteSelectedElements = (
 | 
					const deleteSelectedElements = (
 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					  elements: readonly ExcalidrawElement[],
 | 
				
			||||||
@@ -22,12 +22,6 @@ const deleteSelectedElements = (
 | 
				
			|||||||
      if (appState.selectedElementIds[el.id]) {
 | 
					      if (appState.selectedElementIds[el.id]) {
 | 
				
			||||||
        return newElementWith(el, { isDeleted: true });
 | 
					        return newElementWith(el, { isDeleted: true });
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      if (
 | 
					 | 
				
			||||||
        isBoundToContainer(el) &&
 | 
					 | 
				
			||||||
        appState.selectedElementIds[el.containerId]
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        return newElementWith(el, { isDeleted: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return el;
 | 
					      return el;
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
    appState: {
 | 
					    appState: {
 | 
				
			||||||
@@ -58,12 +52,11 @@ const handleGroupEditingState = (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionDeleteSelected = register({
 | 
					export const actionDeleteSelected = register({
 | 
				
			||||||
  name: "deleteSelectedElements",
 | 
					  name: "deleteSelectedElements",
 | 
				
			||||||
  trackEvent: { category: "element", action: "delete" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    if (appState.editingLinearElement) {
 | 
					    if (appState.editingLinearElement) {
 | 
				
			||||||
      const {
 | 
					      const {
 | 
				
			||||||
        elementId,
 | 
					        elementId,
 | 
				
			||||||
        selectedPointsIndices,
 | 
					        activePointIndex,
 | 
				
			||||||
        startBindingElement,
 | 
					        startBindingElement,
 | 
				
			||||||
        endBindingElement,
 | 
					        endBindingElement,
 | 
				
			||||||
      } = appState.editingLinearElement;
 | 
					      } = appState.editingLinearElement;
 | 
				
			||||||
@@ -73,7 +66,8 @@ export const actionDeleteSelected = register({
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
      if (
 | 
					      if (
 | 
				
			||||||
        // case: no point selected → delete whole element
 | 
					        // case: no point selected → delete whole element
 | 
				
			||||||
        selectedPointsIndices == null ||
 | 
					        activePointIndex == null ||
 | 
				
			||||||
 | 
					        activePointIndex === -1 ||
 | 
				
			||||||
        // case: deleting last remaining point
 | 
					        // case: deleting last remaining point
 | 
				
			||||||
        element.points.length < 2
 | 
					        element.points.length < 2
 | 
				
			||||||
      ) {
 | 
					      ) {
 | 
				
			||||||
@@ -93,17 +87,15 @@ export const actionDeleteSelected = register({
 | 
				
			|||||||
      // We cannot do this inside `movePoint` because it is also called
 | 
					      // We cannot do this inside `movePoint` because it is also called
 | 
				
			||||||
      // when deleting the uncommitted point (which hasn't caused any binding)
 | 
					      // when deleting the uncommitted point (which hasn't caused any binding)
 | 
				
			||||||
      const binding = {
 | 
					      const binding = {
 | 
				
			||||||
        startBindingElement: selectedPointsIndices?.includes(0)
 | 
					        startBindingElement:
 | 
				
			||||||
          ? null
 | 
					          activePointIndex === 0 ? null : startBindingElement,
 | 
				
			||||||
          : startBindingElement,
 | 
					        endBindingElement:
 | 
				
			||||||
        endBindingElement: selectedPointsIndices?.includes(
 | 
					          activePointIndex === element.points.length - 1
 | 
				
			||||||
          element.points.length - 1,
 | 
					            ? null
 | 
				
			||||||
        )
 | 
					            : endBindingElement,
 | 
				
			||||||
          ? null
 | 
					 | 
				
			||||||
          : endBindingElement,
 | 
					 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      LinearElementEditor.deletePoints(element, selectedPointsIndices);
 | 
					      LinearElementEditor.movePoint(element, activePointIndex, "delete");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        elements,
 | 
					        elements,
 | 
				
			||||||
@@ -112,17 +104,17 @@ export const actionDeleteSelected = register({
 | 
				
			|||||||
          editingLinearElement: {
 | 
					          editingLinearElement: {
 | 
				
			||||||
            ...appState.editingLinearElement,
 | 
					            ...appState.editingLinearElement,
 | 
				
			||||||
            ...binding,
 | 
					            ...binding,
 | 
				
			||||||
            selectedPointsIndices:
 | 
					            activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
 | 
				
			||||||
              selectedPointsIndices?.[0] > 0
 | 
					 | 
				
			||||||
                ? [selectedPointsIndices[0] - 1]
 | 
					 | 
				
			||||||
                : [0],
 | 
					 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        commitToHistory: true,
 | 
					        commitToHistory: true,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    let { elements: nextElements, appState: nextAppState } =
 | 
					
 | 
				
			||||||
      deleteSelectedElements(elements, appState);
 | 
					    let {
 | 
				
			||||||
 | 
					      elements: nextElements,
 | 
				
			||||||
 | 
					      appState: nextAppState,
 | 
				
			||||||
 | 
					    } = deleteSelectedElements(elements, appState);
 | 
				
			||||||
    fixBindingsAfterDeletion(
 | 
					    fixBindingsAfterDeletion(
 | 
				
			||||||
      nextElements,
 | 
					      nextElements,
 | 
				
			||||||
      elements.filter(({ id }) => appState.selectedElementIds[id]),
 | 
					      elements.filter(({ id }) => appState.selectedElementIds[id]),
 | 
				
			||||||
@@ -134,7 +126,7 @@ export const actionDeleteSelected = register({
 | 
				
			|||||||
      elements: nextElements,
 | 
					      elements: nextElements,
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...nextAppState,
 | 
					        ...nextAppState,
 | 
				
			||||||
        activeTool: { ...appState.activeTool, type: "selection" },
 | 
					        elementType: "selection",
 | 
				
			||||||
        multiElement: null,
 | 
					        multiElement: null,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: isSomeElementSelected(
 | 
					      commitToHistory: isSomeElementSelected(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,16 +1,17 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DistributeHorizontallyIcon,
 | 
					  DistributeHorizontallyIcon,
 | 
				
			||||||
  DistributeVerticallyIcon,
 | 
					  DistributeVerticallyIcon,
 | 
				
			||||||
} from "../components/icons";
 | 
					} from "../components/icons";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { distributeElements, Distribution } from "../disitrubte";
 | 
					import { distributeElements, Distribution } from "../disitrubte";
 | 
				
			||||||
import { getNonDeletedElements } from "../element";
 | 
					import { getElementMap, getNonDeletedElements } from "../element";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { CODES, KEYS } from "../keys";
 | 
					import { CODES } from "../keys";
 | 
				
			||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
					import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
					import { getShortcutKey } from "../utils";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const enableActionGroup = (
 | 
					const enableActionGroup = (
 | 
				
			||||||
@@ -30,16 +31,13 @@ const distributeSelectedElements = (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const updatedElements = distributeElements(selectedElements, distribution);
 | 
					  const updatedElements = distributeElements(selectedElements, distribution);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
					  const updatedElementsMap = getElementMap(updatedElements);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return elements.map(
 | 
					  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
				
			||||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const distributeHorizontally = register({
 | 
					export const distributeHorizontally = register({
 | 
				
			||||||
  name: "distributeHorizontally",
 | 
					  name: "distributeHorizontally",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -50,8 +48,7 @@ export const distributeHorizontally = register({
 | 
				
			|||||||
      commitToHistory: true,
 | 
					      commitToHistory: true,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) => event.altKey && event.code === CODES.H,
 | 
				
			||||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
 | 
					 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
					  PanelComponent: ({ elements, appState, updateData }) => (
 | 
				
			||||||
    <ToolButton
 | 
					    <ToolButton
 | 
				
			||||||
      hidden={!enableActionGroup(elements, appState)}
 | 
					      hidden={!enableActionGroup(elements, appState)}
 | 
				
			||||||
@@ -69,7 +66,6 @@ export const distributeHorizontally = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const distributeVertically = register({
 | 
					export const distributeVertically = register({
 | 
				
			||||||
  name: "distributeVertically",
 | 
					  name: "distributeVertically",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
@@ -80,8 +76,7 @@ export const distributeVertically = register({
 | 
				
			|||||||
      commitToHistory: true,
 | 
					      commitToHistory: true,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) => event.altKey && event.code === CODES.V,
 | 
				
			||||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
 | 
					 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
					  PanelComponent: ({ elements, appState, updateData }) => (
 | 
				
			||||||
    <ToolButton
 | 
					    <ToolButton
 | 
				
			||||||
      hidden={!enableActionGroup(elements, appState)}
 | 
					      hidden={!enableActionGroup(elements, appState)}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,15 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { duplicateElement, getNonDeletedElements } from "../element";
 | 
					import { duplicateElement, getNonDeletedElements } from "../element";
 | 
				
			||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
					import { isSomeElementSelected } from "../scene";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { clone } from "../components/icons";
 | 
					import { clone } from "../components/icons";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
					import { getShortcutKey } from "../utils";
 | 
				
			||||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
					import { LinearElementEditor } from "../element/linearElementEditor";
 | 
				
			||||||
 | 
					import { mutateElement } from "../element/mutateElement";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  selectGroupsForSelectedElements,
 | 
					  selectGroupsForSelectedElements,
 | 
				
			||||||
  getSelectedGroupForElement,
 | 
					  getSelectedGroupForElement,
 | 
				
			||||||
@@ -17,24 +19,41 @@ import { AppState } from "../types";
 | 
				
			|||||||
import { fixBindingsAfterDuplication } from "../element/binding";
 | 
					import { fixBindingsAfterDuplication } from "../element/binding";
 | 
				
			||||||
import { ActionResult } from "./types";
 | 
					import { ActionResult } from "./types";
 | 
				
			||||||
import { GRID_SIZE } from "../constants";
 | 
					import { GRID_SIZE } from "../constants";
 | 
				
			||||||
import { bindTextToShapeAfterDuplication } from "../element/textElement";
 | 
					 | 
				
			||||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionDuplicateSelection = register({
 | 
					export const actionDuplicateSelection = register({
 | 
				
			||||||
  name: "duplicateSelection",
 | 
					  name: "duplicateSelection",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    // duplicate selected point(s) if editing a line
 | 
					    // duplicate point if selected while editing multi-point element
 | 
				
			||||||
    if (appState.editingLinearElement) {
 | 
					    if (appState.editingLinearElement) {
 | 
				
			||||||
      const ret = LinearElementEditor.duplicateSelectedPoints(appState);
 | 
					      const { activePointIndex, elementId } = appState.editingLinearElement;
 | 
				
			||||||
 | 
					      const element = LinearElementEditor.getElement(elementId);
 | 
				
			||||||
      if (!ret) {
 | 
					      if (!element || activePointIndex === null) {
 | 
				
			||||||
        return false;
 | 
					        return false;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					      const { points } = element;
 | 
				
			||||||
 | 
					      const selectedPoint = points[activePointIndex];
 | 
				
			||||||
 | 
					      const nextPoint = points[activePointIndex + 1];
 | 
				
			||||||
 | 
					      mutateElement(element, {
 | 
				
			||||||
 | 
					        points: [
 | 
				
			||||||
 | 
					          ...points.slice(0, activePointIndex + 1),
 | 
				
			||||||
 | 
					          nextPoint
 | 
				
			||||||
 | 
					            ? [
 | 
				
			||||||
 | 
					                (selectedPoint[0] + nextPoint[0]) / 2,
 | 
				
			||||||
 | 
					                (selectedPoint[1] + nextPoint[1]) / 2,
 | 
				
			||||||
 | 
					              ]
 | 
				
			||||||
 | 
					            : [selectedPoint[0] + 30, selectedPoint[1] + 30],
 | 
				
			||||||
 | 
					          ...points.slice(activePointIndex + 1),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
 | 
					        appState: {
 | 
				
			||||||
 | 
					          ...appState,
 | 
				
			||||||
 | 
					          editingLinearElement: {
 | 
				
			||||||
 | 
					            ...appState.editingLinearElement,
 | 
				
			||||||
 | 
					            activePointIndex: activePointIndex + 1,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
        elements,
 | 
					        elements,
 | 
				
			||||||
        appState: ret.appState,
 | 
					 | 
				
			||||||
        commitToHistory: true,
 | 
					        commitToHistory: true,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -88,12 +107,9 @@ const duplicateElements = (
 | 
				
			|||||||
  const finalElements: ExcalidrawElement[] = [];
 | 
					  const finalElements: ExcalidrawElement[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let index = 0;
 | 
					  let index = 0;
 | 
				
			||||||
  const selectedElementIds = arrayToMap(
 | 
					 | 
				
			||||||
    getSelectedElements(elements, appState, true),
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  while (index < elements.length) {
 | 
					  while (index < elements.length) {
 | 
				
			||||||
    const element = elements[index];
 | 
					    const element = elements[index];
 | 
				
			||||||
    if (selectedElementIds.get(element.id)) {
 | 
					    if (appState.selectedElementIds[element.id]) {
 | 
				
			||||||
      if (element.groupIds.length) {
 | 
					      if (element.groupIds.length) {
 | 
				
			||||||
        const groupId = getSelectedGroupForElement(appState, element);
 | 
					        const groupId = getSelectedGroupForElement(appState, element);
 | 
				
			||||||
        // if group selected, duplicate it atomically
 | 
					        // if group selected, duplicate it atomically
 | 
				
			||||||
@@ -115,11 +131,7 @@ const duplicateElements = (
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    index++;
 | 
					    index++;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  bindTextToShapeAfterDuplication(
 | 
					
 | 
				
			||||||
    finalElements,
 | 
					 | 
				
			||||||
    oldElements,
 | 
					 | 
				
			||||||
    oldIdToDuplicatedId,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
 | 
					  fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
@@ -129,9 +141,7 @@ const duplicateElements = (
 | 
				
			|||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        selectedGroupIds: {},
 | 
					        selectedGroupIds: {},
 | 
				
			||||||
        selectedElementIds: newElements.reduce((acc, element) => {
 | 
					        selectedElementIds: newElements.reduce((acc, element) => {
 | 
				
			||||||
          if (!isBoundToContainer(element)) {
 | 
					          acc[element.id] = true;
 | 
				
			||||||
            acc[element.id] = true;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          return acc;
 | 
					          return acc;
 | 
				
			||||||
        }, {} as any),
 | 
					        }, {} as any),
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,29 +1,21 @@
 | 
				
			|||||||
import { load, questionCircle, saveAs } from "../components/icons";
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { trackEvent } from "../analytics";
 | 
				
			||||||
 | 
					import { load, questionCircle, save, saveAs } from "../components/icons";
 | 
				
			||||||
import { ProjectName } from "../components/ProjectName";
 | 
					import { ProjectName } from "../components/ProjectName";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import "../components/ToolIcon.scss";
 | 
					import "../components/ToolIcon.scss";
 | 
				
			||||||
import { Tooltip } from "../components/Tooltip";
 | 
					import { Tooltip } from "../components/Tooltip";
 | 
				
			||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
					import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
 | 
				
			||||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
					import { loadFromJSON, saveAsJSON } from "../data";
 | 
				
			||||||
import { resaveAsImageWithScene } from "../data/resave";
 | 
					 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { useDeviceType } from "../components/App";
 | 
					import useIsMobile from "../is-mobile";
 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { CheckboxItem } from "../components/CheckboxItem";
 | 
					 | 
				
			||||||
import { getExportSize } from "../scene/export";
 | 
					 | 
				
			||||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
 | 
					 | 
				
			||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
					 | 
				
			||||||
import { getNonDeletedElements } from "../element";
 | 
					 | 
				
			||||||
import { ActiveFile } from "../components/ActiveFile";
 | 
					 | 
				
			||||||
import { isImageFileHandle } from "../data/blob";
 | 
					 | 
				
			||||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
					 | 
				
			||||||
import { Theme } from "../element/types";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionChangeProjectName = register({
 | 
					export const actionChangeProjectName = register({
 | 
				
			||||||
  name: "changeProjectName",
 | 
					  name: "changeProjectName",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (_elements, appState, value) => {
 | 
					  perform: (_elements, appState, value) => {
 | 
				
			||||||
 | 
					    trackEvent("change", "title");
 | 
				
			||||||
    return { appState: { ...appState, name: value }, commitToHistory: false };
 | 
					    return { appState: { ...appState, name: value }, commitToHistory: false };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ appState, updateData, appProps }) => (
 | 
					  PanelComponent: ({ appState, updateData, appProps }) => (
 | 
				
			||||||
@@ -38,58 +30,8 @@ export const actionChangeProjectName = register({
 | 
				
			|||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionChangeExportScale = register({
 | 
					 | 
				
			||||||
  name: "changeExportScale",
 | 
					 | 
				
			||||||
  trackEvent: { category: "export", action: "scale" },
 | 
					 | 
				
			||||||
  perform: (_elements, appState, value) => {
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      appState: { ...appState, exportScale: value },
 | 
					 | 
				
			||||||
      commitToHistory: false,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  PanelComponent: ({ elements: allElements, appState, updateData }) => {
 | 
					 | 
				
			||||||
    const elements = getNonDeletedElements(allElements);
 | 
					 | 
				
			||||||
    const exportSelected = isSomeElementSelected(elements, appState);
 | 
					 | 
				
			||||||
    const exportedElements = exportSelected
 | 
					 | 
				
			||||||
      ? getSelectedElements(elements, appState)
 | 
					 | 
				
			||||||
      : elements;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <>
 | 
					 | 
				
			||||||
        {EXPORT_SCALES.map((s) => {
 | 
					 | 
				
			||||||
          const [width, height] = getExportSize(
 | 
					 | 
				
			||||||
            exportedElements,
 | 
					 | 
				
			||||||
            DEFAULT_EXPORT_PADDING,
 | 
					 | 
				
			||||||
            s,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          const scaleButtonTitle = `${t(
 | 
					 | 
				
			||||||
            "buttons.scale",
 | 
					 | 
				
			||||||
          )} ${s}x (${width}x${height})`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          return (
 | 
					 | 
				
			||||||
            <ToolButton
 | 
					 | 
				
			||||||
              key={s}
 | 
					 | 
				
			||||||
              size="small"
 | 
					 | 
				
			||||||
              type="radio"
 | 
					 | 
				
			||||||
              icon={`${s}x`}
 | 
					 | 
				
			||||||
              name="export-canvas-scale"
 | 
					 | 
				
			||||||
              title={scaleButtonTitle}
 | 
					 | 
				
			||||||
              aria-label={scaleButtonTitle}
 | 
					 | 
				
			||||||
              id="export-canvas-scale"
 | 
					 | 
				
			||||||
              checked={s === appState.exportScale}
 | 
					 | 
				
			||||||
              onChange={() => updateData(s)}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        })}
 | 
					 | 
				
			||||||
      </>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionChangeExportBackground = register({
 | 
					export const actionChangeExportBackground = register({
 | 
				
			||||||
  name: "changeExportBackground",
 | 
					  name: "changeExportBackground",
 | 
				
			||||||
  trackEvent: { category: "export", action: "toggleBackground" },
 | 
					 | 
				
			||||||
  perform: (_elements, appState, value) => {
 | 
					  perform: (_elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: { ...appState, exportBackground: value },
 | 
					      appState: { ...appState, exportBackground: value },
 | 
				
			||||||
@@ -97,18 +39,19 @@ export const actionChangeExportBackground = register({
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ appState, updateData }) => (
 | 
					  PanelComponent: ({ appState, updateData }) => (
 | 
				
			||||||
    <CheckboxItem
 | 
					    <label>
 | 
				
			||||||
      checked={appState.exportBackground}
 | 
					      <input
 | 
				
			||||||
      onChange={(checked) => updateData(checked)}
 | 
					        type="checkbox"
 | 
				
			||||||
    >
 | 
					        checked={appState.exportBackground}
 | 
				
			||||||
 | 
					        onChange={(event) => updateData(event.target.checked)}
 | 
				
			||||||
 | 
					      />{" "}
 | 
				
			||||||
      {t("labels.withBackground")}
 | 
					      {t("labels.withBackground")}
 | 
				
			||||||
    </CheckboxItem>
 | 
					    </label>
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionChangeExportEmbedScene = register({
 | 
					export const actionChangeExportEmbedScene = register({
 | 
				
			||||||
  name: "changeExportEmbedScene",
 | 
					  name: "changeExportEmbedScene",
 | 
				
			||||||
  trackEvent: { category: "export", action: "embedScene" },
 | 
					 | 
				
			||||||
  perform: (_elements, appState, value) => {
 | 
					  perform: (_elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: { ...appState, exportEmbedScene: value },
 | 
					      appState: { ...appState, exportEmbedScene: value },
 | 
				
			||||||
@@ -116,36 +59,57 @@ export const actionChangeExportEmbedScene = register({
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ appState, updateData }) => (
 | 
					  PanelComponent: ({ appState, updateData }) => (
 | 
				
			||||||
    <CheckboxItem
 | 
					    <label style={{ display: "flex" }}>
 | 
				
			||||||
      checked={appState.exportEmbedScene}
 | 
					      <input
 | 
				
			||||||
      onChange={(checked) => updateData(checked)}
 | 
					        type="checkbox"
 | 
				
			||||||
    >
 | 
					        checked={appState.exportEmbedScene}
 | 
				
			||||||
 | 
					        onChange={(event) => updateData(event.target.checked)}
 | 
				
			||||||
 | 
					      />{" "}
 | 
				
			||||||
      {t("labels.exportEmbedScene")}
 | 
					      {t("labels.exportEmbedScene")}
 | 
				
			||||||
      <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
 | 
					      <Tooltip
 | 
				
			||||||
        <div className="excalidraw-tooltip-icon">{questionCircle}</div>
 | 
					        label={t("labels.exportEmbedScene_details")}
 | 
				
			||||||
 | 
					        position="above"
 | 
				
			||||||
 | 
					        long={true}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div className="TooltipIcon">{questionCircle}</div>
 | 
				
			||||||
      </Tooltip>
 | 
					      </Tooltip>
 | 
				
			||||||
    </CheckboxItem>
 | 
					    </label>
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionSaveToActiveFile = register({
 | 
					export const actionChangeShouldAddWatermark = register({
 | 
				
			||||||
  name: "saveToActiveFile",
 | 
					  name: "changeShouldAddWatermark",
 | 
				
			||||||
  trackEvent: { category: "export" },
 | 
					  perform: (_elements, appState, value) => {
 | 
				
			||||||
  perform: async (elements, appState, value, app) => {
 | 
					    return {
 | 
				
			||||||
 | 
					      appState: { ...appState, shouldAddWatermark: value },
 | 
				
			||||||
 | 
					      commitToHistory: false,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  PanelComponent: ({ appState, updateData }) => (
 | 
				
			||||||
 | 
					    <label>
 | 
				
			||||||
 | 
					      <input
 | 
				
			||||||
 | 
					        type="checkbox"
 | 
				
			||||||
 | 
					        checked={appState.shouldAddWatermark}
 | 
				
			||||||
 | 
					        onChange={(event) => updateData(event.target.checked)}
 | 
				
			||||||
 | 
					      />{" "}
 | 
				
			||||||
 | 
					      {t("labels.addWatermark")}
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const actionSaveScene = register({
 | 
				
			||||||
 | 
					  name: "saveScene",
 | 
				
			||||||
 | 
					  perform: async (elements, appState, value) => {
 | 
				
			||||||
    const fileHandleExists = !!appState.fileHandle;
 | 
					    const fileHandleExists = !!appState.fileHandle;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const { fileHandle } = isImageFileHandle(appState.fileHandle)
 | 
					      const { fileHandle } = await saveAsJSON(elements, appState);
 | 
				
			||||||
        ? await resaveAsImageWithScene(elements, appState, app.files)
 | 
					 | 
				
			||||||
        : await saveAsJSON(elements, appState, app.files);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        commitToHistory: false,
 | 
					        commitToHistory: false,
 | 
				
			||||||
        appState: {
 | 
					        appState: {
 | 
				
			||||||
          ...appState,
 | 
					          ...appState,
 | 
				
			||||||
          fileHandle,
 | 
					          fileHandle,
 | 
				
			||||||
          toastMessage: fileHandleExists
 | 
					          toastMessage: fileHandleExists
 | 
				
			||||||
            ? fileHandle?.name
 | 
					            ? fileHandle.name
 | 
				
			||||||
              ? t("toast.fileSavedToFilename").replace(
 | 
					              ? t("toast.fileSavedToFilename").replace(
 | 
				
			||||||
                  "{filename}",
 | 
					                  "{filename}",
 | 
				
			||||||
                  `"${fileHandle.name}"`,
 | 
					                  `"${fileHandle.name}"`,
 | 
				
			||||||
@@ -154,44 +118,39 @@ export const actionSaveToActiveFile = register({
 | 
				
			|||||||
            : null,
 | 
					            : null,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error) {
 | 
				
			||||||
      if (error?.name !== "AbortError") {
 | 
					      if (error?.name !== "AbortError") {
 | 
				
			||||||
        console.error(error);
 | 
					        console.error(error);
 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        console.warn(error);
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      return { commitToHistory: false };
 | 
					      return { commitToHistory: false };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
    event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
 | 
					    event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
 | 
				
			||||||
  PanelComponent: ({ updateData, appState }) => (
 | 
					  PanelComponent: ({ updateData }) => (
 | 
				
			||||||
    <ActiveFile
 | 
					    <ToolButton
 | 
				
			||||||
      onSave={() => updateData(null)}
 | 
					      type="button"
 | 
				
			||||||
      fileName={appState.fileHandle?.name}
 | 
					      icon={save}
 | 
				
			||||||
 | 
					      title={t("buttons.save")}
 | 
				
			||||||
 | 
					      aria-label={t("buttons.save")}
 | 
				
			||||||
 | 
					      showAriaLabel={useIsMobile()}
 | 
				
			||||||
 | 
					      onClick={() => updateData(null)}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionSaveFileToDisk = register({
 | 
					export const actionSaveAsScene = register({
 | 
				
			||||||
  name: "saveFileToDisk",
 | 
					  name: "saveAsScene",
 | 
				
			||||||
  trackEvent: { category: "export" },
 | 
					  perform: async (elements, appState, value) => {
 | 
				
			||||||
  perform: async (elements, appState, value, app) => {
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const { fileHandle } = await saveAsJSON(
 | 
					      const { fileHandle } = await saveAsJSON(elements, {
 | 
				
			||||||
        elements,
 | 
					        ...appState,
 | 
				
			||||||
        {
 | 
					        fileHandle: null,
 | 
				
			||||||
          ...appState,
 | 
					      });
 | 
				
			||||||
          fileHandle: null,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        app.files,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      return { commitToHistory: false, appState: { ...appState, fileHandle } };
 | 
					      return { commitToHistory: false, appState: { ...appState, fileHandle } };
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error) {
 | 
				
			||||||
      if (error?.name !== "AbortError") {
 | 
					      if (error?.name !== "AbortError") {
 | 
				
			||||||
        console.error(error);
 | 
					        console.error(error);
 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        console.warn(error);
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      return { commitToHistory: false };
 | 
					      return { commitToHistory: false };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -204,39 +163,35 @@ export const actionSaveFileToDisk = register({
 | 
				
			|||||||
      icon={saveAs}
 | 
					      icon={saveAs}
 | 
				
			||||||
      title={t("buttons.saveAs")}
 | 
					      title={t("buttons.saveAs")}
 | 
				
			||||||
      aria-label={t("buttons.saveAs")}
 | 
					      aria-label={t("buttons.saveAs")}
 | 
				
			||||||
      showAriaLabel={useDeviceType().isMobile}
 | 
					      showAriaLabel={useIsMobile()}
 | 
				
			||||||
      hidden={!nativeFileSystemSupported}
 | 
					      hidden={
 | 
				
			||||||
 | 
					        !("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      onClick={() => updateData(null)}
 | 
					      onClick={() => updateData(null)}
 | 
				
			||||||
      data-testid="save-as-button"
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionLoadScene = register({
 | 
					export const actionLoadScene = register({
 | 
				
			||||||
  name: "loadScene",
 | 
					  name: "loadScene",
 | 
				
			||||||
  trackEvent: { category: "export" },
 | 
					  perform: async (elements, appState) => {
 | 
				
			||||||
  perform: async (elements, appState, _, app) => {
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const {
 | 
					      const {
 | 
				
			||||||
        elements: loadedElements,
 | 
					        elements: loadedElements,
 | 
				
			||||||
        appState: loadedAppState,
 | 
					        appState: loadedAppState,
 | 
				
			||||||
        files,
 | 
					      } = await loadFromJSON(appState);
 | 
				
			||||||
      } = await loadFromJSON(appState, elements);
 | 
					 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        elements: loadedElements,
 | 
					        elements: loadedElements,
 | 
				
			||||||
        appState: loadedAppState,
 | 
					        appState: loadedAppState,
 | 
				
			||||||
        files,
 | 
					 | 
				
			||||||
        commitToHistory: true,
 | 
					        commitToHistory: true,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error) {
 | 
				
			||||||
      if (error?.name === "AbortError") {
 | 
					      if (error?.name === "AbortError") {
 | 
				
			||||||
        console.warn(error);
 | 
					 | 
				
			||||||
        return false;
 | 
					        return false;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        elements,
 | 
					        elements,
 | 
				
			||||||
        appState: { ...appState, errorMessage: error.message },
 | 
					        appState: { ...appState, errorMessage: error.message },
 | 
				
			||||||
        files: app.files,
 | 
					 | 
				
			||||||
        commitToHistory: false,
 | 
					        commitToHistory: false,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -248,16 +203,14 @@ export const actionLoadScene = register({
 | 
				
			|||||||
      icon={load}
 | 
					      icon={load}
 | 
				
			||||||
      title={t("buttons.load")}
 | 
					      title={t("buttons.load")}
 | 
				
			||||||
      aria-label={t("buttons.load")}
 | 
					      aria-label={t("buttons.load")}
 | 
				
			||||||
      showAriaLabel={useDeviceType().isMobile}
 | 
					      showAriaLabel={useIsMobile()}
 | 
				
			||||||
      onClick={updateData}
 | 
					      onClick={updateData}
 | 
				
			||||||
      data-testid="load-button"
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionExportWithDarkMode = register({
 | 
					export const actionExportWithDarkMode = register({
 | 
				
			||||||
  name: "exportWithDarkMode",
 | 
					  name: "exportWithDarkMode",
 | 
				
			||||||
  trackEvent: { category: "export", action: "toggleTheme" },
 | 
					 | 
				
			||||||
  perform: (_elements, appState, value) => {
 | 
					  perform: (_elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: { ...appState, exportWithDarkMode: value },
 | 
					      appState: { ...appState, exportWithDarkMode: value },
 | 
				
			||||||
@@ -274,9 +227,9 @@ export const actionExportWithDarkMode = register({
 | 
				
			|||||||
      }}
 | 
					      }}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <DarkModeToggle
 | 
					      <DarkModeToggle
 | 
				
			||||||
        value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
 | 
					        value={appState.exportWithDarkMode ? "dark" : "light"}
 | 
				
			||||||
        onChange={(theme: Theme) => {
 | 
					        onChange={(theme: Appearence) => {
 | 
				
			||||||
          updateData(theme === THEME.DARK);
 | 
					          updateData(theme === "dark");
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
        title={t("labels.toggleExportColorScheme")}
 | 
					        title={t("labels.toggleExportColorScheme")}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import { isInvisiblySmallElement } from "../element";
 | 
					import { isInvisiblySmallElement } from "../element";
 | 
				
			||||||
import { resetCursor } from "../utils";
 | 
					import { resetCursor } from "../utils";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { done } from "../components/icons";
 | 
					import { done } from "../components/icons";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
@@ -17,11 +18,13 @@ import { isBindingElement } from "../element/typeChecks";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionFinalize = register({
 | 
					export const actionFinalize = register({
 | 
				
			||||||
  name: "finalize",
 | 
					  name: "finalize",
 | 
				
			||||||
  trackEvent: false,
 | 
					  perform: (elements, appState, _, { canvas }) => {
 | 
				
			||||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
					 | 
				
			||||||
    if (appState.editingLinearElement) {
 | 
					    if (appState.editingLinearElement) {
 | 
				
			||||||
      const { elementId, startBindingElement, endBindingElement } =
 | 
					      const {
 | 
				
			||||||
        appState.editingLinearElement;
 | 
					        elementId,
 | 
				
			||||||
 | 
					        startBindingElement,
 | 
				
			||||||
 | 
					        endBindingElement,
 | 
				
			||||||
 | 
					      } = appState.editingLinearElement;
 | 
				
			||||||
      const element = LinearElementEditor.getElement(elementId);
 | 
					      const element = LinearElementEditor.getElement(elementId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (element) {
 | 
					      if (element) {
 | 
				
			||||||
@@ -39,7 +42,6 @@ export const actionFinalize = register({
 | 
				
			|||||||
              : undefined,
 | 
					              : undefined,
 | 
				
			||||||
          appState: {
 | 
					          appState: {
 | 
				
			||||||
            ...appState,
 | 
					            ...appState,
 | 
				
			||||||
            cursorButton: "up",
 | 
					 | 
				
			||||||
            editingLinearElement: null,
 | 
					            editingLinearElement: null,
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          commitToHistory: true,
 | 
					          commitToHistory: true,
 | 
				
			||||||
@@ -48,25 +50,20 @@ export const actionFinalize = register({
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let newElements = elements;
 | 
					    let newElements = elements;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (appState.pendingImageElement) {
 | 
					 | 
				
			||||||
      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (window.document.activeElement instanceof HTMLElement) {
 | 
					    if (window.document.activeElement instanceof HTMLElement) {
 | 
				
			||||||
      focusContainer();
 | 
					      window.document.activeElement.blur();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const multiPointElement = appState.multiElement
 | 
					    const multiPointElement = appState.multiElement
 | 
				
			||||||
      ? appState.multiElement
 | 
					      ? appState.multiElement
 | 
				
			||||||
      : appState.editingElement?.type === "freedraw"
 | 
					      : appState.editingElement?.type === "draw"
 | 
				
			||||||
      ? appState.editingElement
 | 
					      ? appState.editingElement
 | 
				
			||||||
      : null;
 | 
					      : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (multiPointElement) {
 | 
					    if (multiPointElement) {
 | 
				
			||||||
      // pen and mouse have hover
 | 
					      // pen and mouse have hover
 | 
				
			||||||
      if (
 | 
					      if (
 | 
				
			||||||
        multiPointElement.type !== "freedraw" &&
 | 
					        multiPointElement.type !== "draw" &&
 | 
				
			||||||
        appState.lastPointerDownWith !== "touch"
 | 
					        appState.lastPointerDownWith !== "touch"
 | 
				
			||||||
      ) {
 | 
					      ) {
 | 
				
			||||||
        const { points, lastCommittedPoint } = multiPointElement;
 | 
					        const { points, lastCommittedPoint } = multiPointElement;
 | 
				
			||||||
@@ -89,7 +86,7 @@ export const actionFinalize = register({
 | 
				
			|||||||
      const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
 | 
					      const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
 | 
				
			||||||
      if (
 | 
					      if (
 | 
				
			||||||
        multiPointElement.type === "line" ||
 | 
					        multiPointElement.type === "line" ||
 | 
				
			||||||
        multiPointElement.type === "freedraw"
 | 
					        multiPointElement.type === "draw"
 | 
				
			||||||
      ) {
 | 
					      ) {
 | 
				
			||||||
        if (isLoop) {
 | 
					        if (isLoop) {
 | 
				
			||||||
          const linePoints = multiPointElement.points;
 | 
					          const linePoints = multiPointElement.points;
 | 
				
			||||||
@@ -121,47 +118,25 @@ export const actionFinalize = register({
 | 
				
			|||||||
        );
 | 
					        );
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (
 | 
					      if (!appState.elementLocked && appState.elementType !== "draw") {
 | 
				
			||||||
        !appState.activeTool.locked &&
 | 
					 | 
				
			||||||
        appState.activeTool.type !== "freedraw"
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
					        appState.selectedElementIds[multiPointElement.id] = true;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (
 | 
					    if (
 | 
				
			||||||
      (!appState.activeTool.locked &&
 | 
					      (!appState.elementLocked && appState.elementType !== "draw") ||
 | 
				
			||||||
        appState.activeTool.type !== "freedraw") ||
 | 
					 | 
				
			||||||
      !multiPointElement
 | 
					      !multiPointElement
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
      resetCursor(canvas);
 | 
					      resetCursor(canvas);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const activeTool: any = { ...appState.activeTool };
 | 
					 | 
				
			||||||
    if (appState.activeTool.lastActiveToolBeforeEraser) {
 | 
					 | 
				
			||||||
      if (
 | 
					 | 
				
			||||||
        typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
 | 
					 | 
				
			||||||
        appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
 | 
					 | 
				
			||||||
        activeTool.customType =
 | 
					 | 
				
			||||||
          appState.activeTool.lastActiveToolBeforeEraser.customType;
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      activeTool.type = "selection";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: newElements,
 | 
					      elements: newElements,
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        cursorButton: "up",
 | 
					        elementType:
 | 
				
			||||||
        activeTool:
 | 
					          (appState.elementLocked || appState.elementType === "draw") &&
 | 
				
			||||||
          (appState.activeTool.locked ||
 | 
					 | 
				
			||||||
            appState.activeTool.type === "freedraw") &&
 | 
					 | 
				
			||||||
          multiPointElement
 | 
					          multiPointElement
 | 
				
			||||||
            ? appState.activeTool
 | 
					            ? appState.elementType
 | 
				
			||||||
            : activeTool,
 | 
					            : "selection",
 | 
				
			||||||
        draggingElement: null,
 | 
					        draggingElement: null,
 | 
				
			||||||
        multiElement: null,
 | 
					        multiElement: null,
 | 
				
			||||||
        editingElement: null,
 | 
					        editingElement: null,
 | 
				
			||||||
@@ -169,16 +144,15 @@ export const actionFinalize = register({
 | 
				
			|||||||
        suggestedBindings: [],
 | 
					        suggestedBindings: [],
 | 
				
			||||||
        selectedElementIds:
 | 
					        selectedElementIds:
 | 
				
			||||||
          multiPointElement &&
 | 
					          multiPointElement &&
 | 
				
			||||||
          !appState.activeTool.locked &&
 | 
					          !appState.elementLocked &&
 | 
				
			||||||
          appState.activeTool.type !== "freedraw"
 | 
					          appState.elementType !== "draw"
 | 
				
			||||||
            ? {
 | 
					            ? {
 | 
				
			||||||
                ...appState.selectedElementIds,
 | 
					                ...appState.selectedElementIds,
 | 
				
			||||||
                [multiPointElement.id]: true,
 | 
					                [multiPointElement.id]: true,
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            : appState.selectedElementIds,
 | 
					            : appState.selectedElementIds,
 | 
				
			||||||
        pendingImageElement: null,
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: appState.activeTool.type === "freedraw",
 | 
					      commitToHistory: appState.elementType === "draw",
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  keyTest: (event, appState) =>
 | 
					  keyTest: (event, appState) =>
 | 
				
			||||||
@@ -187,7 +161,7 @@ export const actionFinalize = register({
 | 
				
			|||||||
        (!appState.draggingElement && appState.multiElement === null))) ||
 | 
					        (!appState.draggingElement && appState.multiElement === null))) ||
 | 
				
			||||||
    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
 | 
					    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
 | 
				
			||||||
      appState.multiElement !== null),
 | 
					      appState.multiElement !== null),
 | 
				
			||||||
  PanelComponent: ({ appState, updateData, data }) => (
 | 
					  PanelComponent: ({ appState, updateData }) => (
 | 
				
			||||||
    <ToolButton
 | 
					    <ToolButton
 | 
				
			||||||
      type="button"
 | 
					      type="button"
 | 
				
			||||||
      icon={done}
 | 
					      icon={done}
 | 
				
			||||||
@@ -195,7 +169,6 @@ export const actionFinalize = register({
 | 
				
			|||||||
      aria-label={t("buttons.done")}
 | 
					      aria-label={t("buttons.done")}
 | 
				
			||||||
      onClick={updateData}
 | 
					      onClick={updateData}
 | 
				
			||||||
      visible={appState.multiElement != null}
 | 
					      visible={appState.multiElement != null}
 | 
				
			||||||
      size={data?.size || "medium"}
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,211 +0,0 @@
 | 
				
			|||||||
import { register } from "./register";
 | 
					 | 
				
			||||||
import { getSelectedElements } from "../scene";
 | 
					 | 
				
			||||||
import { getNonDeletedElements } from "../element";
 | 
					 | 
				
			||||||
import { mutateElement } from "../element/mutateElement";
 | 
					 | 
				
			||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
					 | 
				
			||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
 | 
					 | 
				
			||||||
import { AppState } from "../types";
 | 
					 | 
				
			||||||
import { getTransformHandles } from "../element/transformHandles";
 | 
					 | 
				
			||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
 | 
					 | 
				
			||||||
import { updateBoundElements } from "../element/binding";
 | 
					 | 
				
			||||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
					 | 
				
			||||||
import { arrayToMap } from "../utils";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const enableActionFlipHorizontal = (
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
  appState: AppState,
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  const eligibleElements = getSelectedElements(
 | 
					 | 
				
			||||||
    getNonDeletedElements(elements),
 | 
					 | 
				
			||||||
    appState,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const enableActionFlipVertical = (
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
  appState: AppState,
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  const eligibleElements = getSelectedElements(
 | 
					 | 
				
			||||||
    getNonDeletedElements(elements),
 | 
					 | 
				
			||||||
    appState,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  return eligibleElements.length === 1;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionFlipHorizontal = register({
 | 
					 | 
				
			||||||
  name: "flipHorizontal",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      elements: flipSelectedElements(elements, appState, "horizontal"),
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  keyTest: (event) => event.shiftKey && event.code === "KeyH",
 | 
					 | 
				
			||||||
  contextItemLabel: "labels.flipHorizontal",
 | 
					 | 
				
			||||||
  contextItemPredicate: (elements, appState) =>
 | 
					 | 
				
			||||||
    enableActionFlipHorizontal(elements, appState),
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionFlipVertical = register({
 | 
					 | 
				
			||||||
  name: "flipVertical",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      elements: flipSelectedElements(elements, appState, "vertical"),
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  keyTest: (event) => event.shiftKey && event.code === "KeyV",
 | 
					 | 
				
			||||||
  contextItemLabel: "labels.flipVertical",
 | 
					 | 
				
			||||||
  contextItemPredicate: (elements, appState) =>
 | 
					 | 
				
			||||||
    enableActionFlipVertical(elements, appState),
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const flipSelectedElements = (
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
  appState: Readonly<AppState>,
 | 
					 | 
				
			||||||
  flipDirection: "horizontal" | "vertical",
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  const selectedElements = getSelectedElements(
 | 
					 | 
				
			||||||
    getNonDeletedElements(elements),
 | 
					 | 
				
			||||||
    appState,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // remove once we allow for groups of elements to be flipped
 | 
					 | 
				
			||||||
  if (selectedElements.length > 1) {
 | 
					 | 
				
			||||||
    return elements;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const updatedElements = flipElements(
 | 
					 | 
				
			||||||
    selectedElements,
 | 
					 | 
				
			||||||
    appState,
 | 
					 | 
				
			||||||
    flipDirection,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return elements.map(
 | 
					 | 
				
			||||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const flipElements = (
 | 
					 | 
				
			||||||
  elements: NonDeleted<ExcalidrawElement>[],
 | 
					 | 
				
			||||||
  appState: AppState,
 | 
					 | 
				
			||||||
  flipDirection: "horizontal" | "vertical",
 | 
					 | 
				
			||||||
): ExcalidrawElement[] => {
 | 
					 | 
				
			||||||
  elements.forEach((element) => {
 | 
					 | 
				
			||||||
    flipElement(element, appState);
 | 
					 | 
				
			||||||
    // If vertical flip, rotate an extra 180
 | 
					 | 
				
			||||||
    if (flipDirection === "vertical") {
 | 
					 | 
				
			||||||
      rotateElement(element, Math.PI);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  return elements;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const flipElement = (
 | 
					 | 
				
			||||||
  element: NonDeleted<ExcalidrawElement>,
 | 
					 | 
				
			||||||
  appState: AppState,
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  const originalX = element.x;
 | 
					 | 
				
			||||||
  const originalY = element.y;
 | 
					 | 
				
			||||||
  const width = element.width;
 | 
					 | 
				
			||||||
  const height = element.height;
 | 
					 | 
				
			||||||
  const originalAngle = normalizeAngle(element.angle);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let finalOffsetX = 0;
 | 
					 | 
				
			||||||
  if (isLinearElement(element) || isFreeDrawElement(element)) {
 | 
					 | 
				
			||||||
    finalOffsetX =
 | 
					 | 
				
			||||||
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
 | 
					 | 
				
			||||||
      element.width;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Rotate back to zero, if necessary
 | 
					 | 
				
			||||||
  mutateElement(element, {
 | 
					 | 
				
			||||||
    angle: normalizeAngle(0),
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  // Flip unrotated by pulling TransformHandle to opposite side
 | 
					 | 
				
			||||||
  const transformHandles = getTransformHandles(element, appState.zoom);
 | 
					 | 
				
			||||||
  let usingNWHandle = true;
 | 
					 | 
				
			||||||
  let newNCoordsX = 0;
 | 
					 | 
				
			||||||
  let nHandle = transformHandles.nw;
 | 
					 | 
				
			||||||
  if (!nHandle) {
 | 
					 | 
				
			||||||
    // Use ne handle instead
 | 
					 | 
				
			||||||
    usingNWHandle = false;
 | 
					 | 
				
			||||||
    nHandle = transformHandles.ne;
 | 
					 | 
				
			||||||
    if (!nHandle) {
 | 
					 | 
				
			||||||
      mutateElement(element, {
 | 
					 | 
				
			||||||
        angle: originalAngle,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (isLinearElement(element)) {
 | 
					 | 
				
			||||||
    for (let index = 1; index < element.points.length; index++) {
 | 
					 | 
				
			||||||
      LinearElementEditor.movePoints(element, [
 | 
					 | 
				
			||||||
        { index, point: [-element.points[index][0], element.points[index][1]] },
 | 
					 | 
				
			||||||
      ]);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    LinearElementEditor.normalizePoints(element);
 | 
					 | 
				
			||||||
  } else {
 | 
					 | 
				
			||||||
    // calculate new x-coord for transformation
 | 
					 | 
				
			||||||
    newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
 | 
					 | 
				
			||||||
    resizeSingleElement(
 | 
					 | 
				
			||||||
      new Map().set(element.id, element),
 | 
					 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
      element,
 | 
					 | 
				
			||||||
      usingNWHandle ? "nw" : "ne",
 | 
					 | 
				
			||||||
      false,
 | 
					 | 
				
			||||||
      newNCoordsX,
 | 
					 | 
				
			||||||
      nHandle[1],
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    // fix the size to account for handle sizes
 | 
					 | 
				
			||||||
    mutateElement(element, {
 | 
					 | 
				
			||||||
      width,
 | 
					 | 
				
			||||||
      height,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Rotate by (360 degrees - original angle)
 | 
					 | 
				
			||||||
  let angle = normalizeAngle(2 * Math.PI - originalAngle);
 | 
					 | 
				
			||||||
  if (angle < 0) {
 | 
					 | 
				
			||||||
    // check, probably unnecessary
 | 
					 | 
				
			||||||
    angle = normalizeAngle(angle + 2 * Math.PI);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  mutateElement(element, {
 | 
					 | 
				
			||||||
    angle,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Move back to original spot to appear "flipped in place"
 | 
					 | 
				
			||||||
  mutateElement(element, {
 | 
					 | 
				
			||||||
    x: originalX + finalOffsetX,
 | 
					 | 
				
			||||||
    y: originalY,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  updateBoundElements(element);
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
 | 
					 | 
				
			||||||
  const originalX = element.x;
 | 
					 | 
				
			||||||
  const originalY = element.y;
 | 
					 | 
				
			||||||
  let angle = normalizeAngle(element.angle + rotationAngle);
 | 
					 | 
				
			||||||
  if (angle < 0) {
 | 
					 | 
				
			||||||
    // check, probably unnecessary
 | 
					 | 
				
			||||||
    angle = normalizeAngle(2 * Math.PI + angle);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  mutateElement(element, {
 | 
					 | 
				
			||||||
    angle,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Move back to original spot
 | 
					 | 
				
			||||||
  mutateElement(element, {
 | 
					 | 
				
			||||||
    x: originalX,
 | 
					 | 
				
			||||||
    y: originalY,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { CODES, KEYS } from "../keys";
 | 
					import { CODES, KEYS } from "../keys";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
					import { getShortcutKey } from "../utils";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
 | 
					import { UngroupIcon, GroupIcon } from "../components/icons";
 | 
				
			||||||
import { newElementWith } from "../element/mutateElement";
 | 
					import { newElementWith } from "../element/mutateElement";
 | 
				
			||||||
@@ -17,9 +18,8 @@ import {
 | 
				
			|||||||
import { getNonDeletedElements } from "../element";
 | 
					import { getNonDeletedElements } from "../element";
 | 
				
			||||||
import { randomId } from "../random";
 | 
					import { randomId } from "../random";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 | 
					const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 | 
				
			||||||
  if (elements.length >= 2) {
 | 
					  if (elements.length >= 2) {
 | 
				
			||||||
@@ -45,7 +45,6 @@ const enableActionGroup = (
 | 
				
			|||||||
  const selectedElements = getSelectedElements(
 | 
					  const selectedElements = getSelectedElements(
 | 
				
			||||||
    getNonDeletedElements(elements),
 | 
					    getNonDeletedElements(elements),
 | 
				
			||||||
    appState,
 | 
					    appState,
 | 
				
			||||||
    true,
 | 
					 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
 | 
					    selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
 | 
				
			||||||
@@ -54,12 +53,10 @@ const enableActionGroup = (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionGroup = register({
 | 
					export const actionGroup = register({
 | 
				
			||||||
  name: "group",
 | 
					  name: "group",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    const selectedElements = getSelectedElements(
 | 
					    const selectedElements = getSelectedElements(
 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					      getNonDeletedElements(elements),
 | 
				
			||||||
      appState,
 | 
					      appState,
 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    if (selectedElements.length < 2) {
 | 
					    if (selectedElements.length < 2) {
 | 
				
			||||||
      // nothing to group
 | 
					      // nothing to group
 | 
				
			||||||
@@ -87,9 +84,8 @@ export const actionGroup = register({
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const newGroupId = randomId();
 | 
					    const newGroupId = randomId();
 | 
				
			||||||
    const selectElementIds = arrayToMap(selectedElements);
 | 
					 | 
				
			||||||
    const updatedElements = elements.map((element) => {
 | 
					    const updatedElements = elements.map((element) => {
 | 
				
			||||||
      if (!selectElementIds.get(element.id)) {
 | 
					      if (!appState.selectedElementIds[element.id]) {
 | 
				
			||||||
        return element;
 | 
					        return element;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      return newElementWith(element, {
 | 
					      return newElementWith(element, {
 | 
				
			||||||
@@ -104,8 +100,9 @@ export const actionGroup = register({
 | 
				
			|||||||
    // to the z order of the highest element in the layer stack
 | 
					    // to the z order of the highest element in the layer stack
 | 
				
			||||||
    const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
 | 
					    const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
 | 
				
			||||||
    const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
 | 
					    const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
 | 
				
			||||||
    const lastGroupElementIndex =
 | 
					    const lastGroupElementIndex = updatedElements.lastIndexOf(
 | 
				
			||||||
      updatedElements.lastIndexOf(lastElementInGroup);
 | 
					      lastElementInGroup,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
 | 
					    const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
 | 
				
			||||||
    const elementsBeforeGroup = updatedElements
 | 
					    const elementsBeforeGroup = updatedElements
 | 
				
			||||||
      .slice(0, lastGroupElementIndex)
 | 
					      .slice(0, lastGroupElementIndex)
 | 
				
			||||||
@@ -148,18 +145,12 @@ export const actionGroup = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionUngroup = register({
 | 
					export const actionUngroup = register({
 | 
				
			||||||
  name: "ungroup",
 | 
					  name: "ungroup",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    const groupIds = getSelectedGroupIds(appState);
 | 
					    const groupIds = getSelectedGroupIds(appState);
 | 
				
			||||||
    if (groupIds.length === 0) {
 | 
					    if (groupIds.length === 0) {
 | 
				
			||||||
      return { appState, elements, commitToHistory: false };
 | 
					      return { appState, elements, commitToHistory: false };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
 | 
					 | 
				
			||||||
    const nextElements = elements.map((element) => {
 | 
					    const nextElements = elements.map((element) => {
 | 
				
			||||||
      if (isBoundToContainer(element)) {
 | 
					 | 
				
			||||||
        boundTextElementIds.push(element.id);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const nextGroupIds = removeFromSelectedGroups(
 | 
					      const nextGroupIds = removeFromSelectedGroups(
 | 
				
			||||||
        element.groupIds,
 | 
					        element.groupIds,
 | 
				
			||||||
        appState.selectedGroupIds,
 | 
					        appState.selectedGroupIds,
 | 
				
			||||||
@@ -171,19 +162,11 @@ export const actionUngroup = register({
 | 
				
			|||||||
        groupIds: nextGroupIds,
 | 
					        groupIds: nextGroupIds,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const updateAppState = selectGroupsForSelectedElements(
 | 
					 | 
				
			||||||
      { ...appState, selectedGroupIds: {} },
 | 
					 | 
				
			||||||
      getNonDeletedElements(nextElements),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // remove binded text elements from selection
 | 
					 | 
				
			||||||
    boundTextElementIds.forEach(
 | 
					 | 
				
			||||||
      (id) => (updateAppState.selectedElementIds[id] = false),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: updateAppState,
 | 
					      appState: selectGroupsForSelectedElements(
 | 
				
			||||||
 | 
					        { ...appState, selectedGroupIds: {} },
 | 
				
			||||||
 | 
					        getNonDeletedElements(nextElements),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
      elements: nextElements,
 | 
					      elements: nextElements,
 | 
				
			||||||
      commitToHistory: true,
 | 
					      commitToHistory: true,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,15 @@
 | 
				
			|||||||
import { Action, ActionResult } from "./types";
 | 
					import { Action, ActionResult } from "./types";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
import { undo, redo } from "../components/icons";
 | 
					import { undo, redo } from "../components/icons";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import History, { HistoryEntry } from "../history";
 | 
					import { SceneHistory, HistoryEntry } from "../history";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
import { isWindows, KEYS } from "../keys";
 | 
					import { isWindows, KEYS } from "../keys";
 | 
				
			||||||
 | 
					import { getElementMap } from "../element";
 | 
				
			||||||
import { newElementWith } from "../element/mutateElement";
 | 
					import { newElementWith } from "../element/mutateElement";
 | 
				
			||||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
					import { fixBindingsAfterDeletion } from "../element/binding";
 | 
				
			||||||
import { arrayToMap } from "../utils";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const writeData = (
 | 
					const writeData = (
 | 
				
			||||||
  prevElements: readonly ExcalidrawElement[],
 | 
					  prevElements: readonly ExcalidrawElement[],
 | 
				
			||||||
@@ -27,17 +28,17 @@ const writeData = (
 | 
				
			|||||||
      return { commitToHistory };
 | 
					      return { commitToHistory };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const prevElementMap = arrayToMap(prevElements);
 | 
					    const prevElementMap = getElementMap(prevElements);
 | 
				
			||||||
    const nextElements = data.elements;
 | 
					    const nextElements = data.elements;
 | 
				
			||||||
    const nextElementMap = arrayToMap(nextElements);
 | 
					    const nextElementMap = getElementMap(nextElements);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const deletedElements = prevElements.filter(
 | 
					    const deletedElements = prevElements.filter(
 | 
				
			||||||
      (prevElement) => !nextElementMap.has(prevElement.id),
 | 
					      (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    const elements = nextElements
 | 
					    const elements = nextElements
 | 
				
			||||||
      .map((nextElement) =>
 | 
					      .map((nextElement) =>
 | 
				
			||||||
        newElementWith(
 | 
					        newElementWith(
 | 
				
			||||||
          prevElementMap.get(nextElement.id) || nextElement,
 | 
					          prevElementMap[nextElement.id] || nextElement,
 | 
				
			||||||
          nextElement,
 | 
					          nextElement,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
@@ -58,24 +59,22 @@ const writeData = (
 | 
				
			|||||||
  return { commitToHistory };
 | 
					  return { commitToHistory };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ActionCreator = (history: History) => Action;
 | 
					type ActionCreator = (history: SceneHistory) => Action;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
					export const createUndoAction: ActionCreator = (history) => ({
 | 
				
			||||||
  name: "undo",
 | 
					  name: "undo",
 | 
				
			||||||
  trackEvent: { category: "history" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) =>
 | 
					  perform: (elements, appState) =>
 | 
				
			||||||
    writeData(elements, appState, () => history.undoOnce()),
 | 
					    writeData(elements, appState, () => history.undoOnce()),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
    event[KEYS.CTRL_OR_CMD] &&
 | 
					    event[KEYS.CTRL_OR_CMD] &&
 | 
				
			||||||
    event.key.toLowerCase() === KEYS.Z &&
 | 
					    event.key.toLowerCase() === KEYS.Z &&
 | 
				
			||||||
    !event.shiftKey,
 | 
					    !event.shiftKey,
 | 
				
			||||||
  PanelComponent: ({ updateData, data }) => (
 | 
					  PanelComponent: ({ updateData }) => (
 | 
				
			||||||
    <ToolButton
 | 
					    <ToolButton
 | 
				
			||||||
      type="button"
 | 
					      type="button"
 | 
				
			||||||
      icon={undo}
 | 
					      icon={undo}
 | 
				
			||||||
      aria-label={t("buttons.undo")}
 | 
					      aria-label={t("buttons.undo")}
 | 
				
			||||||
      onClick={updateData}
 | 
					      onClick={updateData}
 | 
				
			||||||
      size={data?.size || "medium"}
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  commitToHistory: () => false,
 | 
					  commitToHistory: () => false,
 | 
				
			||||||
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const createRedoAction: ActionCreator = (history) => ({
 | 
					export const createRedoAction: ActionCreator = (history) => ({
 | 
				
			||||||
  name: "redo",
 | 
					  name: "redo",
 | 
				
			||||||
  trackEvent: { category: "history" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) =>
 | 
					  perform: (elements, appState) =>
 | 
				
			||||||
    writeData(elements, appState, () => history.redoOnce()),
 | 
					    writeData(elements, appState, () => history.redoOnce()),
 | 
				
			||||||
  keyTest: (event) =>
 | 
					  keyTest: (event) =>
 | 
				
			||||||
@@ -91,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
 | 
				
			|||||||
      event.shiftKey &&
 | 
					      event.shiftKey &&
 | 
				
			||||||
      event.key.toLowerCase() === KEYS.Z) ||
 | 
					      event.key.toLowerCase() === KEYS.Z) ||
 | 
				
			||||||
    (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
 | 
					    (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
 | 
				
			||||||
  PanelComponent: ({ updateData, data }) => (
 | 
					  PanelComponent: ({ updateData }) => (
 | 
				
			||||||
    <ToolButton
 | 
					    <ToolButton
 | 
				
			||||||
      type="button"
 | 
					      type="button"
 | 
				
			||||||
      icon={redo}
 | 
					      icon={redo}
 | 
				
			||||||
      aria-label={t("buttons.redo")}
 | 
					      aria-label={t("buttons.redo")}
 | 
				
			||||||
      onClick={updateData}
 | 
					      onClick={updateData}
 | 
				
			||||||
      size={data?.size || "medium"}
 | 
					 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  commitToHistory: () => false,
 | 
					  commitToHistory: () => false,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { menu, palette } from "../components/icons";
 | 
					import { menu, palette } from "../components/icons";
 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					import { ToolButton } from "../components/ToolButton";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
@@ -9,7 +10,6 @@ import { HelpIcon } from "../components/HelpIcon";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionToggleCanvasMenu = register({
 | 
					export const actionToggleCanvasMenu = register({
 | 
				
			||||||
  name: "toggleCanvasMenu",
 | 
					  name: "toggleCanvasMenu",
 | 
				
			||||||
  trackEvent: { category: "menu" },
 | 
					 | 
				
			||||||
  perform: (_, appState) => ({
 | 
					  perform: (_, appState) => ({
 | 
				
			||||||
    appState: {
 | 
					    appState: {
 | 
				
			||||||
      ...appState,
 | 
					      ...appState,
 | 
				
			||||||
@@ -30,7 +30,6 @@ export const actionToggleCanvasMenu = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionToggleEditMenu = register({
 | 
					export const actionToggleEditMenu = register({
 | 
				
			||||||
  name: "toggleEditMenu",
 | 
					  name: "toggleEditMenu",
 | 
				
			||||||
  trackEvent: { category: "menu" },
 | 
					 | 
				
			||||||
  perform: (_elements, appState) => ({
 | 
					  perform: (_elements, appState) => ({
 | 
				
			||||||
    appState: {
 | 
					    appState: {
 | 
				
			||||||
      ...appState,
 | 
					      ...appState,
 | 
				
			||||||
@@ -55,7 +54,6 @@ export const actionToggleEditMenu = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionFullScreen = register({
 | 
					export const actionFullScreen = register({
 | 
				
			||||||
  name: "toggleFullScreen",
 | 
					  name: "toggleFullScreen",
 | 
				
			||||||
  trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
 | 
					 | 
				
			||||||
  perform: () => {
 | 
					  perform: () => {
 | 
				
			||||||
    if (!isFullScreen()) {
 | 
					    if (!isFullScreen()) {
 | 
				
			||||||
      allowFullScreen();
 | 
					      allowFullScreen();
 | 
				
			||||||
@@ -72,11 +70,7 @@ export const actionFullScreen = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionShortcuts = register({
 | 
					export const actionShortcuts = register({
 | 
				
			||||||
  name: "toggleShortcuts",
 | 
					  name: "toggleShortcuts",
 | 
				
			||||||
  trackEvent: { category: "menu", action: "toggleHelpDialog" },
 | 
					  perform: (_elements, appState) => {
 | 
				
			||||||
  perform: (_elements, appState, _, { focusContainer }) => {
 | 
					 | 
				
			||||||
    if (appState.showHelpDialog) {
 | 
					 | 
				
			||||||
      focusContainer();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
import { getClientColors } from "../clients";
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { getClientColors, getClientInitials } from "../clients";
 | 
				
			||||||
import { Avatar } from "../components/Avatar";
 | 
					import { Avatar } from "../components/Avatar";
 | 
				
			||||||
import { centerScrollOn } from "../scene/scroll";
 | 
					import { centerScrollOn } from "../scene/scroll";
 | 
				
			||||||
import { Collaborator } from "../types";
 | 
					import { Collaborator } from "../types";
 | 
				
			||||||
@@ -6,7 +7,6 @@ import { register } from "./register";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionGoToCollaborator = register({
 | 
					export const actionGoToCollaborator = register({
 | 
				
			||||||
  name: "goToCollaborator",
 | 
					  name: "goToCollaborator",
 | 
				
			||||||
  trackEvent: { category: "collab" },
 | 
					 | 
				
			||||||
  perform: (_elements, appState, value) => {
 | 
					  perform: (_elements, appState, value) => {
 | 
				
			||||||
    const point = value as Collaborator["pointer"];
 | 
					    const point = value as Collaborator["pointer"];
 | 
				
			||||||
    if (!point) {
 | 
					    if (!point) {
 | 
				
			||||||
@@ -30,8 +30,8 @@ export const actionGoToCollaborator = register({
 | 
				
			|||||||
      commitToHistory: false,
 | 
					      commitToHistory: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ appState, updateData, data }) => {
 | 
					  PanelComponent: ({ appState, updateData, id }) => {
 | 
				
			||||||
    const clientId: string | undefined = data?.id;
 | 
					    const clientId = id;
 | 
				
			||||||
    if (!clientId) {
 | 
					    if (!clientId) {
 | 
				
			||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -43,15 +43,16 @@ export const actionGoToCollaborator = register({
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { background, stroke } = getClientColors(clientId, appState);
 | 
					    const { background, stroke } = getClientColors(clientId, appState);
 | 
				
			||||||
 | 
					    const shortName = getClientInitials(collaborator.username);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Avatar
 | 
					      <Avatar
 | 
				
			||||||
        color={background}
 | 
					        color={background}
 | 
				
			||||||
        border={stroke}
 | 
					        border={stroke}
 | 
				
			||||||
        onClick={() => updateData(collaborator.pointer)}
 | 
					        onClick={() => updateData(collaborator.pointer)}
 | 
				
			||||||
        name={collaborator.username || ""}
 | 
					      >
 | 
				
			||||||
        src={collaborator.src}
 | 
					        {shortName}
 | 
				
			||||||
      />
 | 
					      </Avatar>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,25 +1,19 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { AppState } from "../../src/types";
 | 
					import { AppState } from "../../src/types";
 | 
				
			||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
					import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
				
			||||||
 | 
					import { ButtonSelect } from "../components/ButtonSelect";
 | 
				
			||||||
import { ColorPicker } from "../components/ColorPicker";
 | 
					import { ColorPicker } from "../components/ColorPicker";
 | 
				
			||||||
import { IconPicker } from "../components/IconPicker";
 | 
					import { IconPicker } from "../components/IconPicker";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ArrowheadArrowIcon,
 | 
					  ArrowheadArrowIcon,
 | 
				
			||||||
  ArrowheadBarIcon,
 | 
					  ArrowheadBarIcon,
 | 
				
			||||||
  ArrowheadDotIcon,
 | 
					  ArrowheadDotIcon,
 | 
				
			||||||
  ArrowheadTriangleIcon,
 | 
					 | 
				
			||||||
  ArrowheadNoneIcon,
 | 
					  ArrowheadNoneIcon,
 | 
				
			||||||
  EdgeRoundIcon,
 | 
					  EdgeRoundIcon,
 | 
				
			||||||
  EdgeSharpIcon,
 | 
					  EdgeSharpIcon,
 | 
				
			||||||
  FillCrossHatchIcon,
 | 
					  FillCrossHatchIcon,
 | 
				
			||||||
  FillHachureIcon,
 | 
					  FillHachureIcon,
 | 
				
			||||||
  FillSolidIcon,
 | 
					  FillSolidIcon,
 | 
				
			||||||
  FontFamilyCodeIcon,
 | 
					 | 
				
			||||||
  FontFamilyHandDrawnIcon,
 | 
					 | 
				
			||||||
  FontFamilyNormalIcon,
 | 
					 | 
				
			||||||
  FontSizeExtraLargeIcon,
 | 
					 | 
				
			||||||
  FontSizeLargeIcon,
 | 
					 | 
				
			||||||
  FontSizeMediumIcon,
 | 
					 | 
				
			||||||
  FontSizeSmallIcon,
 | 
					 | 
				
			||||||
  SloppinessArchitectIcon,
 | 
					  SloppinessArchitectIcon,
 | 
				
			||||||
  SloppinessArtistIcon,
 | 
					  SloppinessArtistIcon,
 | 
				
			||||||
  SloppinessCartoonistIcon,
 | 
					  SloppinessCartoonistIcon,
 | 
				
			||||||
@@ -27,72 +21,42 @@ import {
 | 
				
			|||||||
  StrokeStyleDottedIcon,
 | 
					  StrokeStyleDottedIcon,
 | 
				
			||||||
  StrokeStyleSolidIcon,
 | 
					  StrokeStyleSolidIcon,
 | 
				
			||||||
  StrokeWidthIcon,
 | 
					  StrokeWidthIcon,
 | 
				
			||||||
  TextAlignCenterIcon,
 | 
					 | 
				
			||||||
  TextAlignLeftIcon,
 | 
					 | 
				
			||||||
  TextAlignRightIcon,
 | 
					 | 
				
			||||||
  TextAlignTopIcon,
 | 
					 | 
				
			||||||
  TextAlignBottomIcon,
 | 
					 | 
				
			||||||
  TextAlignMiddleIcon,
 | 
					 | 
				
			||||||
} from "../components/icons";
 | 
					} from "../components/icons";
 | 
				
			||||||
import {
 | 
					import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
 | 
				
			||||||
  DEFAULT_FONT_FAMILY,
 | 
					 | 
				
			||||||
  DEFAULT_FONT_SIZE,
 | 
					 | 
				
			||||||
  FONT_FAMILY,
 | 
					 | 
				
			||||||
  VERTICAL_ALIGN,
 | 
					 | 
				
			||||||
} from "../constants";
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  getNonDeletedElements,
 | 
					  getNonDeletedElements,
 | 
				
			||||||
  isTextElement,
 | 
					  isTextElement,
 | 
				
			||||||
  redrawTextBoundingBox,
 | 
					  redrawTextBoundingBox,
 | 
				
			||||||
} from "../element";
 | 
					} from "../element";
 | 
				
			||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
					import { newElementWith } from "../element/mutateElement";
 | 
				
			||||||
import {
 | 
					import { isLinearElement, isLinearElementType } from "../element/typeChecks";
 | 
				
			||||||
  getBoundTextElement,
 | 
					 | 
				
			||||||
  getContainerElement,
 | 
					 | 
				
			||||||
} from "../element/textElement";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  isBoundToContainer,
 | 
					 | 
				
			||||||
  isLinearElement,
 | 
					 | 
				
			||||||
  isLinearElementType,
 | 
					 | 
				
			||||||
} from "../element/typeChecks";
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Arrowhead,
 | 
					  Arrowhead,
 | 
				
			||||||
  ExcalidrawElement,
 | 
					  ExcalidrawElement,
 | 
				
			||||||
  ExcalidrawLinearElement,
 | 
					  ExcalidrawLinearElement,
 | 
				
			||||||
  ExcalidrawTextElement,
 | 
					  ExcalidrawTextElement,
 | 
				
			||||||
  FontFamilyValues,
 | 
					  FontFamily,
 | 
				
			||||||
  TextAlign,
 | 
					  TextAlign,
 | 
				
			||||||
  VerticalAlign,
 | 
					 | 
				
			||||||
} from "../element/types";
 | 
					} from "../element/types";
 | 
				
			||||||
import { getLanguage, t } from "../i18n";
 | 
					import { getLanguage, t } from "../i18n";
 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					 | 
				
			||||||
import { randomInteger } from "../random";
 | 
					import { randomInteger } from "../random";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  canChangeSharpness,
 | 
					  canChangeSharpness,
 | 
				
			||||||
  canHaveArrowheads,
 | 
					  canHaveArrowheads,
 | 
				
			||||||
  getCommonAttributeOfSelectedElements,
 | 
					  getCommonAttributeOfSelectedElements,
 | 
				
			||||||
  getSelectedElements,
 | 
					 | 
				
			||||||
  getTargetElements,
 | 
					  getTargetElements,
 | 
				
			||||||
  isSomeElementSelected,
 | 
					  isSomeElementSelected,
 | 
				
			||||||
} from "../scene";
 | 
					} from "../scene";
 | 
				
			||||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
					 | 
				
			||||||
import { arrayToMap } from "../utils";
 | 
					 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const changeProperty = (
 | 
					const changeProperty = (
 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					  elements: readonly ExcalidrawElement[],
 | 
				
			||||||
  appState: AppState,
 | 
					  appState: AppState,
 | 
				
			||||||
  callback: (element: ExcalidrawElement) => ExcalidrawElement,
 | 
					  callback: (element: ExcalidrawElement) => ExcalidrawElement,
 | 
				
			||||||
  includeBoundText = false,
 | 
					 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  const selectedElementIds = arrayToMap(
 | 
					 | 
				
			||||||
    getSelectedElements(elements, appState, includeBoundText),
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  return elements.map((element) => {
 | 
					  return elements.map((element) => {
 | 
				
			||||||
    if (
 | 
					    if (
 | 
				
			||||||
      selectedElementIds.get(element.id) ||
 | 
					      appState.selectedElementIds[element.id] ||
 | 
				
			||||||
      element.id === appState.editingElement?.id
 | 
					      element.id === appState.editingElement?.id
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
      return callback(element);
 | 
					      return callback(element);
 | 
				
			||||||
@@ -122,100 +86,17 @@ const getFormValue = function <T>(
 | 
				
			|||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const offsetElementAfterFontResize = (
 | 
					 | 
				
			||||||
  prevElement: ExcalidrawTextElement,
 | 
					 | 
				
			||||||
  nextElement: ExcalidrawTextElement,
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  if (isBoundToContainer(nextElement)) {
 | 
					 | 
				
			||||||
    return nextElement;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return mutateElement(
 | 
					 | 
				
			||||||
    nextElement,
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      x:
 | 
					 | 
				
			||||||
        prevElement.textAlign === "left"
 | 
					 | 
				
			||||||
          ? prevElement.x
 | 
					 | 
				
			||||||
          : prevElement.x +
 | 
					 | 
				
			||||||
            (prevElement.width - nextElement.width) /
 | 
					 | 
				
			||||||
              (prevElement.textAlign === "center" ? 2 : 1),
 | 
					 | 
				
			||||||
      // centering vertically is non-standard, but for Excalidraw I think
 | 
					 | 
				
			||||||
      // it makes sense
 | 
					 | 
				
			||||||
      y: prevElement.y + (prevElement.height - nextElement.height) / 2,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    false,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const changeFontSize = (
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
  appState: AppState,
 | 
					 | 
				
			||||||
  getNewFontSize: (element: ExcalidrawTextElement) => number,
 | 
					 | 
				
			||||||
  fallbackValue?: ExcalidrawTextElement["fontSize"],
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  const newFontSizes = new Set<number>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    elements: changeProperty(
 | 
					 | 
				
			||||||
      elements,
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
      (oldElement) => {
 | 
					 | 
				
			||||||
        if (isTextElement(oldElement)) {
 | 
					 | 
				
			||||||
          const newFontSize = getNewFontSize(oldElement);
 | 
					 | 
				
			||||||
          newFontSizes.add(newFontSize);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
 | 
					 | 
				
			||||||
            fontSize: newFontSize,
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
          redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          newElement = offsetElementAfterFontResize(oldElement, newElement);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          return newElement;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return oldElement;
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    appState: {
 | 
					 | 
				
			||||||
      ...appState,
 | 
					 | 
				
			||||||
      // update state only if we've set all select text elements to
 | 
					 | 
				
			||||||
      // the same font size
 | 
					 | 
				
			||||||
      currentItemFontSize:
 | 
					 | 
				
			||||||
        newFontSizes.size === 1
 | 
					 | 
				
			||||||
          ? [...newFontSizes][0]
 | 
					 | 
				
			||||||
          : fallbackValue ?? appState.currentItemFontSize,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    commitToHistory: true,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// -----------------------------------------------------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionChangeStrokeColor = register({
 | 
					export const actionChangeStrokeColor = register({
 | 
				
			||||||
  name: "changeStrokeColor",
 | 
					  name: "changeStrokeColor",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...(value.currentItemStrokeColor && {
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
        elements: changeProperty(
 | 
					        newElementWith(el, {
 | 
				
			||||||
          elements,
 | 
					          strokeColor: value,
 | 
				
			||||||
          appState,
 | 
					        }),
 | 
				
			||||||
          (el) => {
 | 
					      ),
 | 
				
			||||||
            return hasStrokeColor(el.type)
 | 
					      appState: { ...appState, currentItemStrokeColor: value },
 | 
				
			||||||
              ? newElementWith(el, {
 | 
					      commitToHistory: true,
 | 
				
			||||||
                  strokeColor: value.currentItemStrokeColor,
 | 
					 | 
				
			||||||
                })
 | 
					 | 
				
			||||||
              : el;
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          true,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      appState: {
 | 
					 | 
				
			||||||
        ...appState,
 | 
					 | 
				
			||||||
        ...value,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      commitToHistory: !!value.currentItemStrokeColor,
 | 
					 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
					  PanelComponent: ({ elements, appState, updateData }) => (
 | 
				
			||||||
@@ -230,13 +111,7 @@ export const actionChangeStrokeColor = register({
 | 
				
			|||||||
          (element) => element.strokeColor,
 | 
					          (element) => element.strokeColor,
 | 
				
			||||||
          appState.currentItemStrokeColor,
 | 
					          appState.currentItemStrokeColor,
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        onChange={(color) => updateData({ currentItemStrokeColor: color })}
 | 
					        onChange={updateData}
 | 
				
			||||||
        isActive={appState.openPopup === "strokeColorPicker"}
 | 
					 | 
				
			||||||
        setActive={(active) =>
 | 
					 | 
				
			||||||
          updateData({ openPopup: active ? "strokeColorPicker" : null })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        elements={elements}
 | 
					 | 
				
			||||||
        appState={appState}
 | 
					 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
@@ -244,21 +119,15 @@ export const actionChangeStrokeColor = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeBackgroundColor = register({
 | 
					export const actionChangeBackgroundColor = register({
 | 
				
			||||||
  name: "changeBackgroundColor",
 | 
					  name: "changeBackgroundColor",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...(value.currentItemBackgroundColor && {
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
        elements: changeProperty(elements, appState, (el) =>
 | 
					        newElementWith(el, {
 | 
				
			||||||
          newElementWith(el, {
 | 
					          backgroundColor: value,
 | 
				
			||||||
            backgroundColor: value.currentItemBackgroundColor,
 | 
					        }),
 | 
				
			||||||
          }),
 | 
					      ),
 | 
				
			||||||
        ),
 | 
					      appState: { ...appState, currentItemBackgroundColor: value },
 | 
				
			||||||
      }),
 | 
					      commitToHistory: true,
 | 
				
			||||||
      appState: {
 | 
					 | 
				
			||||||
        ...appState,
 | 
					 | 
				
			||||||
        ...value,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      commitToHistory: !!value.currentItemBackgroundColor,
 | 
					 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
					  PanelComponent: ({ elements, appState, updateData }) => (
 | 
				
			||||||
@@ -273,13 +142,7 @@ export const actionChangeBackgroundColor = register({
 | 
				
			|||||||
          (element) => element.backgroundColor,
 | 
					          (element) => element.backgroundColor,
 | 
				
			||||||
          appState.currentItemBackgroundColor,
 | 
					          appState.currentItemBackgroundColor,
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        onChange={(color) => updateData({ currentItemBackgroundColor: color })}
 | 
					        onChange={updateData}
 | 
				
			||||||
        isActive={appState.openPopup === "backgroundColorPicker"}
 | 
					 | 
				
			||||||
        setActive={(active) =>
 | 
					 | 
				
			||||||
          updateData({ openPopup: active ? "backgroundColorPicker" : null })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        elements={elements}
 | 
					 | 
				
			||||||
        appState={appState}
 | 
					 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
@@ -287,7 +150,6 @@ export const actionChangeBackgroundColor = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeFillStyle = register({
 | 
					export const actionChangeFillStyle = register({
 | 
				
			||||||
  name: "changeFillStyle",
 | 
					  name: "changeFillStyle",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(elements, appState, (el) =>
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
@@ -337,7 +199,6 @@ export const actionChangeFillStyle = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeStrokeWidth = register({
 | 
					export const actionChangeStrokeWidth = register({
 | 
				
			||||||
  name: "changeStrokeWidth",
 | 
					  name: "changeStrokeWidth",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(elements, appState, (el) =>
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
@@ -385,7 +246,6 @@ export const actionChangeStrokeWidth = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeSloppiness = register({
 | 
					export const actionChangeSloppiness = register({
 | 
				
			||||||
  name: "changeSloppiness",
 | 
					  name: "changeSloppiness",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(elements, appState, (el) =>
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
@@ -434,7 +294,6 @@ export const actionChangeSloppiness = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeStrokeStyle = register({
 | 
					export const actionChangeStrokeStyle = register({
 | 
				
			||||||
  name: "changeStrokeStyle",
 | 
					  name: "changeStrokeStyle",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(elements, appState, (el) =>
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
@@ -482,7 +341,6 @@ export const actionChangeStrokeStyle = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeOpacity = register({
 | 
					export const actionChangeOpacity = register({
 | 
				
			||||||
  name: "changeOpacity",
 | 
					  name: "changeOpacity",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(elements, appState, (el) =>
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
@@ -503,6 +361,20 @@ export const actionChangeOpacity = register({
 | 
				
			|||||||
        max="100"
 | 
					        max="100"
 | 
				
			||||||
        step="10"
 | 
					        step="10"
 | 
				
			||||||
        onChange={(event) => updateData(+event.target.value)}
 | 
					        onChange={(event) => updateData(+event.target.value)}
 | 
				
			||||||
 | 
					        onWheel={(event) => {
 | 
				
			||||||
 | 
					          event.stopPropagation();
 | 
				
			||||||
 | 
					          const target = event.target as HTMLInputElement;
 | 
				
			||||||
 | 
					          const STEP = 10;
 | 
				
			||||||
 | 
					          const MAX = 100;
 | 
				
			||||||
 | 
					          const MIN = 0;
 | 
				
			||||||
 | 
					          const value = +target.value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          if (event.deltaY < 0 && value < MAX) {
 | 
				
			||||||
 | 
					            updateData(value + STEP);
 | 
				
			||||||
 | 
					          } else if (event.deltaY > 0 && value > MIN) {
 | 
				
			||||||
 | 
					            updateData(value - STEP);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
        value={
 | 
					        value={
 | 
				
			||||||
          getFormValue(
 | 
					          getFormValue(
 | 
				
			||||||
            elements,
 | 
					            elements,
 | 
				
			||||||
@@ -518,54 +390,41 @@ export const actionChangeOpacity = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeFontSize = register({
 | 
					export const actionChangeFontSize = register({
 | 
				
			||||||
  name: "changeFontSize",
 | 
					  name: "changeFontSize",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return changeFontSize(elements, appState, () => value, value);
 | 
					    return {
 | 
				
			||||||
 | 
					      elements: changeProperty(elements, appState, (el) => {
 | 
				
			||||||
 | 
					        if (isTextElement(el)) {
 | 
				
			||||||
 | 
					          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
				
			||||||
 | 
					            fontSize: value,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					          redrawTextBoundingBox(element);
 | 
				
			||||||
 | 
					          return element;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return el;
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					      appState: {
 | 
				
			||||||
 | 
					        ...appState,
 | 
				
			||||||
 | 
					        currentItemFontSize: value,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      commitToHistory: true,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
					  PanelComponent: ({ elements, appState, updateData }) => (
 | 
				
			||||||
    <fieldset>
 | 
					    <fieldset>
 | 
				
			||||||
      <legend>{t("labels.fontSize")}</legend>
 | 
					      <legend>{t("labels.fontSize")}</legend>
 | 
				
			||||||
      <ButtonIconSelect
 | 
					      <ButtonSelect
 | 
				
			||||||
        group="font-size"
 | 
					        group="font-size"
 | 
				
			||||||
        options={[
 | 
					        options={[
 | 
				
			||||||
          {
 | 
					          { value: 16, text: t("labels.small") },
 | 
				
			||||||
            value: 16,
 | 
					          { value: 20, text: t("labels.medium") },
 | 
				
			||||||
            text: t("labels.small"),
 | 
					          { value: 28, text: t("labels.large") },
 | 
				
			||||||
            icon: <FontSizeSmallIcon theme={appState.theme} />,
 | 
					          { value: 36, text: t("labels.veryLarge") },
 | 
				
			||||||
            testId: "fontSize-small",
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            value: 20,
 | 
					 | 
				
			||||||
            text: t("labels.medium"),
 | 
					 | 
				
			||||||
            icon: <FontSizeMediumIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            testId: "fontSize-medium",
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            value: 28,
 | 
					 | 
				
			||||||
            text: t("labels.large"),
 | 
					 | 
				
			||||||
            icon: <FontSizeLargeIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            testId: "fontSize-large",
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            value: 36,
 | 
					 | 
				
			||||||
            text: t("labels.veryLarge"),
 | 
					 | 
				
			||||||
            icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            testId: "fontSize-veryLarge",
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        ]}
 | 
					        ]}
 | 
				
			||||||
        value={getFormValue(
 | 
					        value={getFormValue(
 | 
				
			||||||
          elements,
 | 
					          elements,
 | 
				
			||||||
          appState,
 | 
					          appState,
 | 
				
			||||||
          (element) => {
 | 
					          (element) => isTextElement(element) && element.fontSize,
 | 
				
			||||||
            if (isTextElement(element)) {
 | 
					 | 
				
			||||||
              return element.fontSize;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            const boundTextElement = getBoundTextElement(element);
 | 
					 | 
				
			||||||
            if (boundTextElement) {
 | 
					 | 
				
			||||||
              return boundTextElement.fontSize;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return null;
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          appState.currentItemFontSize || DEFAULT_FONT_SIZE,
 | 
					          appState.currentItemFontSize || DEFAULT_FONT_SIZE,
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        onChange={(value) => updateData(value)}
 | 
					        onChange={(value) => updateData(value)}
 | 
				
			||||||
@@ -574,70 +433,21 @@ export const actionChangeFontSize = register({
 | 
				
			|||||||
  ),
 | 
					  ),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionDecreaseFontSize = register({
 | 
					 | 
				
			||||||
  name: "decreaseFontSize",
 | 
					 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					 | 
				
			||||||
    return changeFontSize(elements, appState, (element) =>
 | 
					 | 
				
			||||||
      Math.round(
 | 
					 | 
				
			||||||
        // get previous value before relative increase (doesn't work fully
 | 
					 | 
				
			||||||
        // due to rounding and float precision issues)
 | 
					 | 
				
			||||||
        (1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  keyTest: (event) => {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
					 | 
				
			||||||
      event.shiftKey &&
 | 
					 | 
				
			||||||
      // KEYS.COMMA needed for MacOS
 | 
					 | 
				
			||||||
      (event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionIncreaseFontSize = register({
 | 
					 | 
				
			||||||
  name: "increaseFontSize",
 | 
					 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					 | 
				
			||||||
    return changeFontSize(elements, appState, (element) =>
 | 
					 | 
				
			||||||
      Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  keyTest: (event) => {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
					 | 
				
			||||||
      event.shiftKey &&
 | 
					 | 
				
			||||||
      // KEYS.PERIOD needed for MacOS
 | 
					 | 
				
			||||||
      (event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionChangeFontFamily = register({
 | 
					export const actionChangeFontFamily = register({
 | 
				
			||||||
  name: "changeFontFamily",
 | 
					  name: "changeFontFamily",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(
 | 
					      elements: changeProperty(elements, appState, (el) => {
 | 
				
			||||||
        elements,
 | 
					        if (isTextElement(el)) {
 | 
				
			||||||
        appState,
 | 
					          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
				
			||||||
        (oldElement) => {
 | 
					            fontFamily: value,
 | 
				
			||||||
          if (isTextElement(oldElement)) {
 | 
					          });
 | 
				
			||||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
					          redrawTextBoundingBox(element);
 | 
				
			||||||
              oldElement,
 | 
					          return element;
 | 
				
			||||||
              {
 | 
					        }
 | 
				
			||||||
                fontFamily: value,
 | 
					 | 
				
			||||||
              },
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
					 | 
				
			||||||
            return newElement;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          return oldElement;
 | 
					        return el;
 | 
				
			||||||
        },
 | 
					      }),
 | 
				
			||||||
        true,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        currentItemFontFamily: value,
 | 
					        currentItemFontFamily: value,
 | 
				
			||||||
@@ -646,47 +456,22 @@ export const actionChangeFontFamily = register({
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
					  PanelComponent: ({ elements, appState, updateData }) => {
 | 
				
			||||||
    const options: {
 | 
					    const options: { value: FontFamily; text: string }[] = [
 | 
				
			||||||
      value: FontFamilyValues;
 | 
					      { value: 1, text: t("labels.handDrawn") },
 | 
				
			||||||
      text: string;
 | 
					      { value: 2, text: t("labels.normal") },
 | 
				
			||||||
      icon: JSX.Element;
 | 
					      { value: 3, text: t("labels.code") },
 | 
				
			||||||
    }[] = [
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        value: FONT_FAMILY.Virgil,
 | 
					 | 
				
			||||||
        text: t("labels.handDrawn"),
 | 
					 | 
				
			||||||
        icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        value: FONT_FAMILY.Helvetica,
 | 
					 | 
				
			||||||
        text: t("labels.normal"),
 | 
					 | 
				
			||||||
        icon: <FontFamilyNormalIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        value: FONT_FAMILY.Cascadia,
 | 
					 | 
				
			||||||
        text: t("labels.code"),
 | 
					 | 
				
			||||||
        icon: <FontFamilyCodeIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <fieldset>
 | 
					      <fieldset>
 | 
				
			||||||
        <legend>{t("labels.fontFamily")}</legend>
 | 
					        <legend>{t("labels.fontFamily")}</legend>
 | 
				
			||||||
        <ButtonIconSelect<FontFamilyValues | false>
 | 
					        <ButtonSelect<FontFamily | false>
 | 
				
			||||||
          group="font-family"
 | 
					          group="font-family"
 | 
				
			||||||
          options={options}
 | 
					          options={options}
 | 
				
			||||||
          value={getFormValue(
 | 
					          value={getFormValue(
 | 
				
			||||||
            elements,
 | 
					            elements,
 | 
				
			||||||
            appState,
 | 
					            appState,
 | 
				
			||||||
            (element) => {
 | 
					            (element) => isTextElement(element) && element.fontFamily,
 | 
				
			||||||
              if (isTextElement(element)) {
 | 
					 | 
				
			||||||
                return element.fontFamily;
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              const boundTextElement = getBoundTextElement(element);
 | 
					 | 
				
			||||||
              if (boundTextElement) {
 | 
					 | 
				
			||||||
                return boundTextElement.fontFamily;
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              return null;
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
					            appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
          onChange={(value) => updateData(value)}
 | 
					          onChange={(value) => updateData(value)}
 | 
				
			||||||
@@ -698,26 +483,19 @@ export const actionChangeFontFamily = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeTextAlign = register({
 | 
					export const actionChangeTextAlign = register({
 | 
				
			||||||
  name: "changeTextAlign",
 | 
					  name: "changeTextAlign",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(
 | 
					      elements: changeProperty(elements, appState, (el) => {
 | 
				
			||||||
        elements,
 | 
					        if (isTextElement(el)) {
 | 
				
			||||||
        appState,
 | 
					          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
				
			||||||
        (oldElement) => {
 | 
					            textAlign: value,
 | 
				
			||||||
          if (isTextElement(oldElement)) {
 | 
					          });
 | 
				
			||||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
					          redrawTextBoundingBox(element);
 | 
				
			||||||
              oldElement,
 | 
					          return element;
 | 
				
			||||||
              { textAlign: value },
 | 
					        }
 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
					 | 
				
			||||||
            return newElement;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          return oldElement;
 | 
					        return el;
 | 
				
			||||||
        },
 | 
					      }),
 | 
				
			||||||
        true,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        currentItemTextAlign: value,
 | 
					        currentItemTextAlign: value,
 | 
				
			||||||
@@ -725,121 +503,30 @@ export const actionChangeTextAlign = register({
 | 
				
			|||||||
      commitToHistory: true,
 | 
					      commitToHistory: true,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
					  PanelComponent: ({ elements, appState, updateData }) => (
 | 
				
			||||||
    return (
 | 
					    <fieldset>
 | 
				
			||||||
      <fieldset>
 | 
					      <legend>{t("labels.textAlign")}</legend>
 | 
				
			||||||
        <legend>{t("labels.textAlign")}</legend>
 | 
					      <ButtonSelect<TextAlign | false>
 | 
				
			||||||
        <ButtonIconSelect<TextAlign | false>
 | 
					        group="text-align"
 | 
				
			||||||
          group="text-align"
 | 
					        options={[
 | 
				
			||||||
          options={[
 | 
					          { value: "left", text: t("labels.left") },
 | 
				
			||||||
            {
 | 
					          { value: "center", text: t("labels.center") },
 | 
				
			||||||
              value: "left",
 | 
					          { value: "right", text: t("labels.right") },
 | 
				
			||||||
              text: t("labels.left"),
 | 
					        ]}
 | 
				
			||||||
              icon: <TextAlignLeftIcon theme={appState.theme} />,
 | 
					        value={getFormValue(
 | 
				
			||||||
            },
 | 
					          elements,
 | 
				
			||||||
            {
 | 
					          appState,
 | 
				
			||||||
              value: "center",
 | 
					          (element) => isTextElement(element) && element.textAlign,
 | 
				
			||||||
              text: t("labels.center"),
 | 
					          appState.currentItemTextAlign,
 | 
				
			||||||
              icon: <TextAlignCenterIcon theme={appState.theme} />,
 | 
					        )}
 | 
				
			||||||
            },
 | 
					        onChange={(value) => updateData(value)}
 | 
				
			||||||
            {
 | 
					      />
 | 
				
			||||||
              value: "right",
 | 
					    </fieldset>
 | 
				
			||||||
              text: t("labels.right"),
 | 
					  ),
 | 
				
			||||||
              icon: <TextAlignRightIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
          ]}
 | 
					 | 
				
			||||||
          value={getFormValue(
 | 
					 | 
				
			||||||
            elements,
 | 
					 | 
				
			||||||
            appState,
 | 
					 | 
				
			||||||
            (element) => {
 | 
					 | 
				
			||||||
              if (isTextElement(element)) {
 | 
					 | 
				
			||||||
                return element.textAlign;
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              const boundTextElement = getBoundTextElement(element);
 | 
					 | 
				
			||||||
              if (boundTextElement) {
 | 
					 | 
				
			||||||
                return boundTextElement.textAlign;
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              return null;
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            appState.currentItemTextAlign,
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          onChange={(value) => updateData(value)}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </fieldset>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
export const actionChangeVerticalAlign = register({
 | 
					 | 
				
			||||||
  name: "changeVerticalAlign",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      elements: changeProperty(
 | 
					 | 
				
			||||||
        elements,
 | 
					 | 
				
			||||||
        appState,
 | 
					 | 
				
			||||||
        (oldElement) => {
 | 
					 | 
				
			||||||
          if (isTextElement(oldElement)) {
 | 
					 | 
				
			||||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
					 | 
				
			||||||
              oldElement,
 | 
					 | 
				
			||||||
              { verticalAlign: value },
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
					 | 
				
			||||||
            return newElement;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          return oldElement;
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        true,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      appState: {
 | 
					 | 
				
			||||||
        ...appState,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <fieldset>
 | 
					 | 
				
			||||||
        <ButtonIconSelect<VerticalAlign | false>
 | 
					 | 
				
			||||||
          group="text-align"
 | 
					 | 
				
			||||||
          options={[
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
              value: VERTICAL_ALIGN.TOP,
 | 
					 | 
				
			||||||
              text: t("labels.alignTop"),
 | 
					 | 
				
			||||||
              icon: <TextAlignTopIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
              value: VERTICAL_ALIGN.MIDDLE,
 | 
					 | 
				
			||||||
              text: t("labels.centerVertically"),
 | 
					 | 
				
			||||||
              icon: <TextAlignMiddleIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
              value: VERTICAL_ALIGN.BOTTOM,
 | 
					 | 
				
			||||||
              text: t("labels.alignBottom"),
 | 
					 | 
				
			||||||
              icon: <TextAlignBottomIcon theme={appState.theme} />,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
          ]}
 | 
					 | 
				
			||||||
          value={getFormValue(elements, appState, (element) => {
 | 
					 | 
				
			||||||
            if (isTextElement(element) && element.containerId) {
 | 
					 | 
				
			||||||
              return element.verticalAlign;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            const boundTextElement = getBoundTextElement(element);
 | 
					 | 
				
			||||||
            if (boundTextElement) {
 | 
					 | 
				
			||||||
              return boundTextElement.verticalAlign;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return null;
 | 
					 | 
				
			||||||
          })}
 | 
					 | 
				
			||||||
          onChange={(value) => updateData(value)}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </fieldset>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionChangeSharpness = register({
 | 
					export const actionChangeSharpness = register({
 | 
				
			||||||
  name: "changeSharpness",
 | 
					  name: "changeSharpness",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (elements, appState, value) => {
 | 
					  perform: (elements, appState, value) => {
 | 
				
			||||||
    const targetElements = getTargetElements(
 | 
					    const targetElements = getTargetElements(
 | 
				
			||||||
      getNonDeletedElements(elements),
 | 
					      getNonDeletedElements(elements),
 | 
				
			||||||
@@ -847,10 +534,10 @@ export const actionChangeSharpness = register({
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
    const shouldUpdateForNonLinearElements = targetElements.length
 | 
					    const shouldUpdateForNonLinearElements = targetElements.length
 | 
				
			||||||
      ? targetElements.every((el) => !isLinearElement(el))
 | 
					      ? targetElements.every((el) => !isLinearElement(el))
 | 
				
			||||||
      : !isLinearElementType(appState.activeTool.type);
 | 
					      : !isLinearElementType(appState.elementType);
 | 
				
			||||||
    const shouldUpdateForLinearElements = targetElements.length
 | 
					    const shouldUpdateForLinearElements = targetElements.length
 | 
				
			||||||
      ? targetElements.every(isLinearElement)
 | 
					      ? targetElements.every(isLinearElement)
 | 
				
			||||||
      : isLinearElementType(appState.activeTool.type);
 | 
					      : isLinearElementType(appState.elementType);
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: changeProperty(elements, appState, (el) =>
 | 
					      elements: changeProperty(elements, appState, (el) =>
 | 
				
			||||||
        newElementWith(el, {
 | 
					        newElementWith(el, {
 | 
				
			||||||
@@ -890,8 +577,8 @@ export const actionChangeSharpness = register({
 | 
				
			|||||||
          elements,
 | 
					          elements,
 | 
				
			||||||
          appState,
 | 
					          appState,
 | 
				
			||||||
          (element) => element.strokeSharpness,
 | 
					          (element) => element.strokeSharpness,
 | 
				
			||||||
          (canChangeSharpness(appState.activeTool.type) &&
 | 
					          (canChangeSharpness(appState.elementType) &&
 | 
				
			||||||
            (isLinearElementType(appState.activeTool.type)
 | 
					            (isLinearElementType(appState.elementType)
 | 
				
			||||||
              ? appState.currentItemLinearStrokeSharpness
 | 
					              ? appState.currentItemLinearStrokeSharpness
 | 
				
			||||||
              : appState.currentItemStrokeSharpness)) ||
 | 
					              : appState.currentItemStrokeSharpness)) ||
 | 
				
			||||||
            null,
 | 
					            null,
 | 
				
			||||||
@@ -904,7 +591,6 @@ export const actionChangeSharpness = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionChangeArrowhead = register({
 | 
					export const actionChangeArrowhead = register({
 | 
				
			||||||
  name: "changeArrowhead",
 | 
					  name: "changeArrowhead",
 | 
				
			||||||
  trackEvent: false,
 | 
					 | 
				
			||||||
  perform: (
 | 
					  perform: (
 | 
				
			||||||
    elements,
 | 
					    elements,
 | 
				
			||||||
    appState,
 | 
					    appState,
 | 
				
			||||||
@@ -975,14 +661,6 @@ export const actionChangeArrowhead = register({
 | 
				
			|||||||
                icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
 | 
					                icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
 | 
				
			||||||
                keyBinding: "r",
 | 
					                keyBinding: "r",
 | 
				
			||||||
              },
 | 
					              },
 | 
				
			||||||
              {
 | 
					 | 
				
			||||||
                value: "triangle",
 | 
					 | 
				
			||||||
                text: t("labels.arrowhead_triangle"),
 | 
					 | 
				
			||||||
                icon: (
 | 
					 | 
				
			||||||
                  <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                keyBinding: "t",
 | 
					 | 
				
			||||||
              },
 | 
					 | 
				
			||||||
            ]}
 | 
					            ]}
 | 
				
			||||||
            value={getFormValue<Arrowhead | null>(
 | 
					            value={getFormValue<Arrowhead | null>(
 | 
				
			||||||
              elements,
 | 
					              elements,
 | 
				
			||||||
@@ -1025,14 +703,6 @@ export const actionChangeArrowhead = register({
 | 
				
			|||||||
                keyBinding: "r",
 | 
					                keyBinding: "r",
 | 
				
			||||||
                icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
 | 
					                icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
 | 
				
			||||||
              },
 | 
					              },
 | 
				
			||||||
              {
 | 
					 | 
				
			||||||
                value: "triangle",
 | 
					 | 
				
			||||||
                text: t("labels.arrowhead_triangle"),
 | 
					 | 
				
			||||||
                icon: (
 | 
					 | 
				
			||||||
                  <ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                keyBinding: "t",
 | 
					 | 
				
			||||||
              },
 | 
					 | 
				
			||||||
            ]}
 | 
					            ]}
 | 
				
			||||||
            value={getFormValue<Arrowhead | null>(
 | 
					            value={getFormValue<Arrowhead | null>(
 | 
				
			||||||
              elements,
 | 
					              elements,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,10 @@
 | 
				
			|||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { selectGroupsForSelectedElements } from "../groups";
 | 
					import { selectGroupsForSelectedElements } from "../groups";
 | 
				
			||||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
					import { getNonDeletedElements } from "../element";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionSelectAll = register({
 | 
					export const actionSelectAll = register({
 | 
				
			||||||
  name: "selectAll",
 | 
					  name: "selectAll",
 | 
				
			||||||
  trackEvent: { category: "canvas" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    if (appState.editingLinearElement) {
 | 
					    if (appState.editingLinearElement) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
@@ -16,11 +15,7 @@ export const actionSelectAll = register({
 | 
				
			|||||||
          ...appState,
 | 
					          ...appState,
 | 
				
			||||||
          editingGroupId: null,
 | 
					          editingGroupId: null,
 | 
				
			||||||
          selectedElementIds: elements.reduce((map, element) => {
 | 
					          selectedElementIds: elements.reduce((map, element) => {
 | 
				
			||||||
            if (
 | 
					            if (!element.isDeleted) {
 | 
				
			||||||
              !element.isDeleted &&
 | 
					 | 
				
			||||||
              !(isTextElement(element) && element.containerId) &&
 | 
					 | 
				
			||||||
              element.locked === false
 | 
					 | 
				
			||||||
            ) {
 | 
					 | 
				
			||||||
              map[element.id] = true;
 | 
					              map[element.id] = true;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            return map;
 | 
					            return map;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,71 +0,0 @@
 | 
				
			|||||||
import ExcalidrawApp from "../excalidraw-app";
 | 
					 | 
				
			||||||
import { t } from "../i18n";
 | 
					 | 
				
			||||||
import { CODES } from "../keys";
 | 
					 | 
				
			||||||
import { API } from "../tests/helpers/api";
 | 
					 | 
				
			||||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
 | 
					 | 
				
			||||||
import { fireEvent, render, screen } from "../tests/test-utils";
 | 
					 | 
				
			||||||
import { copiedStyles } from "./actionStyles";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const { h } = window;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const mouse = new Pointer("mouse");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe("actionStyles", () => {
 | 
					 | 
				
			||||||
  beforeEach(async () => {
 | 
					 | 
				
			||||||
    await render(<ExcalidrawApp />);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  it("should copy & paste styles via keyboard", () => {
 | 
					 | 
				
			||||||
    UI.clickTool("rectangle");
 | 
					 | 
				
			||||||
    mouse.down(10, 10);
 | 
					 | 
				
			||||||
    mouse.up(20, 20);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    UI.clickTool("rectangle");
 | 
					 | 
				
			||||||
    mouse.down(10, 10);
 | 
					 | 
				
			||||||
    mouse.up(20, 20);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Change some styles of second rectangle
 | 
					 | 
				
			||||||
    UI.clickLabeledElement("Stroke");
 | 
					 | 
				
			||||||
    UI.clickLabeledElement(t("colors.c92a2a"));
 | 
					 | 
				
			||||||
    UI.clickLabeledElement("Background");
 | 
					 | 
				
			||||||
    UI.clickLabeledElement(t("colors.e64980"));
 | 
					 | 
				
			||||||
    // Fill style
 | 
					 | 
				
			||||||
    fireEvent.click(screen.getByTitle("Cross-hatch"));
 | 
					 | 
				
			||||||
    // Stroke width
 | 
					 | 
				
			||||||
    fireEvent.click(screen.getByTitle("Bold"));
 | 
					 | 
				
			||||||
    // Stroke style
 | 
					 | 
				
			||||||
    fireEvent.click(screen.getByTitle("Dotted"));
 | 
					 | 
				
			||||||
    // Roughness
 | 
					 | 
				
			||||||
    fireEvent.click(screen.getByTitle("Cartoonist"));
 | 
					 | 
				
			||||||
    // Opacity
 | 
					 | 
				
			||||||
    fireEvent.change(screen.getByLabelText("Opacity"), {
 | 
					 | 
				
			||||||
      target: { value: "60" },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    mouse.reset();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    API.setSelectedElements([h.elements[1]]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
 | 
					 | 
				
			||||||
      Keyboard.codeDown(CODES.C);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    const secondRect = JSON.parse(copiedStyles);
 | 
					 | 
				
			||||||
    expect(secondRect.id).toBe(h.elements[1].id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    mouse.reset();
 | 
					 | 
				
			||||||
    // Paste styles to first rectangle
 | 
					 | 
				
			||||||
    API.setSelectedElements([h.elements[0]]);
 | 
					 | 
				
			||||||
    Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
 | 
					 | 
				
			||||||
      Keyboard.codeDown(CODES.V);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const firstRect = API.getSelectedElement();
 | 
					 | 
				
			||||||
    expect(firstRect.id).toBe(h.elements[0].id);
 | 
					 | 
				
			||||||
    expect(firstRect.strokeColor).toBe("#c92a2a");
 | 
					 | 
				
			||||||
    expect(firstRect.backgroundColor).toBe("#e64980");
 | 
					 | 
				
			||||||
    expect(firstRect.fillStyle).toBe("cross-hatch");
 | 
					 | 
				
			||||||
    expect(firstRect.strokeWidth).toBe(2); // Bold: 2
 | 
					 | 
				
			||||||
    expect(firstRect.strokeStyle).toBe("dotted");
 | 
					 | 
				
			||||||
    expect(firstRect.roughness).toBe(2); // Cartoonist: 2
 | 
					 | 
				
			||||||
    expect(firstRect.opacity).toBe(60);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@@ -12,14 +12,12 @@ import {
 | 
				
			|||||||
  DEFAULT_FONT_FAMILY,
 | 
					  DEFAULT_FONT_FAMILY,
 | 
				
			||||||
  DEFAULT_TEXT_ALIGN,
 | 
					  DEFAULT_TEXT_ALIGN,
 | 
				
			||||||
} from "../constants";
 | 
					} from "../constants";
 | 
				
			||||||
import { getContainerElement } from "../element/textElement";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// `copiedStyles` is exported only for tests.
 | 
					// `copiedStyles` is exported only for tests.
 | 
				
			||||||
export let copiedStyles: string = "{}";
 | 
					export let copiedStyles: string = "{}";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionCopyStyles = register({
 | 
					export const actionCopyStyles = register({
 | 
				
			||||||
  name: "copyStyles",
 | 
					  name: "copyStyles",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    const element = elements.find((el) => appState.selectedElementIds[el.id]);
 | 
					    const element = elements.find((el) => appState.selectedElementIds[el.id]);
 | 
				
			||||||
    if (element) {
 | 
					    if (element) {
 | 
				
			||||||
@@ -40,7 +38,6 @@ export const actionCopyStyles = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionPasteStyles = register({
 | 
					export const actionPasteStyles = register({
 | 
				
			||||||
  name: "pasteStyles",
 | 
					  name: "pasteStyles",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    const pastedElement = JSON.parse(copiedStyles);
 | 
					    const pastedElement = JSON.parse(copiedStyles);
 | 
				
			||||||
    if (!isExcalidrawElement(pastedElement)) {
 | 
					    if (!isExcalidrawElement(pastedElement)) {
 | 
				
			||||||
@@ -58,14 +55,13 @@ export const actionPasteStyles = register({
 | 
				
			|||||||
            opacity: pastedElement?.opacity,
 | 
					            opacity: pastedElement?.opacity,
 | 
				
			||||||
            roughness: pastedElement?.roughness,
 | 
					            roughness: pastedElement?.roughness,
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
          if (isTextElement(newElement) && isTextElement(element)) {
 | 
					          if (isTextElement(newElement)) {
 | 
				
			||||||
            mutateElement(newElement, {
 | 
					            mutateElement(newElement, {
 | 
				
			||||||
              fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
 | 
					              fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
 | 
				
			||||||
              fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
					              fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
				
			||||||
              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
					              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
 | 
					            redrawTextBoundingBox(newElement);
 | 
				
			||||||
            redrawTextBoundingBox(newElement, getContainerElement(newElement));
 | 
					 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          return newElement;
 | 
					          return newElement;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
 | 
				
			|||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { GRID_SIZE } from "../constants";
 | 
					import { GRID_SIZE } from "../constants";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
 | 
					import { trackEvent } from "../analytics";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionToggleGridMode = register({
 | 
					export const actionToggleGridMode = register({
 | 
				
			||||||
  name: "gridMode",
 | 
					  name: "gridMode",
 | 
				
			||||||
  trackEvent: {
 | 
					 | 
				
			||||||
    category: "canvas",
 | 
					 | 
				
			||||||
    predicate: (appState) => !appState.gridSize,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  perform(elements, appState) {
 | 
					  perform(elements, appState) {
 | 
				
			||||||
 | 
					    trackEvent("view", "mode", "grid");
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,63 +0,0 @@
 | 
				
			|||||||
import { newElementWith } from "../element/mutateElement";
 | 
					 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					 | 
				
			||||||
import { getSelectedElements } from "../scene";
 | 
					 | 
				
			||||||
import { arrayToMap } from "../utils";
 | 
					 | 
				
			||||||
import { register } from "./register";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actionToggleLock = register({
 | 
					 | 
				
			||||||
  name: "toggleLock",
 | 
					 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!selectedElements.length) {
 | 
					 | 
				
			||||||
      return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const operation = getOperation(selectedElements);
 | 
					 | 
				
			||||||
    const selectedElementsMap = arrayToMap(selectedElements);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      elements: elements.map((element) => {
 | 
					 | 
				
			||||||
        if (!selectedElementsMap.has(element.id)) {
 | 
					 | 
				
			||||||
          return element;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return newElementWith(element, { locked: operation === "lock" });
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      appState,
 | 
					 | 
				
			||||||
      commitToHistory: true,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  contextItemLabel: (elements, appState) => {
 | 
					 | 
				
			||||||
    const selected = getSelectedElements(elements, appState, false);
 | 
					 | 
				
			||||||
    if (selected.length === 1) {
 | 
					 | 
				
			||||||
      return selected[0].locked
 | 
					 | 
				
			||||||
        ? "labels.elementLock.unlock"
 | 
					 | 
				
			||||||
        : "labels.elementLock.lock";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (selected.length > 1) {
 | 
					 | 
				
			||||||
      return getOperation(selected) === "lock"
 | 
					 | 
				
			||||||
        ? "labels.elementLock.lockAll"
 | 
					 | 
				
			||||||
        : "labels.elementLock.unlockAll";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    throw new Error(
 | 
					 | 
				
			||||||
      "Unexpected zero elements to lock/unlock. This should never happen.",
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  keyTest: (event, appState, elements) => {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      event.key.toLocaleLowerCase() === KEYS.L &&
 | 
					 | 
				
			||||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
					 | 
				
			||||||
      event.shiftKey &&
 | 
					 | 
				
			||||||
      getSelectedElements(elements, appState, false).length > 0
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const getOperation = (
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
 | 
					 | 
				
			||||||
@@ -1,9 +1,7 @@
 | 
				
			|||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
import { CODES, KEYS } from "../keys";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionToggleStats = register({
 | 
					export const actionToggleStats = register({
 | 
				
			||||||
  name: "stats",
 | 
					  name: "stats",
 | 
				
			||||||
  trackEvent: { category: "menu" },
 | 
					 | 
				
			||||||
  perform(elements, appState) {
 | 
					  perform(elements, appState) {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
@@ -15,6 +13,4 @@ export const actionToggleStats = register({
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  checked: (appState) => appState.showStats,
 | 
					  checked: (appState) => appState.showStats,
 | 
				
			||||||
  contextItemLabel: "stats.title",
 | 
					  contextItemLabel: "stats.title",
 | 
				
			||||||
  keyTest: (event) =>
 | 
					 | 
				
			||||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,17 +1,16 @@
 | 
				
			|||||||
import { CODES, KEYS } from "../keys";
 | 
					import { CODES, KEYS } from "../keys";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
 | 
					import { trackEvent } from "../analytics";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionToggleViewMode = register({
 | 
					export const actionToggleViewMode = register({
 | 
				
			||||||
  name: "viewMode",
 | 
					  name: "viewMode",
 | 
				
			||||||
  trackEvent: {
 | 
					 | 
				
			||||||
    category: "canvas",
 | 
					 | 
				
			||||||
    predicate: (appState) => !appState.viewModeEnabled,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  perform(elements, appState) {
 | 
					  perform(elements, appState) {
 | 
				
			||||||
 | 
					    trackEvent("view", "mode", "view");
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
        viewModeEnabled: !this.checked!(appState),
 | 
					        viewModeEnabled: !this.checked!(appState),
 | 
				
			||||||
 | 
					        selectedElementIds: {},
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      commitToHistory: false,
 | 
					      commitToHistory: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,12 @@
 | 
				
			|||||||
import { CODES, KEYS } from "../keys";
 | 
					import { CODES, KEYS } from "../keys";
 | 
				
			||||||
import { register } from "./register";
 | 
					import { register } from "./register";
 | 
				
			||||||
 | 
					import { trackEvent } from "../analytics";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actionToggleZenMode = register({
 | 
					export const actionToggleZenMode = register({
 | 
				
			||||||
  name: "zenMode",
 | 
					  name: "zenMode",
 | 
				
			||||||
  trackEvent: {
 | 
					 | 
				
			||||||
    category: "canvas",
 | 
					 | 
				
			||||||
    predicate: (appState) => !appState.zenModeEnabled,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  perform(elements, appState) {
 | 
					  perform(elements, appState) {
 | 
				
			||||||
 | 
					    trackEvent("view", "mode", "zen");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      appState: {
 | 
					      appState: {
 | 
				
			||||||
        ...appState,
 | 
					        ...appState,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,6 @@ import {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionSendBackward = register({
 | 
					export const actionSendBackward = register({
 | 
				
			||||||
  name: "sendBackward",
 | 
					  name: "sendBackward",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: moveOneLeft(elements, appState),
 | 
					      elements: moveOneLeft(elements, appState),
 | 
				
			||||||
@@ -46,7 +45,6 @@ export const actionSendBackward = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionBringForward = register({
 | 
					export const actionBringForward = register({
 | 
				
			||||||
  name: "bringForward",
 | 
					  name: "bringForward",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: moveOneRight(elements, appState),
 | 
					      elements: moveOneRight(elements, appState),
 | 
				
			||||||
@@ -74,7 +72,6 @@ export const actionBringForward = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionSendToBack = register({
 | 
					export const actionSendToBack = register({
 | 
				
			||||||
  name: "sendToBack",
 | 
					  name: "sendToBack",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: moveAllLeft(elements, appState),
 | 
					      elements: moveAllLeft(elements, appState),
 | 
				
			||||||
@@ -109,8 +106,6 @@ export const actionSendToBack = register({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const actionBringToFront = register({
 | 
					export const actionBringToFront = register({
 | 
				
			||||||
  name: "bringToFront",
 | 
					  name: "bringToFront",
 | 
				
			||||||
  trackEvent: { category: "element" },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  perform: (elements, appState) => {
 | 
					  perform: (elements, appState) => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      elements: moveAllRight(elements, appState),
 | 
					      elements: moveAllRight(elements, appState),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,6 @@ export {
 | 
				
			|||||||
  actionChangeFontSize,
 | 
					  actionChangeFontSize,
 | 
				
			||||||
  actionChangeFontFamily,
 | 
					  actionChangeFontFamily,
 | 
				
			||||||
  actionChangeTextAlign,
 | 
					  actionChangeTextAlign,
 | 
				
			||||||
  actionChangeVerticalAlign,
 | 
					 | 
				
			||||||
} from "./actionProperties";
 | 
					} from "./actionProperties";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export {
 | 
					export {
 | 
				
			||||||
@@ -27,7 +26,6 @@ export {
 | 
				
			|||||||
  actionZoomOut,
 | 
					  actionZoomOut,
 | 
				
			||||||
  actionResetZoom,
 | 
					  actionResetZoom,
 | 
				
			||||||
  actionZoomToFit,
 | 
					  actionZoomToFit,
 | 
				
			||||||
  actionToggleTheme,
 | 
					 | 
				
			||||||
} from "./actionCanvas";
 | 
					} from "./actionCanvas";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export { actionFinalize } from "./actionFinalize";
 | 
					export { actionFinalize } from "./actionFinalize";
 | 
				
			||||||
@@ -35,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
 | 
				
			|||||||
export {
 | 
					export {
 | 
				
			||||||
  actionChangeProjectName,
 | 
					  actionChangeProjectName,
 | 
				
			||||||
  actionChangeExportBackground,
 | 
					  actionChangeExportBackground,
 | 
				
			||||||
  actionSaveToActiveFile,
 | 
					  actionSaveScene,
 | 
				
			||||||
  actionSaveFileToDisk,
 | 
					  actionSaveAsScene,
 | 
				
			||||||
  actionLoadScene,
 | 
					  actionLoadScene,
 | 
				
			||||||
} from "./actionExport";
 | 
					} from "./actionExport";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -68,20 +66,14 @@ export {
 | 
				
			|||||||
  distributeVertically,
 | 
					  distributeVertically,
 | 
				
			||||||
} from "./actionDistribute";
 | 
					} from "./actionDistribute";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export {
 | 
					export {
 | 
				
			||||||
  actionCopy,
 | 
					  actionCopy,
 | 
				
			||||||
  actionCut,
 | 
					  actionCut,
 | 
				
			||||||
  actionCopyAsPng,
 | 
					  actionCopyAsPng,
 | 
				
			||||||
  actionCopyAsSvg,
 | 
					  actionCopyAsSvg,
 | 
				
			||||||
  copyText,
 | 
					 | 
				
			||||||
} from "./actionClipboard";
 | 
					} from "./actionClipboard";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
					export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
				
			||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
					export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export { actionToggleStats } from "./actionToggleStats";
 | 
					export { actionToggleStats } from "./actionToggleStats";
 | 
				
			||||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
 | 
					 | 
				
			||||||
export { actionLink } from "../element/Hyperlink";
 | 
					 | 
				
			||||||
export { actionToggleLock } from "./actionToggleLock";
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,59 +1,33 @@
 | 
				
			|||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Action,
 | 
					  Action,
 | 
				
			||||||
 | 
					  ActionsManagerInterface,
 | 
				
			||||||
  UpdaterFn,
 | 
					  UpdaterFn,
 | 
				
			||||||
  ActionName,
 | 
					  ActionName,
 | 
				
			||||||
  ActionResult,
 | 
					  ActionResult,
 | 
				
			||||||
  PanelComponentProps,
 | 
					 | 
				
			||||||
  ActionSource,
 | 
					 | 
				
			||||||
} from "./types";
 | 
					} from "./types";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { AppClassProperties, AppState } from "../types";
 | 
					import { AppState, ExcalidrawProps } from "../types";
 | 
				
			||||||
import { MODES } from "../constants";
 | 
					import { MODES } from "../constants";
 | 
				
			||||||
import { trackEvent } from "../analytics";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const trackAction = (
 | 
					// This is the <App> component, but for now we don't care about anything but its
 | 
				
			||||||
  action: Action,
 | 
					// `canvas` state.
 | 
				
			||||||
  source: ActionSource,
 | 
					type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
 | 
				
			||||||
  appState: Readonly<AppState>,
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
  app: AppClassProperties,
 | 
					 | 
				
			||||||
  value: any,
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  if (action.trackEvent) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      if (typeof action.trackEvent === "object") {
 | 
					 | 
				
			||||||
        const shouldTrack = action.trackEvent.predicate
 | 
					 | 
				
			||||||
          ? action.trackEvent.predicate(appState, elements, value)
 | 
					 | 
				
			||||||
          : true;
 | 
					 | 
				
			||||||
        if (shouldTrack) {
 | 
					 | 
				
			||||||
          trackEvent(
 | 
					 | 
				
			||||||
            action.trackEvent.category,
 | 
					 | 
				
			||||||
            action.trackEvent.action || action.name,
 | 
					 | 
				
			||||||
            `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      console.error("error while logging action:", error);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class ActionManager {
 | 
					export class ActionManager implements ActionsManagerInterface {
 | 
				
			||||||
  actions = {} as Record<ActionName, Action>;
 | 
					  actions = {} as ActionsManagerInterface["actions"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 | 
					  updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getAppState: () => Readonly<AppState>;
 | 
					  getAppState: () => Readonly<AppState>;
 | 
				
			||||||
  getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
 | 
					  getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
 | 
				
			||||||
  app: AppClassProperties;
 | 
					  app: App;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    updater: UpdaterFn,
 | 
					    updater: UpdaterFn,
 | 
				
			||||||
    getAppState: () => AppState,
 | 
					    getAppState: () => AppState,
 | 
				
			||||||
    getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
 | 
					    getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
 | 
				
			||||||
    app: AppClassProperties,
 | 
					    app: App,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.updater = (actionResult) => {
 | 
					    this.updater = (actionResult) => {
 | 
				
			||||||
      if (actionResult && "then" in actionResult) {
 | 
					      if (actionResult && "then" in actionResult) {
 | 
				
			||||||
@@ -77,15 +51,11 @@ export class ActionManager {
 | 
				
			|||||||
    actions.forEach((action) => this.registerAction(action));
 | 
					    actions.forEach((action) => this.registerAction(action));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
 | 
					  handleKeyDown(event: KeyboardEvent) {
 | 
				
			||||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
					 | 
				
			||||||
    const data = Object.values(this.actions)
 | 
					    const data = Object.values(this.actions)
 | 
				
			||||||
      .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
 | 
					      .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
 | 
				
			||||||
      .filter(
 | 
					      .filter(
 | 
				
			||||||
        (action) =>
 | 
					        (action) =>
 | 
				
			||||||
          (action.name in canvasActions
 | 
					 | 
				
			||||||
            ? canvasActions[action.name as keyof typeof canvasActions]
 | 
					 | 
				
			||||||
            : true) &&
 | 
					 | 
				
			||||||
          action.keyTest &&
 | 
					          action.keyTest &&
 | 
				
			||||||
          action.keyTest(
 | 
					          action.keyTest(
 | 
				
			||||||
            event,
 | 
					            event,
 | 
				
			||||||
@@ -94,15 +64,9 @@ export class ActionManager {
 | 
				
			|||||||
          ),
 | 
					          ),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (data.length !== 1) {
 | 
					    if (data.length === 0) {
 | 
				
			||||||
      if (data.length > 1) {
 | 
					 | 
				
			||||||
        console.warn("Canceling as multiple actions match this shortcut", data);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const action = data[0];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const { viewModeEnabled } = this.getAppState();
 | 
					    const { viewModeEnabled } = this.getAppState();
 | 
				
			||||||
    if (viewModeEnabled) {
 | 
					    if (viewModeEnabled) {
 | 
				
			||||||
      if (!Object.values(MODES).includes(data[0].name)) {
 | 
					      if (!Object.values(MODES).includes(data[0].name)) {
 | 
				
			||||||
@@ -110,48 +74,38 @@ export class ActionManager {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const elements = this.getElementsIncludingDeleted();
 | 
					 | 
				
			||||||
    const appState = this.getAppState();
 | 
					 | 
				
			||||||
    const value = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    trackAction(action, "keyboard", appState, elements, this.app, null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    event.preventDefault();
 | 
					    event.preventDefault();
 | 
				
			||||||
    event.stopPropagation();
 | 
					    this.updater(
 | 
				
			||||||
    this.updater(data[0].perform(elements, appState, value, this.app));
 | 
					      data[0].perform(
 | 
				
			||||||
 | 
					        this.getElementsIncludingDeleted(),
 | 
				
			||||||
 | 
					        this.getAppState(),
 | 
				
			||||||
 | 
					        null,
 | 
				
			||||||
 | 
					        this.app,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  executeAction(action: Action, source: ActionSource = "api") {
 | 
					  executeAction(action: Action) {
 | 
				
			||||||
    const elements = this.getElementsIncludingDeleted();
 | 
					    this.updater(
 | 
				
			||||||
    const appState = this.getAppState();
 | 
					      action.perform(
 | 
				
			||||||
    const value = null;
 | 
					        this.getElementsIncludingDeleted(),
 | 
				
			||||||
 | 
					        this.getAppState(),
 | 
				
			||||||
    trackAction(action, source, appState, elements, this.app, value);
 | 
					        null,
 | 
				
			||||||
 | 
					        this.app,
 | 
				
			||||||
    this.updater(action.perform(elements, appState, value, this.app));
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  // Id is an attribute that we can use to pass in data like keys.
 | 
				
			||||||
   * @param data additional data sent to the PanelComponent
 | 
					  // This is needed for dynamically generated action components
 | 
				
			||||||
   */
 | 
					  // like the user list. We can use this key to extract more
 | 
				
			||||||
  renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
 | 
					  // data from app state. This is an alternative to generic prop hell!
 | 
				
			||||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
					  renderAction = (name: ActionName, id?: string) => {
 | 
				
			||||||
 | 
					    if (this.actions[name] && "PanelComponent" in this.actions[name]) {
 | 
				
			||||||
    if (
 | 
					 | 
				
			||||||
      this.actions[name] &&
 | 
					 | 
				
			||||||
      "PanelComponent" in this.actions[name] &&
 | 
					 | 
				
			||||||
      (name in canvasActions
 | 
					 | 
				
			||||||
        ? canvasActions[name as keyof typeof canvasActions]
 | 
					 | 
				
			||||||
        : true)
 | 
					 | 
				
			||||||
    ) {
 | 
					 | 
				
			||||||
      const action = this.actions[name];
 | 
					      const action = this.actions[name];
 | 
				
			||||||
      const PanelComponent = action.PanelComponent!;
 | 
					      const PanelComponent = action.PanelComponent!;
 | 
				
			||||||
      const elements = this.getElementsIncludingDeleted();
 | 
					 | 
				
			||||||
      const appState = this.getAppState();
 | 
					 | 
				
			||||||
      const updateData = (formState?: any) => {
 | 
					      const updateData = (formState?: any) => {
 | 
				
			||||||
        trackAction(action, "ui", appState, elements, this.app, formState);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        this.updater(
 | 
					        this.updater(
 | 
				
			||||||
          action.perform(
 | 
					          action.perform(
 | 
				
			||||||
            this.getElementsIncludingDeleted(),
 | 
					            this.getElementsIncludingDeleted(),
 | 
				
			||||||
@@ -167,8 +121,8 @@ export class ActionManager {
 | 
				
			|||||||
          elements={this.getElementsIncludingDeleted()}
 | 
					          elements={this.getElementsIncludingDeleted()}
 | 
				
			||||||
          appState={this.getAppState()}
 | 
					          appState={this.getAppState()}
 | 
				
			||||||
          updateData={updateData}
 | 
					          updateData={updateData}
 | 
				
			||||||
 | 
					          id={id}
 | 
				
			||||||
          appProps={this.app.props}
 | 
					          appProps={this.app.props}
 | 
				
			||||||
          data={data}
 | 
					 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,9 +2,7 @@ import { Action } from "./types";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export let actions: readonly Action[] = [];
 | 
					export let actions: readonly Action[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const register = <T extends Action>(action: T) => {
 | 
					export const register = (action: Action): Action => {
 | 
				
			||||||
  actions = actions.concat(action);
 | 
					  actions = actions.concat(action);
 | 
				
			||||||
  return action as T & {
 | 
					  return action;
 | 
				
			||||||
    keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,8 @@
 | 
				
			|||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { isDarwin } from "../keys";
 | 
					import { isDarwin } from "../keys";
 | 
				
			||||||
import { getShortcutKey } from "../utils";
 | 
					import { getShortcutKey } from "../utils";
 | 
				
			||||||
import { ActionName } from "./types";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ShortcutName = SubtypeOf<
 | 
					export type ShortcutName =
 | 
				
			||||||
  ActionName,
 | 
					 | 
				
			||||||
  | "cut"
 | 
					  | "cut"
 | 
				
			||||||
  | "copy"
 | 
					  | "copy"
 | 
				
			||||||
  | "paste"
 | 
					  | "paste"
 | 
				
			||||||
@@ -25,12 +23,7 @@ export type ShortcutName = SubtypeOf<
 | 
				
			|||||||
  | "zenMode"
 | 
					  | "zenMode"
 | 
				
			||||||
  | "stats"
 | 
					  | "stats"
 | 
				
			||||||
  | "addToLibrary"
 | 
					  | "addToLibrary"
 | 
				
			||||||
  | "viewMode"
 | 
					  | "viewMode";
 | 
				
			||||||
  | "flipHorizontal"
 | 
					 | 
				
			||||||
  | "flipVertical"
 | 
					 | 
				
			||||||
  | "hyperlink"
 | 
					 | 
				
			||||||
  | "toggleLock"
 | 
					 | 
				
			||||||
>;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const shortcutMap: Record<ShortcutName, string[]> = {
 | 
					const shortcutMap: Record<ShortcutName, string[]> = {
 | 
				
			||||||
  cut: [getShortcutKey("CtrlOrCmd+X")],
 | 
					  cut: [getShortcutKey("CtrlOrCmd+X")],
 | 
				
			||||||
@@ -62,17 +55,13 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
				
			|||||||
  ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
 | 
					  ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
 | 
				
			||||||
  gridMode: [getShortcutKey("CtrlOrCmd+'")],
 | 
					  gridMode: [getShortcutKey("CtrlOrCmd+'")],
 | 
				
			||||||
  zenMode: [getShortcutKey("Alt+Z")],
 | 
					  zenMode: [getShortcutKey("Alt+Z")],
 | 
				
			||||||
  stats: [getShortcutKey("Alt+/")],
 | 
					  stats: [],
 | 
				
			||||||
  addToLibrary: [],
 | 
					  addToLibrary: [],
 | 
				
			||||||
  flipHorizontal: [getShortcutKey("Shift+H")],
 | 
					 | 
				
			||||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
					 | 
				
			||||||
  viewMode: [getShortcutKey("Alt+R")],
 | 
					  viewMode: [getShortcutKey("Alt+R")],
 | 
				
			||||||
  hyperlink: [getShortcutKey("CtrlOrCmd+K")],
 | 
					 | 
				
			||||||
  toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
					export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
				
			||||||
  const shortcuts = shortcutMap[name];
 | 
					  const shortcuts = shortcutMap[name];
 | 
				
			||||||
  // if multiple shortcuts available, take the first one
 | 
					  // if multiple shortcuts availiable, take the first one
 | 
				
			||||||
  return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
 | 
					  return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,27 +1,14 @@
 | 
				
			|||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import {
 | 
					import { AppState, ExcalidrawProps } from "../types";
 | 
				
			||||||
  AppClassProperties,
 | 
					 | 
				
			||||||
  AppState,
 | 
					 | 
				
			||||||
  ExcalidrawProps,
 | 
					 | 
				
			||||||
  BinaryFiles,
 | 
					 | 
				
			||||||
} from "../types";
 | 
					 | 
				
			||||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** if false, the action should be prevented */
 | 
					/** if false, the action should be prevented */
 | 
				
			||||||
export type ActionResult =
 | 
					export type ActionResult =
 | 
				
			||||||
  | {
 | 
					  | {
 | 
				
			||||||
      elements?: readonly ExcalidrawElement[] | null;
 | 
					      elements?: readonly ExcalidrawElement[] | null;
 | 
				
			||||||
      appState?: MarkOptional<
 | 
					      appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
 | 
				
			||||||
        AppState,
 | 
					 | 
				
			||||||
        "offsetTop" | "offsetLeft" | "width" | "height"
 | 
					 | 
				
			||||||
      > | null;
 | 
					 | 
				
			||||||
      files?: BinaryFiles | null;
 | 
					 | 
				
			||||||
      commitToHistory: boolean;
 | 
					      commitToHistory: boolean;
 | 
				
			||||||
      syncHistory?: boolean;
 | 
					      syncHistory?: boolean;
 | 
				
			||||||
      replaceFiles?: boolean;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  | false;
 | 
					  | false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -29,7 +16,7 @@ type ActionFn = (
 | 
				
			|||||||
  elements: readonly ExcalidrawElement[],
 | 
					  elements: readonly ExcalidrawElement[],
 | 
				
			||||||
  appState: Readonly<AppState>,
 | 
					  appState: Readonly<AppState>,
 | 
				
			||||||
  formData: any,
 | 
					  formData: any,
 | 
				
			||||||
  app: AppClassProperties,
 | 
					  app: { canvas: HTMLCanvasElement | null },
 | 
				
			||||||
) => ActionResult | Promise<ActionResult>;
 | 
					) => ActionResult | Promise<ActionResult>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type UpdaterFn = (res: ActionResult) => void;
 | 
					export type UpdaterFn = (res: ActionResult) => void;
 | 
				
			||||||
@@ -41,7 +28,6 @@ export type ActionName =
 | 
				
			|||||||
  | "paste"
 | 
					  | "paste"
 | 
				
			||||||
  | "copyAsPng"
 | 
					  | "copyAsPng"
 | 
				
			||||||
  | "copyAsSvg"
 | 
					  | "copyAsSvg"
 | 
				
			||||||
  | "copyText"
 | 
					 | 
				
			||||||
  | "sendBackward"
 | 
					  | "sendBackward"
 | 
				
			||||||
  | "bringForward"
 | 
					  | "bringForward"
 | 
				
			||||||
  | "sendToBack"
 | 
					  | "sendToBack"
 | 
				
			||||||
@@ -56,7 +42,6 @@ export type ActionName =
 | 
				
			|||||||
  | "changeBackgroundColor"
 | 
					  | "changeBackgroundColor"
 | 
				
			||||||
  | "changeFillStyle"
 | 
					  | "changeFillStyle"
 | 
				
			||||||
  | "changeStrokeWidth"
 | 
					  | "changeStrokeWidth"
 | 
				
			||||||
  | "changeStrokeShape"
 | 
					 | 
				
			||||||
  | "changeSloppiness"
 | 
					  | "changeSloppiness"
 | 
				
			||||||
  | "changeStrokeStyle"
 | 
					  | "changeStrokeStyle"
 | 
				
			||||||
  | "changeArrowhead"
 | 
					  | "changeArrowhead"
 | 
				
			||||||
@@ -70,9 +55,9 @@ export type ActionName =
 | 
				
			|||||||
  | "changeProjectName"
 | 
					  | "changeProjectName"
 | 
				
			||||||
  | "changeExportBackground"
 | 
					  | "changeExportBackground"
 | 
				
			||||||
  | "changeExportEmbedScene"
 | 
					  | "changeExportEmbedScene"
 | 
				
			||||||
  | "changeExportScale"
 | 
					  | "changeShouldAddWatermark"
 | 
				
			||||||
  | "saveToActiveFile"
 | 
					  | "saveScene"
 | 
				
			||||||
  | "saveFileToDisk"
 | 
					  | "saveAsScene"
 | 
				
			||||||
  | "loadScene"
 | 
					  | "loadScene"
 | 
				
			||||||
  | "duplicateSelection"
 | 
					  | "duplicateSelection"
 | 
				
			||||||
  | "deleteSelectedElements"
 | 
					  | "deleteSelectedElements"
 | 
				
			||||||
@@ -85,7 +70,6 @@ export type ActionName =
 | 
				
			|||||||
  | "zoomToSelection"
 | 
					  | "zoomToSelection"
 | 
				
			||||||
  | "changeFontFamily"
 | 
					  | "changeFontFamily"
 | 
				
			||||||
  | "changeTextAlign"
 | 
					  | "changeTextAlign"
 | 
				
			||||||
  | "changeVerticalAlign"
 | 
					 | 
				
			||||||
  | "toggleFullScreen"
 | 
					  | "toggleFullScreen"
 | 
				
			||||||
  | "toggleShortcuts"
 | 
					  | "toggleShortcuts"
 | 
				
			||||||
  | "group"
 | 
					  | "group"
 | 
				
			||||||
@@ -101,65 +85,36 @@ export type ActionName =
 | 
				
			|||||||
  | "alignHorizontallyCentered"
 | 
					  | "alignHorizontallyCentered"
 | 
				
			||||||
  | "distributeHorizontally"
 | 
					  | "distributeHorizontally"
 | 
				
			||||||
  | "distributeVertically"
 | 
					  | "distributeVertically"
 | 
				
			||||||
  | "flipHorizontal"
 | 
					 | 
				
			||||||
  | "flipVertical"
 | 
					 | 
				
			||||||
  | "viewMode"
 | 
					  | "viewMode"
 | 
				
			||||||
  | "exportWithDarkMode"
 | 
					  | "exportWithDarkMode";
 | 
				
			||||||
  | "toggleTheme"
 | 
					 | 
				
			||||||
  | "increaseFontSize"
 | 
					 | 
				
			||||||
  | "decreaseFontSize"
 | 
					 | 
				
			||||||
  | "unbindText"
 | 
					 | 
				
			||||||
  | "hyperlink"
 | 
					 | 
				
			||||||
  | "eraser"
 | 
					 | 
				
			||||||
  | "bindText"
 | 
					 | 
				
			||||||
  | "toggleLock";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type PanelComponentProps = {
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[];
 | 
					 | 
				
			||||||
  appState: AppState;
 | 
					 | 
				
			||||||
  updateData: (formData?: any) => void;
 | 
					 | 
				
			||||||
  appProps: ExcalidrawProps;
 | 
					 | 
				
			||||||
  data?: Partial<{ id: string; size: ToolButtonSize }>;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface Action {
 | 
					export interface Action {
 | 
				
			||||||
  name: ActionName;
 | 
					  name: ActionName;
 | 
				
			||||||
  PanelComponent?: React.FC<PanelComponentProps>;
 | 
					  PanelComponent?: React.FC<{
 | 
				
			||||||
 | 
					    elements: readonly ExcalidrawElement[];
 | 
				
			||||||
 | 
					    appState: AppState;
 | 
				
			||||||
 | 
					    updateData: (formData?: any) => void;
 | 
				
			||||||
 | 
					    appProps: ExcalidrawProps;
 | 
				
			||||||
 | 
					    id?: string;
 | 
				
			||||||
 | 
					  }>;
 | 
				
			||||||
  perform: ActionFn;
 | 
					  perform: ActionFn;
 | 
				
			||||||
  keyPriority?: number;
 | 
					  keyPriority?: number;
 | 
				
			||||||
  keyTest?: (
 | 
					  keyTest?: (
 | 
				
			||||||
    event: React.KeyboardEvent | KeyboardEvent,
 | 
					    event: KeyboardEvent,
 | 
				
			||||||
    appState: AppState,
 | 
					    appState: AppState,
 | 
				
			||||||
    elements: readonly ExcalidrawElement[],
 | 
					    elements: readonly ExcalidrawElement[],
 | 
				
			||||||
  ) => boolean;
 | 
					  ) => boolean;
 | 
				
			||||||
  contextItemLabel?:
 | 
					  contextItemLabel?: string;
 | 
				
			||||||
    | string
 | 
					 | 
				
			||||||
    | ((
 | 
					 | 
				
			||||||
        elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
        appState: Readonly<AppState>,
 | 
					 | 
				
			||||||
      ) => string);
 | 
					 | 
				
			||||||
  contextItemPredicate?: (
 | 
					  contextItemPredicate?: (
 | 
				
			||||||
    elements: readonly ExcalidrawElement[],
 | 
					    elements: readonly ExcalidrawElement[],
 | 
				
			||||||
    appState: AppState,
 | 
					    appState: AppState,
 | 
				
			||||||
  ) => boolean;
 | 
					  ) => boolean;
 | 
				
			||||||
  checked?: (appState: Readonly<AppState>) => boolean;
 | 
					  checked?: (appState: Readonly<AppState>) => boolean;
 | 
				
			||||||
  trackEvent:
 | 
					}
 | 
				
			||||||
    | false
 | 
					
 | 
				
			||||||
    | {
 | 
					export interface ActionsManagerInterface {
 | 
				
			||||||
        category:
 | 
					  actions: Record<ActionName, Action>;
 | 
				
			||||||
          | "toolbar"
 | 
					  registerAction: (action: Action) => void;
 | 
				
			||||||
          | "element"
 | 
					  handleKeyDown: (event: KeyboardEvent) => boolean;
 | 
				
			||||||
          | "canvas"
 | 
					  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
				
			||||||
          | "export"
 | 
					 | 
				
			||||||
          | "history"
 | 
					 | 
				
			||||||
          | "menu"
 | 
					 | 
				
			||||||
          | "collab"
 | 
					 | 
				
			||||||
          | "hyperlink";
 | 
					 | 
				
			||||||
        action?: string;
 | 
					 | 
				
			||||||
        predicate?: (
 | 
					 | 
				
			||||||
          appState: Readonly<AppState>,
 | 
					 | 
				
			||||||
          elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
          value: any,
 | 
					 | 
				
			||||||
        ) => boolean;
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								src/align.ts
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								src/align.ts
									
									
									
									
									
								
							@@ -1,7 +1,13 @@
 | 
				
			|||||||
import { ExcalidrawElement } from "./element/types";
 | 
					import { ExcalidrawElement } from "./element/types";
 | 
				
			||||||
import { newElementWith } from "./element/mutateElement";
 | 
					import { newElementWith } from "./element/mutateElement";
 | 
				
			||||||
import { Box, getCommonBoundingBox } from "./element/bounds";
 | 
					import { getCommonBounds } from "./element";
 | 
				
			||||||
import { getMaximumGroups } from "./groups";
 | 
					
 | 
				
			||||||
 | 
					interface Box {
 | 
				
			||||||
 | 
					  minX: number;
 | 
				
			||||||
 | 
					  minY: number;
 | 
				
			||||||
 | 
					  maxX: number;
 | 
				
			||||||
 | 
					  maxY: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface Alignment {
 | 
					export interface Alignment {
 | 
				
			||||||
  position: "start" | "center" | "end";
 | 
					  position: "start" | "center" | "end";
 | 
				
			||||||
@@ -31,6 +37,28 @@ export const alignElements = (
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getMaximumGroups = (
 | 
				
			||||||
 | 
					  elements: ExcalidrawElement[],
 | 
				
			||||||
 | 
					): ExcalidrawElement[][] => {
 | 
				
			||||||
 | 
					  const groups: Map<String, ExcalidrawElement[]> = new Map<
 | 
				
			||||||
 | 
					    String,
 | 
				
			||||||
 | 
					    ExcalidrawElement[]
 | 
				
			||||||
 | 
					  >();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  elements.forEach((element: ExcalidrawElement) => {
 | 
				
			||||||
 | 
					    const groupId =
 | 
				
			||||||
 | 
					      element.groupIds.length === 0
 | 
				
			||||||
 | 
					        ? element.id
 | 
				
			||||||
 | 
					        : element.groupIds[element.groupIds.length - 1];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const currentGroupMembers = groups.get(groupId) || [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    groups.set(groupId, [...currentGroupMembers, element]);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return Array.from(groups.values());
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const calculateTranslation = (
 | 
					const calculateTranslation = (
 | 
				
			||||||
  group: ExcalidrawElement[],
 | 
					  group: ExcalidrawElement[],
 | 
				
			||||||
  selectionBoundingBox: Box,
 | 
					  selectionBoundingBox: Box,
 | 
				
			||||||
@@ -60,3 +88,8 @@ const calculateTranslation = (
 | 
				
			|||||||
      (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
 | 
					      (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
 | 
				
			||||||
 | 
					  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
 | 
				
			||||||
 | 
					  return { minX, minY, maxX, maxY };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,20 +3,16 @@ export const trackEvent =
 | 
				
			|||||||
  process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
 | 
					  process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
 | 
				
			||||||
  typeof window !== "undefined" &&
 | 
					  typeof window !== "undefined" &&
 | 
				
			||||||
  window.gtag
 | 
					  window.gtag
 | 
				
			||||||
    ? (category: string, action: string, label?: string, value?: number) => {
 | 
					    ? (category: string, name: string, label?: string, value?: number) => {
 | 
				
			||||||
        try {
 | 
					        window.gtag("event", name, {
 | 
				
			||||||
          window.gtag("event", action, {
 | 
					          event_category: category,
 | 
				
			||||||
            event_category: category,
 | 
					          event_label: label,
 | 
				
			||||||
            event_label: label,
 | 
					          value,
 | 
				
			||||||
            value,
 | 
					        });
 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
        } catch (error) {
 | 
					 | 
				
			||||||
          console.error("error logging to ga", error);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
 | 
					    : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
 | 
				
			||||||
    ? (category: string, action: string, label?: string, value?: number) => {}
 | 
					    ? (category: string, name: string, label?: string, value?: number) => {}
 | 
				
			||||||
    : (category: string, action: string, label?: string, value?: number) => {
 | 
					    : (category: string, name: string, label?: string, value?: number) => {
 | 
				
			||||||
        // Uncomment the next line to track locally
 | 
					        // Uncomment the next line to track locally
 | 
				
			||||||
        // console.log("Track Event", { category, action, label, value });
 | 
					        // console.info("Track Event", category, name, label, value);
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										193
									
								
								src/appState.ts
									
									
									
									
									
								
							
							
						
						
									
										193
									
								
								src/appState.ts
									
									
									
									
									
								
							@@ -3,23 +3,17 @@ import {
 | 
				
			|||||||
  DEFAULT_FONT_FAMILY,
 | 
					  DEFAULT_FONT_FAMILY,
 | 
				
			||||||
  DEFAULT_FONT_SIZE,
 | 
					  DEFAULT_FONT_SIZE,
 | 
				
			||||||
  DEFAULT_TEXT_ALIGN,
 | 
					  DEFAULT_TEXT_ALIGN,
 | 
				
			||||||
  EXPORT_SCALES,
 | 
					 | 
				
			||||||
  THEME,
 | 
					 | 
				
			||||||
} from "./constants";
 | 
					} from "./constants";
 | 
				
			||||||
import { t } from "./i18n";
 | 
					import { t } from "./i18n";
 | 
				
			||||||
import { AppState, NormalizedZoomValue } from "./types";
 | 
					import { AppState, NormalizedZoomValue } from "./types";
 | 
				
			||||||
import { getDateTime } from "./utils";
 | 
					import { getDateTime } from "./utils";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
 | 
					 | 
				
			||||||
  ? devicePixelRatio
 | 
					 | 
				
			||||||
  : 1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const getDefaultAppState = (): Omit<
 | 
					export const getDefaultAppState = (): Omit<
 | 
				
			||||||
  AppState,
 | 
					  AppState,
 | 
				
			||||||
  "offsetTop" | "offsetLeft" | "width" | "height"
 | 
					  "offsetTop" | "offsetLeft"
 | 
				
			||||||
> => {
 | 
					> => {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    theme: THEME.LIGHT,
 | 
					    theme: "light",
 | 
				
			||||||
    collaborators: new Map(),
 | 
					    collaborators: new Map(),
 | 
				
			||||||
    currentChartType: "bar",
 | 
					    currentChartType: "bar",
 | 
				
			||||||
    currentItemBackgroundColor: "transparent",
 | 
					    currentItemBackgroundColor: "transparent",
 | 
				
			||||||
@@ -41,20 +35,15 @@ export const getDefaultAppState = (): Omit<
 | 
				
			|||||||
    editingElement: null,
 | 
					    editingElement: null,
 | 
				
			||||||
    editingGroupId: null,
 | 
					    editingGroupId: null,
 | 
				
			||||||
    editingLinearElement: null,
 | 
					    editingLinearElement: null,
 | 
				
			||||||
    activeTool: {
 | 
					    elementLocked: false,
 | 
				
			||||||
      type: "selection",
 | 
					    elementType: "selection",
 | 
				
			||||||
      locked: false,
 | 
					 | 
				
			||||||
      lastActiveToolBeforeEraser: null,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    penMode: false,
 | 
					 | 
				
			||||||
    penDetected: false,
 | 
					 | 
				
			||||||
    errorMessage: null,
 | 
					    errorMessage: null,
 | 
				
			||||||
    exportBackground: true,
 | 
					    exportBackground: true,
 | 
				
			||||||
    exportScale: defaultExportScale,
 | 
					 | 
				
			||||||
    exportEmbedScene: false,
 | 
					    exportEmbedScene: false,
 | 
				
			||||||
    exportWithDarkMode: false,
 | 
					    exportWithDarkMode: false,
 | 
				
			||||||
    fileHandle: null,
 | 
					    fileHandle: null,
 | 
				
			||||||
    gridSize: null,
 | 
					    gridSize: null,
 | 
				
			||||||
 | 
					    height: window.innerHeight,
 | 
				
			||||||
    isBindingEnabled: true,
 | 
					    isBindingEnabled: true,
 | 
				
			||||||
    isLibraryOpen: false,
 | 
					    isLibraryOpen: false,
 | 
				
			||||||
    isLoading: false,
 | 
					    isLoading: false,
 | 
				
			||||||
@@ -64,7 +53,6 @@ export const getDefaultAppState = (): Omit<
 | 
				
			|||||||
    multiElement: null,
 | 
					    multiElement: null,
 | 
				
			||||||
    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
					    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
				
			||||||
    openMenu: null,
 | 
					    openMenu: null,
 | 
				
			||||||
    openPopup: null,
 | 
					 | 
				
			||||||
    pasteDialog: { shown: false, data: null },
 | 
					    pasteDialog: { shown: false, data: null },
 | 
				
			||||||
    previousSelectedElementIds: {},
 | 
					    previousSelectedElementIds: {},
 | 
				
			||||||
    resizingElement: null,
 | 
					    resizingElement: null,
 | 
				
			||||||
@@ -74,6 +62,7 @@ export const getDefaultAppState = (): Omit<
 | 
				
			|||||||
    selectedElementIds: {},
 | 
					    selectedElementIds: {},
 | 
				
			||||||
    selectedGroupIds: {},
 | 
					    selectedGroupIds: {},
 | 
				
			||||||
    selectionElement: null,
 | 
					    selectionElement: null,
 | 
				
			||||||
 | 
					    shouldAddWatermark: false,
 | 
				
			||||||
    shouldCacheIgnoreZoom: false,
 | 
					    shouldCacheIgnoreZoom: false,
 | 
				
			||||||
    showHelpDialog: false,
 | 
					    showHelpDialog: false,
 | 
				
			||||||
    showStats: false,
 | 
					    showStats: false,
 | 
				
			||||||
@@ -81,13 +70,10 @@ export const getDefaultAppState = (): Omit<
 | 
				
			|||||||
    suggestedBindings: [],
 | 
					    suggestedBindings: [],
 | 
				
			||||||
    toastMessage: null,
 | 
					    toastMessage: null,
 | 
				
			||||||
    viewBackgroundColor: oc.white,
 | 
					    viewBackgroundColor: oc.white,
 | 
				
			||||||
 | 
					    width: window.innerWidth,
 | 
				
			||||||
    zenModeEnabled: false,
 | 
					    zenModeEnabled: false,
 | 
				
			||||||
    zoom: {
 | 
					    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
 | 
				
			||||||
      value: 1 as NormalizedZoomValue,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    viewModeEnabled: false,
 | 
					    viewModeEnabled: false,
 | 
				
			||||||
    pendingImageElement: null,
 | 
					 | 
				
			||||||
    showHyperlinkPopup: false,
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -101,88 +87,77 @@ const APP_STATE_STORAGE_CONF = (<
 | 
				
			|||||||
    browser: boolean;
 | 
					    browser: boolean;
 | 
				
			||||||
    /** whether to keep when exporting to file/database */
 | 
					    /** whether to keep when exporting to file/database */
 | 
				
			||||||
    export: boolean;
 | 
					    export: boolean;
 | 
				
			||||||
    /** server (shareLink/collab/...) */
 | 
					 | 
				
			||||||
    server: boolean;
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  T extends Record<keyof AppState, Values>,
 | 
					  T extends Record<keyof AppState, Values>
 | 
				
			||||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
 | 
					>(
 | 
				
			||||||
  config)({
 | 
					  config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
 | 
				
			||||||
  theme: { browser: true, export: false, server: false },
 | 
					) => config)({
 | 
				
			||||||
  collaborators: { browser: false, export: false, server: false },
 | 
					  theme: { browser: true, export: false },
 | 
				
			||||||
  currentChartType: { browser: true, export: false, server: false },
 | 
					  collaborators: { browser: false, export: false },
 | 
				
			||||||
  currentItemBackgroundColor: { browser: true, export: false, server: false },
 | 
					  currentChartType: { browser: true, export: false },
 | 
				
			||||||
  currentItemEndArrowhead: { browser: true, export: false, server: false },
 | 
					  currentItemBackgroundColor: { browser: true, export: false },
 | 
				
			||||||
  currentItemFillStyle: { browser: true, export: false, server: false },
 | 
					  currentItemEndArrowhead: { browser: true, export: false },
 | 
				
			||||||
  currentItemFontFamily: { browser: true, export: false, server: false },
 | 
					  currentItemFillStyle: { browser: true, export: false },
 | 
				
			||||||
  currentItemFontSize: { browser: true, export: false, server: false },
 | 
					  currentItemFontFamily: { browser: true, export: false },
 | 
				
			||||||
  currentItemLinearStrokeSharpness: {
 | 
					  currentItemFontSize: { browser: true, export: false },
 | 
				
			||||||
    browser: true,
 | 
					  currentItemLinearStrokeSharpness: { browser: true, export: false },
 | 
				
			||||||
    export: false,
 | 
					  currentItemOpacity: { browser: true, export: false },
 | 
				
			||||||
    server: false,
 | 
					  currentItemRoughness: { browser: true, export: false },
 | 
				
			||||||
  },
 | 
					  currentItemStartArrowhead: { browser: true, export: false },
 | 
				
			||||||
  currentItemOpacity: { browser: true, export: false, server: false },
 | 
					  currentItemStrokeColor: { browser: true, export: false },
 | 
				
			||||||
  currentItemRoughness: { browser: true, export: false, server: false },
 | 
					  currentItemStrokeSharpness: { browser: true, export: false },
 | 
				
			||||||
  currentItemStartArrowhead: { browser: true, export: false, server: false },
 | 
					  currentItemStrokeStyle: { browser: true, export: false },
 | 
				
			||||||
  currentItemStrokeColor: { browser: true, export: false, server: false },
 | 
					  currentItemStrokeWidth: { browser: true, export: false },
 | 
				
			||||||
  currentItemStrokeSharpness: { browser: true, export: false, server: false },
 | 
					  currentItemTextAlign: { browser: true, export: false },
 | 
				
			||||||
  currentItemStrokeStyle: { browser: true, export: false, server: false },
 | 
					  cursorButton: { browser: true, export: false },
 | 
				
			||||||
  currentItemStrokeWidth: { browser: true, export: false, server: false },
 | 
					  draggingElement: { browser: false, export: false },
 | 
				
			||||||
  currentItemTextAlign: { browser: true, export: false, server: false },
 | 
					  editingElement: { browser: false, export: false },
 | 
				
			||||||
  cursorButton: { browser: true, export: false, server: false },
 | 
					  editingGroupId: { browser: true, export: false },
 | 
				
			||||||
  draggingElement: { browser: false, export: false, server: false },
 | 
					  editingLinearElement: { browser: false, export: false },
 | 
				
			||||||
  editingElement: { browser: false, export: false, server: false },
 | 
					  elementLocked: { browser: true, export: false },
 | 
				
			||||||
  editingGroupId: { browser: true, export: false, server: false },
 | 
					  elementType: { browser: true, export: false },
 | 
				
			||||||
  editingLinearElement: { browser: false, export: false, server: false },
 | 
					  errorMessage: { browser: false, export: false },
 | 
				
			||||||
  activeTool: { browser: true, export: false, server: false },
 | 
					  exportBackground: { browser: true, export: false },
 | 
				
			||||||
  penMode: { browser: true, export: false, server: false },
 | 
					  exportEmbedScene: { browser: true, export: false },
 | 
				
			||||||
  penDetected: { browser: true, export: false, server: false },
 | 
					  exportWithDarkMode: { browser: true, export: false },
 | 
				
			||||||
  errorMessage: { browser: false, export: false, server: false },
 | 
					  fileHandle: { browser: false, export: false },
 | 
				
			||||||
  exportBackground: { browser: true, export: false, server: false },
 | 
					  gridSize: { browser: true, export: true },
 | 
				
			||||||
  exportEmbedScene: { browser: true, export: false, server: false },
 | 
					  height: { browser: false, export: false },
 | 
				
			||||||
  exportScale: { browser: true, export: false, server: false },
 | 
					  isBindingEnabled: { browser: false, export: false },
 | 
				
			||||||
  exportWithDarkMode: { browser: true, export: false, server: false },
 | 
					  isLibraryOpen: { browser: false, export: false },
 | 
				
			||||||
  fileHandle: { browser: false, export: false, server: false },
 | 
					  isLoading: { browser: false, export: false },
 | 
				
			||||||
  gridSize: { browser: true, export: true, server: true },
 | 
					  isResizing: { browser: false, export: false },
 | 
				
			||||||
  height: { browser: false, export: false, server: false },
 | 
					  isRotating: { browser: false, export: false },
 | 
				
			||||||
  isBindingEnabled: { browser: false, export: false, server: false },
 | 
					  lastPointerDownWith: { browser: true, export: false },
 | 
				
			||||||
  isLibraryOpen: { browser: false, export: false, server: false },
 | 
					  multiElement: { browser: false, export: false },
 | 
				
			||||||
  isLoading: { browser: false, export: false, server: false },
 | 
					  name: { browser: true, export: false },
 | 
				
			||||||
  isResizing: { browser: false, export: false, server: false },
 | 
					  offsetLeft: { browser: false, export: false },
 | 
				
			||||||
  isRotating: { browser: false, export: false, server: false },
 | 
					  offsetTop: { browser: false, export: false },
 | 
				
			||||||
  lastPointerDownWith: { browser: true, export: false, server: false },
 | 
					  openMenu: { browser: true, export: false },
 | 
				
			||||||
  multiElement: { browser: false, export: false, server: false },
 | 
					  pasteDialog: { browser: false, export: false },
 | 
				
			||||||
  name: { browser: true, export: false, server: false },
 | 
					  previousSelectedElementIds: { browser: true, export: false },
 | 
				
			||||||
  offsetLeft: { browser: false, export: false, server: false },
 | 
					  resizingElement: { browser: false, export: false },
 | 
				
			||||||
  offsetTop: { browser: false, export: false, server: false },
 | 
					  scrolledOutside: { browser: true, export: false },
 | 
				
			||||||
  openMenu: { browser: true, export: false, server: false },
 | 
					  scrollX: { browser: true, export: false },
 | 
				
			||||||
  openPopup: { browser: false, export: false, server: false },
 | 
					  scrollY: { browser: true, export: false },
 | 
				
			||||||
  pasteDialog: { browser: false, export: false, server: false },
 | 
					  selectedElementIds: { browser: true, export: false },
 | 
				
			||||||
  previousSelectedElementIds: { browser: true, export: false, server: false },
 | 
					  selectedGroupIds: { browser: true, export: false },
 | 
				
			||||||
  resizingElement: { browser: false, export: false, server: false },
 | 
					  selectionElement: { browser: false, export: false },
 | 
				
			||||||
  scrolledOutside: { browser: true, export: false, server: false },
 | 
					  shouldAddWatermark: { browser: true, export: false },
 | 
				
			||||||
  scrollX: { browser: true, export: false, server: false },
 | 
					  shouldCacheIgnoreZoom: { browser: true, export: false },
 | 
				
			||||||
  scrollY: { browser: true, export: false, server: false },
 | 
					  showHelpDialog: { browser: false, export: false },
 | 
				
			||||||
  selectedElementIds: { browser: true, export: false, server: false },
 | 
					  showStats: { browser: true, export: false },
 | 
				
			||||||
  selectedGroupIds: { browser: true, export: false, server: false },
 | 
					  startBoundElement: { browser: false, export: false },
 | 
				
			||||||
  selectionElement: { browser: false, export: false, server: false },
 | 
					  suggestedBindings: { browser: false, export: false },
 | 
				
			||||||
  shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
 | 
					  toastMessage: { browser: false, export: false },
 | 
				
			||||||
  showHelpDialog: { browser: false, export: false, server: false },
 | 
					  viewBackgroundColor: { browser: true, export: true },
 | 
				
			||||||
  showStats: { browser: true, export: false, server: false },
 | 
					  width: { browser: false, export: false },
 | 
				
			||||||
  startBoundElement: { browser: false, export: false, server: false },
 | 
					  zenModeEnabled: { browser: true, export: false },
 | 
				
			||||||
  suggestedBindings: { browser: false, export: false, server: false },
 | 
					  zoom: { browser: true, export: false },
 | 
				
			||||||
  toastMessage: { browser: false, export: false, server: false },
 | 
					  viewModeEnabled: { browser: false, export: false },
 | 
				
			||||||
  viewBackgroundColor: { browser: true, export: true, server: true },
 | 
					 | 
				
			||||||
  width: { browser: false, export: false, server: false },
 | 
					 | 
				
			||||||
  zenModeEnabled: { browser: true, export: false, server: false },
 | 
					 | 
				
			||||||
  zoom: { browser: true, export: false, server: false },
 | 
					 | 
				
			||||||
  viewModeEnabled: { browser: false, export: false, server: false },
 | 
					 | 
				
			||||||
  pendingImageElement: { browser: false, export: false, server: false },
 | 
					 | 
				
			||||||
  showHyperlinkPopup: { browser: false, export: false, server: false },
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const _clearAppStateForStorage = <
 | 
					const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
 | 
				
			||||||
  ExportType extends "export" | "browser" | "server",
 | 
					 | 
				
			||||||
>(
 | 
					 | 
				
			||||||
  appState: Partial<AppState>,
 | 
					  appState: Partial<AppState>,
 | 
				
			||||||
  exportType: ExportType,
 | 
					  exportType: ExportType,
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
@@ -195,10 +170,8 @@ const _clearAppStateForStorage = <
 | 
				
			|||||||
  for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
 | 
					  for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
 | 
				
			||||||
    const propConfig = APP_STATE_STORAGE_CONF[key];
 | 
					    const propConfig = APP_STATE_STORAGE_CONF[key];
 | 
				
			||||||
    if (propConfig?.[exportType]) {
 | 
					    if (propConfig?.[exportType]) {
 | 
				
			||||||
      const nextValue = appState[key];
 | 
					      // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
 | 
				
			||||||
 | 
					      stateForExport[key] = appState[key];
 | 
				
			||||||
      // https://github.com/microsoft/TypeScript/issues/31445
 | 
					 | 
				
			||||||
      (stateForExport as any)[key] = nextValue;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return stateForExport;
 | 
					  return stateForExport;
 | 
				
			||||||
@@ -211,13 +184,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
 | 
				
			|||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
 | 
					export const cleanAppStateForExport = (appState: Partial<AppState>) => {
 | 
				
			||||||
  return _clearAppStateForStorage(appState, "export");
 | 
					  return _clearAppStateForStorage(appState, "export");
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					 | 
				
			||||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
 | 
					 | 
				
			||||||
  return _clearAppStateForStorage(appState, "server");
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const isEraserActive = ({
 | 
					 | 
				
			||||||
  activeTool,
 | 
					 | 
				
			||||||
}: {
 | 
					 | 
				
			||||||
  activeTool: AppState["activeTool"];
 | 
					 | 
				
			||||||
}) => activeTool.type === "eraser";
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,5 @@
 | 
				
			|||||||
import colors from "./colors";
 | 
					import colors from "./colors";
 | 
				
			||||||
import {
 | 
					import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
 | 
				
			||||||
  DEFAULT_FONT_FAMILY,
 | 
					 | 
				
			||||||
  DEFAULT_FONT_SIZE,
 | 
					 | 
				
			||||||
  ENV,
 | 
					 | 
				
			||||||
  VERTICAL_ALIGN,
 | 
					 | 
				
			||||||
} from "./constants";
 | 
					 | 
				
			||||||
import { newElement, newLinearElement, newTextElement } from "./element";
 | 
					import { newElement, newLinearElement, newTextElement } from "./element";
 | 
				
			||||||
import { NonDeletedExcalidrawElement } from "./element/types";
 | 
					import { NonDeletedExcalidrawElement } from "./element/types";
 | 
				
			||||||
import { randomId } from "./random";
 | 
					import { randomId } from "./random";
 | 
				
			||||||
@@ -108,7 +103,7 @@ const transposeCells = (cells: string[][]) => {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
 | 
					export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
 | 
				
			||||||
  // Copy/paste from excel, spreadsheets, tsv, csv.
 | 
					  // Copy/paste from excel, spreadhseets, tsv, csv.
 | 
				
			||||||
  // For now we only accept 2 columns with an optional header
 | 
					  // For now we only accept 2 columns with an optional header
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Check for tab separated values
 | 
					  // Check for tab separated values
 | 
				
			||||||
@@ -166,8 +161,7 @@ const commonProps = {
 | 
				
			|||||||
  strokeSharpness: "sharp",
 | 
					  strokeSharpness: "sharp",
 | 
				
			||||||
  strokeStyle: "solid",
 | 
					  strokeStyle: "solid",
 | 
				
			||||||
  strokeWidth: 1,
 | 
					  strokeWidth: 1,
 | 
				
			||||||
  verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
					  verticalAlign: "middle",
 | 
				
			||||||
  locked: false,
 | 
					 | 
				
			||||||
} as const;
 | 
					} as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
 | 
					const getChartDimentions = (spreadsheet: Spreadsheet) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,27 +2,18 @@ import {
 | 
				
			|||||||
  ExcalidrawElement,
 | 
					  ExcalidrawElement,
 | 
				
			||||||
  NonDeletedExcalidrawElement,
 | 
					  NonDeletedExcalidrawElement,
 | 
				
			||||||
} from "./element/types";
 | 
					} from "./element/types";
 | 
				
			||||||
import { AppState, BinaryFiles } from "./types";
 | 
					import { getSelectedElements } from "./scene";
 | 
				
			||||||
 | 
					import { AppState } from "./types";
 | 
				
			||||||
import { SVG_EXPORT_TAG } from "./scene/export";
 | 
					import { SVG_EXPORT_TAG } from "./scene/export";
 | 
				
			||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
					import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
				
			||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 | 
					import { canvasToBlob } from "./data/blob";
 | 
				
			||||||
import { isInitializedImageElement } from "./element/typeChecks";
 | 
					import { EXPORT_DATA_TYPES } from "./constants";
 | 
				
			||||||
import { isPromiseLike } from "./utils";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ElementsClipboard = {
 | 
					type ElementsClipboard = {
 | 
				
			||||||
  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
					  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
				
			||||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
					  elements: ExcalidrawElement[];
 | 
				
			||||||
  files: BinaryFiles | undefined;
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface ClipboardData {
 | 
					 | 
				
			||||||
  spreadsheet?: Spreadsheet;
 | 
					 | 
				
			||||||
  elements?: readonly ExcalidrawElement[];
 | 
					 | 
				
			||||||
  files?: BinaryFiles;
 | 
					 | 
				
			||||||
  text?: string;
 | 
					 | 
				
			||||||
  errorMessage?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
let CLIPBOARD = "";
 | 
					let CLIPBOARD = "";
 | 
				
			||||||
let PREFER_APP_CLIPBOARD = false;
 | 
					let PREFER_APP_CLIPBOARD = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -40,7 +31,7 @@ export const probablySupportsClipboardBlob =
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const clipboardContainsElements = (
 | 
					const clipboardContainsElements = (
 | 
				
			||||||
  contents: any,
 | 
					  contents: any,
 | 
				
			||||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
 | 
					): contents is { elements: ExcalidrawElement[] } => {
 | 
				
			||||||
  if (
 | 
					  if (
 | 
				
			||||||
    [
 | 
					    [
 | 
				
			||||||
      EXPORT_DATA_TYPES.excalidraw,
 | 
					      EXPORT_DATA_TYPES.excalidraw,
 | 
				
			||||||
@@ -56,27 +47,17 @@ const clipboardContainsElements = (
 | 
				
			|||||||
export const copyToClipboard = async (
 | 
					export const copyToClipboard = async (
 | 
				
			||||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
					  elements: readonly NonDeletedExcalidrawElement[],
 | 
				
			||||||
  appState: AppState,
 | 
					  appState: AppState,
 | 
				
			||||||
  files: BinaryFiles | null,
 | 
					 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  // select binded text elements when copying
 | 
					 | 
				
			||||||
  const contents: ElementsClipboard = {
 | 
					  const contents: ElementsClipboard = {
 | 
				
			||||||
    type: EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
					    type: EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
				
			||||||
    elements,
 | 
					    elements: getSelectedElements(elements, appState),
 | 
				
			||||||
    files: files
 | 
					 | 
				
			||||||
      ? elements.reduce((acc, element) => {
 | 
					 | 
				
			||||||
          if (isInitializedImageElement(element) && files[element.fileId]) {
 | 
					 | 
				
			||||||
            acc[element.fileId] = files[element.fileId];
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          return acc;
 | 
					 | 
				
			||||||
        }, {} as BinaryFiles)
 | 
					 | 
				
			||||||
      : undefined,
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  const json = JSON.stringify(contents);
 | 
					  const json = JSON.stringify(contents);
 | 
				
			||||||
  CLIPBOARD = json;
 | 
					  CLIPBOARD = json;
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    PREFER_APP_CLIPBOARD = false;
 | 
					    PREFER_APP_CLIPBOARD = false;
 | 
				
			||||||
    await copyTextToSystemClipboard(json);
 | 
					    await copyTextToSystemClipboard(json);
 | 
				
			||||||
  } catch (error: any) {
 | 
					  } catch (error) {
 | 
				
			||||||
    PREFER_APP_CLIPBOARD = true;
 | 
					    PREFER_APP_CLIPBOARD = true;
 | 
				
			||||||
    console.error(error);
 | 
					    console.error(error);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -89,7 +70,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    return JSON.parse(CLIPBOARD);
 | 
					    return JSON.parse(CLIPBOARD);
 | 
				
			||||||
  } catch (error: any) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error(error);
 | 
					    console.error(error);
 | 
				
			||||||
    return {};
 | 
					    return {};
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -125,11 +106,16 @@ const getSystemClipboard = async (
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Attempts to parse clipboard. Prefers system clipboard.
 | 
					 * Attemps to parse clipboard. Prefers system clipboard.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const parseClipboard = async (
 | 
					export const parseClipboard = async (
 | 
				
			||||||
  event: ClipboardEvent | null,
 | 
					  event: ClipboardEvent | null,
 | 
				
			||||||
): Promise<ClipboardData> => {
 | 
					): Promise<{
 | 
				
			||||||
 | 
					  spreadsheet?: Spreadsheet;
 | 
				
			||||||
 | 
					  elements?: readonly ExcalidrawElement[];
 | 
				
			||||||
 | 
					  text?: string;
 | 
				
			||||||
 | 
					  errorMessage?: string;
 | 
				
			||||||
 | 
					}> => {
 | 
				
			||||||
  const systemClipboard = await getSystemClipboard(event);
 | 
					  const systemClipboard = await getSystemClipboard(event);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // if system clipboard empty, couldn't be resolved, or contains previously
 | 
					  // if system clipboard empty, couldn't be resolved, or contains previously
 | 
				
			||||||
@@ -151,10 +137,7 @@ export const parseClipboard = async (
 | 
				
			|||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const systemClipboardData = JSON.parse(systemClipboard);
 | 
					    const systemClipboardData = JSON.parse(systemClipboard);
 | 
				
			||||||
    if (clipboardContainsElements(systemClipboardData)) {
 | 
					    if (clipboardContainsElements(systemClipboardData)) {
 | 
				
			||||||
      return {
 | 
					      return { elements: systemClipboardData.elements };
 | 
				
			||||||
        elements: systemClipboardData.elements,
 | 
					 | 
				
			||||||
        files: systemClipboardData.files,
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return appClipboardData;
 | 
					    return appClipboardData;
 | 
				
			||||||
  } catch {
 | 
					  } catch {
 | 
				
			||||||
@@ -167,35 +150,11 @@ export const parseClipboard = async (
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
 | 
					export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
 | 
				
			||||||
  let promise;
 | 
					  const blob = await canvasToBlob(canvas);
 | 
				
			||||||
  try {
 | 
					  await navigator.clipboard.write([
 | 
				
			||||||
    // in Safari so far we need to construct the ClipboardItem synchronously
 | 
					    new window.ClipboardItem({ "image/png": blob }),
 | 
				
			||||||
    // (i.e. in the same tick) otherwise browser will complain for lack of
 | 
					  ]);
 | 
				
			||||||
    // user intent. Using a Promise ClipboardItem constructor solves this.
 | 
					 | 
				
			||||||
    // https://bugs.webkit.org/show_bug.cgi?id=222262
 | 
					 | 
				
			||||||
    //
 | 
					 | 
				
			||||||
    // not await so that we can detect whether the thrown error likely relates
 | 
					 | 
				
			||||||
    // to a lack of support for the Promise ClipboardItem constructor
 | 
					 | 
				
			||||||
    promise = navigator.clipboard.write([
 | 
					 | 
				
			||||||
      new window.ClipboardItem({
 | 
					 | 
				
			||||||
        [MIME_TYPES.png]: blob,
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
  } catch (error: any) {
 | 
					 | 
				
			||||||
    // if we're using a Promise ClipboardItem, let's try constructing
 | 
					 | 
				
			||||||
    // with resolution value instead
 | 
					 | 
				
			||||||
    if (isPromiseLike(blob)) {
 | 
					 | 
				
			||||||
      await navigator.clipboard.write([
 | 
					 | 
				
			||||||
        new window.ClipboardItem({
 | 
					 | 
				
			||||||
          [MIME_TYPES.png]: await blob,
 | 
					 | 
				
			||||||
        }),
 | 
					 | 
				
			||||||
      ]);
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      throw error;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  await promise;
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
					export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
				
			||||||
@@ -206,7 +165,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
				
			|||||||
      // not focused
 | 
					      // not focused
 | 
				
			||||||
      await navigator.clipboard.writeText(text || "");
 | 
					      await navigator.clipboard.writeText(text || "");
 | 
				
			||||||
      copied = true;
 | 
					      copied = true;
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error(error);
 | 
					      console.error(error);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -246,7 +205,7 @@ const copyTextViaExecCommand = (text: string) => {
 | 
				
			|||||||
    textarea.setSelectionRange(0, textarea.value.length);
 | 
					    textarea.setSelectionRange(0, textarea.value.length);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    success = document.execCommand("copy");
 | 
					    success = document.execCommand("copy");
 | 
				
			||||||
  } catch (error: any) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error(error);
 | 
					    console.error(error);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,16 +1,15 @@
 | 
				
			|||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { ActionManager } from "../actions/manager";
 | 
					import { ActionManager } from "../actions/manager";
 | 
				
			||||||
import { getNonDeletedElements } from "../element";
 | 
					import { getNonDeletedElements } from "../element";
 | 
				
			||||||
import { ExcalidrawElement, PointerType } from "../element/types";
 | 
					import { ExcalidrawElement } from "../element/types";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { useDeviceType } from "../components/App";
 | 
					import useIsMobile from "../is-mobile";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  canChangeSharpness,
 | 
					  canChangeSharpness,
 | 
				
			||||||
  canHaveArrowheads,
 | 
					  canHaveArrowheads,
 | 
				
			||||||
  getTargetElements,
 | 
					  getTargetElements,
 | 
				
			||||||
  hasBackground,
 | 
					  hasBackground,
 | 
				
			||||||
  hasStrokeStyle,
 | 
					  hasStroke,
 | 
				
			||||||
  hasStrokeWidth,
 | 
					 | 
				
			||||||
  hasText,
 | 
					  hasText,
 | 
				
			||||||
} from "../scene";
 | 
					} from "../scene";
 | 
				
			||||||
import { SHAPES } from "../shapes";
 | 
					import { SHAPES } from "../shapes";
 | 
				
			||||||
@@ -18,92 +17,57 @@ import { AppState, Zoom } from "../types";
 | 
				
			|||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
					import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
				
			||||||
import Stack from "./Stack";
 | 
					import Stack from "./Stack";
 | 
				
			||||||
import { ToolButton } from "./ToolButton";
 | 
					import { ToolButton } from "./ToolButton";
 | 
				
			||||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
					 | 
				
			||||||
import { trackEvent } from "../analytics";
 | 
					 | 
				
			||||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SelectedShapeActions = ({
 | 
					export const SelectedShapeActions = ({
 | 
				
			||||||
  appState,
 | 
					  appState,
 | 
				
			||||||
  elements,
 | 
					  elements,
 | 
				
			||||||
  renderAction,
 | 
					  renderAction,
 | 
				
			||||||
  activeTool,
 | 
					  elementType,
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  appState: AppState;
 | 
					  appState: AppState;
 | 
				
			||||||
  elements: readonly ExcalidrawElement[];
 | 
					  elements: readonly ExcalidrawElement[];
 | 
				
			||||||
  renderAction: ActionManager["renderAction"];
 | 
					  renderAction: ActionManager["renderAction"];
 | 
				
			||||||
  activeTool: AppState["activeTool"]["type"];
 | 
					  elementType: ExcalidrawElement["type"];
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const targetElements = getTargetElements(
 | 
					  const targetElements = getTargetElements(
 | 
				
			||||||
    getNonDeletedElements(elements),
 | 
					    getNonDeletedElements(elements),
 | 
				
			||||||
    appState,
 | 
					    appState,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					 | 
				
			||||||
  let isSingleElementBoundContainer = false;
 | 
					 | 
				
			||||||
  if (
 | 
					 | 
				
			||||||
    targetElements.length === 2 &&
 | 
					 | 
				
			||||||
    (hasBoundTextElement(targetElements[0]) ||
 | 
					 | 
				
			||||||
      hasBoundTextElement(targetElements[1]))
 | 
					 | 
				
			||||||
  ) {
 | 
					 | 
				
			||||||
    isSingleElementBoundContainer = true;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  const isEditing = Boolean(appState.editingElement);
 | 
					  const isEditing = Boolean(appState.editingElement);
 | 
				
			||||||
  const deviceType = useDeviceType();
 | 
					  const isMobile = useIsMobile();
 | 
				
			||||||
  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 | 
					  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const showFillIcons =
 | 
					  const showFillIcons =
 | 
				
			||||||
    hasBackground(activeTool) ||
 | 
					    hasBackground(elementType) ||
 | 
				
			||||||
    targetElements.some(
 | 
					    targetElements.some(
 | 
				
			||||||
      (element) =>
 | 
					      (element) =>
 | 
				
			||||||
        hasBackground(element.type) && !isTransparent(element.backgroundColor),
 | 
					        hasBackground(element.type) && !isTransparent(element.backgroundColor),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  const showChangeBackgroundIcons =
 | 
					  const showChangeBackgroundIcons =
 | 
				
			||||||
    hasBackground(activeTool) ||
 | 
					    hasBackground(elementType) ||
 | 
				
			||||||
    targetElements.some((element) => hasBackground(element.type));
 | 
					    targetElements.some((element) => hasBackground(element.type));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const showLinkIcon =
 | 
					 | 
				
			||||||
    targetElements.length === 1 || isSingleElementBoundContainer;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let commonSelectedType: string | null = targetElements[0]?.type || null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (const element of targetElements) {
 | 
					 | 
				
			||||||
    if (element.type !== commonSelectedType) {
 | 
					 | 
				
			||||||
      commonSelectedType = null;
 | 
					 | 
				
			||||||
      break;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="panelColumn">
 | 
					    <div className="panelColumn">
 | 
				
			||||||
      {((hasStrokeColor(activeTool) &&
 | 
					      {renderAction("changeStrokeColor")}
 | 
				
			||||||
        activeTool !== "image" &&
 | 
					 | 
				
			||||||
        commonSelectedType !== "image") ||
 | 
					 | 
				
			||||||
        targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
					 | 
				
			||||||
        renderAction("changeStrokeColor")}
 | 
					 | 
				
			||||||
      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
					      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
				
			||||||
      {showFillIcons && renderAction("changeFillStyle")}
 | 
					      {showFillIcons && renderAction("changeFillStyle")}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {(hasStrokeWidth(activeTool) ||
 | 
					      {(hasStroke(elementType) ||
 | 
				
			||||||
        targetElements.some((element) => hasStrokeWidth(element.type))) &&
 | 
					        targetElements.some((element) => hasStroke(element.type))) && (
 | 
				
			||||||
        renderAction("changeStrokeWidth")}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {(activeTool === "freedraw" ||
 | 
					 | 
				
			||||||
        targetElements.some((element) => element.type === "freedraw")) &&
 | 
					 | 
				
			||||||
        renderAction("changeStrokeShape")}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {(hasStrokeStyle(activeTool) ||
 | 
					 | 
				
			||||||
        targetElements.some((element) => hasStrokeStyle(element.type))) && (
 | 
					 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
 | 
					          {renderAction("changeStrokeWidth")}
 | 
				
			||||||
          {renderAction("changeStrokeStyle")}
 | 
					          {renderAction("changeStrokeStyle")}
 | 
				
			||||||
          {renderAction("changeSloppiness")}
 | 
					          {renderAction("changeSloppiness")}
 | 
				
			||||||
        </>
 | 
					        </>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {(canChangeSharpness(activeTool) ||
 | 
					      {(canChangeSharpness(elementType) ||
 | 
				
			||||||
        targetElements.some((element) => canChangeSharpness(element.type))) && (
 | 
					        targetElements.some((element) => canChangeSharpness(element.type))) && (
 | 
				
			||||||
        <>{renderAction("changeSharpness")}</>
 | 
					        <>{renderAction("changeSharpness")}</>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {(hasText(activeTool) ||
 | 
					      {(hasText(elementType) ||
 | 
				
			||||||
        targetElements.some((element) => hasText(element.type))) && (
 | 
					        targetElements.some((element) => hasText(element.type))) && (
 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
          {renderAction("changeFontSize")}
 | 
					          {renderAction("changeFontSize")}
 | 
				
			||||||
@@ -114,11 +78,7 @@ export const SelectedShapeActions = ({
 | 
				
			|||||||
        </>
 | 
					        </>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {targetElements.some(
 | 
					      {(canHaveArrowheads(elementType) ||
 | 
				
			||||||
        (element) =>
 | 
					 | 
				
			||||||
          hasBoundTextElement(element) || isBoundToContainer(element),
 | 
					 | 
				
			||||||
      ) && renderAction("changeVerticalAlign")}
 | 
					 | 
				
			||||||
      {(canHaveArrowheads(activeTool) ||
 | 
					 | 
				
			||||||
        targetElements.some((element) => canHaveArrowheads(element.type))) && (
 | 
					        targetElements.some((element) => canHaveArrowheads(element.type))) && (
 | 
				
			||||||
        <>{renderAction("changeArrowhead")}</>
 | 
					        <>{renderAction("changeArrowhead")}</>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
@@ -135,7 +95,7 @@ export const SelectedShapeActions = ({
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </fieldset>
 | 
					      </fieldset>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {targetElements.length > 1 && !isSingleElementBoundContainer && (
 | 
					      {targetElements.length > 1 && (
 | 
				
			||||||
        <fieldset>
 | 
					        <fieldset>
 | 
				
			||||||
          <legend>{t("labels.align")}</legend>
 | 
					          <legend>{t("labels.align")}</legend>
 | 
				
			||||||
          <div className="buttonList">
 | 
					          <div className="buttonList">
 | 
				
			||||||
@@ -168,15 +128,14 @@ export const SelectedShapeActions = ({
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </fieldset>
 | 
					        </fieldset>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
      {!isEditing && targetElements.length > 0 && (
 | 
					      {!isMobile && !isEditing && targetElements.length > 0 && (
 | 
				
			||||||
        <fieldset>
 | 
					        <fieldset>
 | 
				
			||||||
          <legend>{t("labels.actions")}</legend>
 | 
					          <legend>{t("labels.actions")}</legend>
 | 
				
			||||||
          <div className="buttonList">
 | 
					          <div className="buttonList">
 | 
				
			||||||
            {!deviceType.isMobile && renderAction("duplicateSelection")}
 | 
					            {renderAction("duplicateSelection")}
 | 
				
			||||||
            {!deviceType.isMobile && renderAction("deleteSelectedElements")}
 | 
					            {renderAction("deleteSelectedElements")}
 | 
				
			||||||
            {renderAction("group")}
 | 
					            {renderAction("group")}
 | 
				
			||||||
            {renderAction("ungroup")}
 | 
					            {renderAction("ungroup")}
 | 
				
			||||||
            {showLinkIcon && renderAction("hyperlink")}
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </fieldset>
 | 
					        </fieldset>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
@@ -184,68 +143,69 @@ export const SelectedShapeActions = ({
 | 
				
			|||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const LIBRARY_ICON = (
 | 
				
			||||||
 | 
					  // fa-th-large
 | 
				
			||||||
 | 
					  <svg viewBox="0 0 512 512">
 | 
				
			||||||
 | 
					    <path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
 | 
				
			||||||
 | 
					  </svg>
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ShapesSwitcher = ({
 | 
					export const ShapesSwitcher = ({
 | 
				
			||||||
  canvas,
 | 
					  canvas,
 | 
				
			||||||
  activeTool,
 | 
					  elementType,
 | 
				
			||||||
  setAppState,
 | 
					  setAppState,
 | 
				
			||||||
  onImageAction,
 | 
					  isLibraryOpen,
 | 
				
			||||||
  appState,
 | 
					 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  canvas: HTMLCanvasElement | null;
 | 
					  canvas: HTMLCanvasElement | null;
 | 
				
			||||||
  activeTool: AppState["activeTool"];
 | 
					  elementType: ExcalidrawElement["type"];
 | 
				
			||||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
					  setAppState: React.Component<any, AppState>["setState"];
 | 
				
			||||||
  onImageAction: (data: { pointerType: PointerType | null }) => void;
 | 
					  isLibraryOpen: boolean;
 | 
				
			||||||
  appState: AppState;
 | 
					 | 
				
			||||||
}) => (
 | 
					}) => (
 | 
				
			||||||
  <>
 | 
					  <>
 | 
				
			||||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
					    {SHAPES.map(({ value, icon, key }, index) => {
 | 
				
			||||||
      const label = t(`toolBar.${value}`);
 | 
					      const label = t(`toolBar.${value}`);
 | 
				
			||||||
      const letter = key && (typeof key === "string" ? key : key[0]);
 | 
					      const letter = typeof key === "string" ? key : key[0];
 | 
				
			||||||
      const shortcut = letter
 | 
					      const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
 | 
				
			||||||
        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
 | 
					        index + 1
 | 
				
			||||||
        : `${index + 1}`;
 | 
					      }`;
 | 
				
			||||||
      return (
 | 
					      return (
 | 
				
			||||||
        <ToolButton
 | 
					        <ToolButton
 | 
				
			||||||
          className="Shape"
 | 
					          className="Shape"
 | 
				
			||||||
          key={value}
 | 
					          key={value}
 | 
				
			||||||
          type="radio"
 | 
					          type="radio"
 | 
				
			||||||
          icon={icon}
 | 
					          icon={icon}
 | 
				
			||||||
          checked={activeTool.type === value}
 | 
					          checked={elementType === value}
 | 
				
			||||||
          name="editor-current-shape"
 | 
					          name="editor-current-shape"
 | 
				
			||||||
          title={`${capitalizeString(label)} — ${shortcut}`}
 | 
					          title={`${capitalizeString(label)} — ${shortcut}`}
 | 
				
			||||||
          keyBindingLabel={`${index + 1}`}
 | 
					          keyBindingLabel={`${index + 1}`}
 | 
				
			||||||
          aria-label={capitalizeString(label)}
 | 
					          aria-label={capitalizeString(label)}
 | 
				
			||||||
          aria-keyshortcuts={shortcut}
 | 
					          aria-keyshortcuts={shortcut}
 | 
				
			||||||
          data-testid={value}
 | 
					          data-testid={value}
 | 
				
			||||||
          onPointerDown={({ pointerType }) => {
 | 
					          onChange={() => {
 | 
				
			||||||
            if (!appState.penDetected && pointerType === "pen") {
 | 
					 | 
				
			||||||
              setAppState({
 | 
					 | 
				
			||||||
                penDetected: true,
 | 
					 | 
				
			||||||
                penMode: true,
 | 
					 | 
				
			||||||
              });
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          onChange={({ pointerType }) => {
 | 
					 | 
				
			||||||
            if (appState.activeTool.type !== value) {
 | 
					 | 
				
			||||||
              trackEvent("toolbar", value, "ui");
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            const nextActiveTool = { ...activeTool, type: value };
 | 
					 | 
				
			||||||
            setAppState({
 | 
					            setAppState({
 | 
				
			||||||
              activeTool: nextActiveTool,
 | 
					              elementType: value,
 | 
				
			||||||
              multiElement: null,
 | 
					              multiElement: null,
 | 
				
			||||||
              selectedElementIds: {},
 | 
					              selectedElementIds: {},
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
            setCursorForShape(canvas, {
 | 
					            setCursorForShape(canvas, value);
 | 
				
			||||||
              ...appState,
 | 
					            setAppState({});
 | 
				
			||||||
              activeTool: nextActiveTool,
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
            if (value === "image") {
 | 
					 | 
				
			||||||
              onImageAction({ pointerType });
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    })}
 | 
					    })}
 | 
				
			||||||
 | 
					    <ToolButton
 | 
				
			||||||
 | 
					      className="Shape ToolIcon_type_button__library"
 | 
				
			||||||
 | 
					      type="button"
 | 
				
			||||||
 | 
					      icon={LIBRARY_ICON}
 | 
				
			||||||
 | 
					      name="editor-library"
 | 
				
			||||||
 | 
					      keyBindingLabel="9"
 | 
				
			||||||
 | 
					      aria-keyshortcuts="9"
 | 
				
			||||||
 | 
					      title={`${capitalizeString(t("toolBar.library"))} — 9`}
 | 
				
			||||||
 | 
					      aria-label={capitalizeString(t("toolBar.library"))}
 | 
				
			||||||
 | 
					      onClick={() => {
 | 
				
			||||||
 | 
					        setAppState({ isLibraryOpen: !isLibraryOpen });
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
  </>
 | 
					  </>
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -258,9 +218,12 @@ export const ZoomActions = ({
 | 
				
			|||||||
}) => (
 | 
					}) => (
 | 
				
			||||||
  <Stack.Col gap={1}>
 | 
					  <Stack.Col gap={1}>
 | 
				
			||||||
    <Stack.Row gap={1} align="center">
 | 
					    <Stack.Row gap={1} align="center">
 | 
				
			||||||
      {renderAction("zoomOut")}
 | 
					 | 
				
			||||||
      {renderAction("zoomIn")}
 | 
					      {renderAction("zoomIn")}
 | 
				
			||||||
 | 
					      {renderAction("zoomOut")}
 | 
				
			||||||
      {renderAction("resetZoom")}
 | 
					      {renderAction("resetZoom")}
 | 
				
			||||||
 | 
					      <div style={{ marginInlineStart: 4 }}>
 | 
				
			||||||
 | 
					        {(zoom.value * 100).toFixed(0)}%
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </Stack.Row>
 | 
					    </Stack.Row>
 | 
				
			||||||
  </Stack.Col>
 | 
					  </Stack.Col>
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,21 +0,0 @@
 | 
				
			|||||||
.excalidraw {
 | 
					 | 
				
			||||||
  .ActiveFile {
 | 
					 | 
				
			||||||
    .ActiveFile__fileName {
 | 
					 | 
				
			||||||
      display: flex;
 | 
					 | 
				
			||||||
      align-items: center;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      span {
 | 
					 | 
				
			||||||
        text-overflow: ellipsis;
 | 
					 | 
				
			||||||
        overflow: hidden;
 | 
					 | 
				
			||||||
        white-space: nowrap;
 | 
					 | 
				
			||||||
        width: 9.3em;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      svg {
 | 
					 | 
				
			||||||
        width: 1.15em;
 | 
					 | 
				
			||||||
        margin-inline-end: 0.3em;
 | 
					 | 
				
			||||||
        transform: scaleY(0.9);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,28 +0,0 @@
 | 
				
			|||||||
import Stack from "../components/Stack";
 | 
					 | 
				
			||||||
import { ToolButton } from "../components/ToolButton";
 | 
					 | 
				
			||||||
import { save, file } from "../components/icons";
 | 
					 | 
				
			||||||
import { t } from "../i18n";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import "./ActiveFile.scss";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type ActiveFileProps = {
 | 
					 | 
				
			||||||
  fileName?: string;
 | 
					 | 
				
			||||||
  onSave: () => void;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
 | 
					 | 
				
			||||||
  <Stack.Row className="ActiveFile" gap={1} align="center">
 | 
					 | 
				
			||||||
    <span className="ActiveFile__fileName">
 | 
					 | 
				
			||||||
      {file}
 | 
					 | 
				
			||||||
      <span>{fileName}</span>
 | 
					 | 
				
			||||||
    </span>
 | 
					 | 
				
			||||||
    <ToolButton
 | 
					 | 
				
			||||||
      type="icon"
 | 
					 | 
				
			||||||
      icon={save}
 | 
					 | 
				
			||||||
      title={t("buttons.save")}
 | 
					 | 
				
			||||||
      aria-label={t("buttons.save")}
 | 
					 | 
				
			||||||
      onClick={onSave}
 | 
					 | 
				
			||||||
      data-testid="save-button"
 | 
					 | 
				
			||||||
    />
 | 
					 | 
				
			||||||
  </Stack.Row>
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -12,11 +12,5 @@
 | 
				
			|||||||
    cursor: pointer;
 | 
					    cursor: pointer;
 | 
				
			||||||
    font-size: 0.8rem;
 | 
					    font-size: 0.8rem;
 | 
				
			||||||
    font-weight: 500;
 | 
					    font-weight: 500;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    &-img {
 | 
					 | 
				
			||||||
      width: 100%;
 | 
					 | 
				
			||||||
      height: 100%;
 | 
					 | 
				
			||||||
      border-radius: 100%;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,28 +1,20 @@
 | 
				
			|||||||
import "./Avatar.scss";
 | 
					import "./Avatar.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { getClientInitials } from "../clients";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
type AvatarProps = {
 | 
					type AvatarProps = {
 | 
				
			||||||
 | 
					  children: string;
 | 
				
			||||||
  onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
 | 
					  onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
 | 
				
			||||||
  color: string;
 | 
					  color: string;
 | 
				
			||||||
  border: string;
 | 
					  border: string;
 | 
				
			||||||
  name: string;
 | 
					 | 
				
			||||||
  src?: string;
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
 | 
					export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
 | 
				
			||||||
  const shortName = getClientInitials(name);
 | 
					  <div
 | 
				
			||||||
  const style = src
 | 
					    className="Avatar"
 | 
				
			||||||
    ? undefined
 | 
					    style={{ background: color, border: `1px solid ${border}` }}
 | 
				
			||||||
    : { background: color, border: `1px solid ${border}` };
 | 
					    onClick={onClick}
 | 
				
			||||||
  return (
 | 
					  >
 | 
				
			||||||
    <div className="Avatar" style={style} onClick={onClick}>
 | 
					    {children}
 | 
				
			||||||
      {src ? (
 | 
					  </div>
 | 
				
			||||||
        <img className="Avatar-img" src={src} alt={shortName} />
 | 
					);
 | 
				
			||||||
      ) : (
 | 
					 | 
				
			||||||
        shortName
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { ActionManager } from "../actions/manager";
 | 
					import { ActionManager } from "../actions/manager";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
 | 
					import { DarkModeToggle } from "./DarkModeToggle";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const BackgroundPickerAndDarkModeToggle = ({
 | 
					export const BackgroundPickerAndDarkModeToggle = ({
 | 
				
			||||||
  appState,
 | 
					  appState,
 | 
				
			||||||
@@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
 | 
				
			|||||||
}) => (
 | 
					}) => (
 | 
				
			||||||
  <div style={{ display: "flex" }}>
 | 
					  <div style={{ display: "flex" }}>
 | 
				
			||||||
    {actionManager.renderAction("changeViewBackgroundColor")}
 | 
					    {actionManager.renderAction("changeViewBackgroundColor")}
 | 
				
			||||||
    {showThemeBtn && actionManager.renderAction("toggleTheme")}
 | 
					    {showThemeBtn && (
 | 
				
			||||||
 | 
					      <div style={{ marginInlineStart: "0.25rem" }}>
 | 
				
			||||||
 | 
					        <DarkModeToggle
 | 
				
			||||||
 | 
					          value={appState.theme}
 | 
				
			||||||
 | 
					          onChange={(theme) => {
 | 
				
			||||||
 | 
					            setAppState({ theme });
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    )}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ButtonIconCycle = <T extends any>({
 | 
					export const ButtonIconCycle = <T extends any>({
 | 
				
			||||||
@@ -13,11 +14,11 @@ export const ButtonIconCycle = <T extends any>({
 | 
				
			|||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const current = options.find((op) => op.value === value);
 | 
					  const current = options.find((op) => op.value === value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const cycle = () => {
 | 
					  function cycle() {
 | 
				
			||||||
    const index = options.indexOf(current!);
 | 
					    const index = options.indexOf(current!);
 | 
				
			||||||
    const next = (index + 1) % options.length;
 | 
					    const next = (index + 1) % options.length;
 | 
				
			||||||
    onChange(options[next].value);
 | 
					    onChange(options[next].value);
 | 
				
			||||||
  };
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <label key={group} className={clsx({ active: current!.value !== null })}>
 | 
					    <label key={group} className={clsx({ active: current!.value !== null })}>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
 | 
					// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
 | 
				
			||||||
@@ -7,7 +8,7 @@ export const ButtonIconSelect = <T extends Object>({
 | 
				
			|||||||
  onChange,
 | 
					  onChange,
 | 
				
			||||||
  group,
 | 
					  group,
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
 | 
					  options: { value: T; text: string; icon: JSX.Element }[];
 | 
				
			||||||
  value: T | null;
 | 
					  value: T | null;
 | 
				
			||||||
  onChange: (value: T) => void;
 | 
					  onChange: (value: T) => void;
 | 
				
			||||||
  group: string;
 | 
					  group: string;
 | 
				
			||||||
@@ -24,7 +25,6 @@ export const ButtonIconSelect = <T extends Object>({
 | 
				
			|||||||
          name={group}
 | 
					          name={group}
 | 
				
			||||||
          onChange={() => onChange(option.value)}
 | 
					          onChange={() => onChange(option.value)}
 | 
				
			||||||
          checked={value === option.value}
 | 
					          checked={value === option.value}
 | 
				
			||||||
          data-testid={option.testId}
 | 
					 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        {option.icon}
 | 
					        {option.icon}
 | 
				
			||||||
      </label>
 | 
					      </label>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ButtonSelect = <T extends Object>({
 | 
					export const ButtonSelect = <T extends Object>({
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,57 +0,0 @@
 | 
				
			|||||||
@import "../css/variables.module";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.excalidraw {
 | 
					 | 
				
			||||||
  .Card {
 | 
					 | 
				
			||||||
    display: flex;
 | 
					 | 
				
			||||||
    flex-direction: column;
 | 
					 | 
				
			||||||
    align-items: center;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    max-width: 290px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    margin: 1em;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    text-align: center;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .Card-icon {
 | 
					 | 
				
			||||||
      font-size: 2.6em;
 | 
					 | 
				
			||||||
      display: flex;
 | 
					 | 
				
			||||||
      flex: 0 0 auto;
 | 
					 | 
				
			||||||
      padding: 1.4rem;
 | 
					 | 
				
			||||||
      border-radius: 50%;
 | 
					 | 
				
			||||||
      background: var(--card-color);
 | 
					 | 
				
			||||||
      color: $oc-white;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      svg {
 | 
					 | 
				
			||||||
        width: 2.8rem;
 | 
					 | 
				
			||||||
        height: 2.8rem;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .Card-details {
 | 
					 | 
				
			||||||
      font-size: 0.96em;
 | 
					 | 
				
			||||||
      min-height: 90px;
 | 
					 | 
				
			||||||
      padding: 0 1em;
 | 
					 | 
				
			||||||
      margin-bottom: auto;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    & .Card-button.ToolIcon_type_button {
 | 
					 | 
				
			||||||
      height: 2.5rem;
 | 
					 | 
				
			||||||
      margin-top: 1em;
 | 
					 | 
				
			||||||
      margin-bottom: 0.3em;
 | 
					 | 
				
			||||||
      background-color: var(--card-color);
 | 
					 | 
				
			||||||
      &:hover {
 | 
					 | 
				
			||||||
        background-color: var(--card-color-darker);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      &:active {
 | 
					 | 
				
			||||||
        background-color: var(--card-color-darkest);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      .ToolIcon__label {
 | 
					 | 
				
			||||||
        color: $oc-white;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      .Spinner {
 | 
					 | 
				
			||||||
        --spinner-color: #fff;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,27 +0,0 @@
 | 
				
			|||||||
import OpenColor from "open-color";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import "./Card.scss";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const Card: React.FC<{
 | 
					 | 
				
			||||||
  color: keyof OpenColor | "primary";
 | 
					 | 
				
			||||||
}> = ({ children, color }) => {
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div
 | 
					 | 
				
			||||||
      className="Card"
 | 
					 | 
				
			||||||
      style={{
 | 
					 | 
				
			||||||
        ["--card-color" as any]:
 | 
					 | 
				
			||||||
          color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
 | 
					 | 
				
			||||||
        ["--card-color-darker" as any]:
 | 
					 | 
				
			||||||
          color === "primary"
 | 
					 | 
				
			||||||
            ? "var(--color-primary-darker)"
 | 
					 | 
				
			||||||
            : OpenColor[color][8],
 | 
					 | 
				
			||||||
        ["--card-color-darkest" as any]:
 | 
					 | 
				
			||||||
          color === "primary"
 | 
					 | 
				
			||||||
            ? "var(--color-primary-darkest)"
 | 
					 | 
				
			||||||
            : OpenColor[color][9],
 | 
					 | 
				
			||||||
      }}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      {children}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,89 +0,0 @@
 | 
				
			|||||||
@import "../css/variables.module";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.excalidraw {
 | 
					 | 
				
			||||||
  .Checkbox {
 | 
					 | 
				
			||||||
    margin: 4px 0.3em;
 | 
					 | 
				
			||||||
    display: flex;
 | 
					 | 
				
			||||||
    align-items: center;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    cursor: pointer;
 | 
					 | 
				
			||||||
    user-select: none;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    -webkit-tap-highlight-color: transparent;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
 | 
					 | 
				
			||||||
      box-shadow: 0 0 0 2px #{$oc-blue-4};
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
 | 
					 | 
				
			||||||
      svg {
 | 
					 | 
				
			||||||
        display: block;
 | 
					 | 
				
			||||||
        opacity: 0.3;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &:active {
 | 
					 | 
				
			||||||
      .Checkbox-box {
 | 
					 | 
				
			||||||
        box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &:hover {
 | 
					 | 
				
			||||||
      .Checkbox-box {
 | 
					 | 
				
			||||||
        background-color: fade-out($oc-blue-1, 0.8);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &.is-checked {
 | 
					 | 
				
			||||||
      .Checkbox-box {
 | 
					 | 
				
			||||||
        background-color: #{$oc-blue-1};
 | 
					 | 
				
			||||||
        svg {
 | 
					 | 
				
			||||||
          display: block;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      &:hover .Checkbox-box {
 | 
					 | 
				
			||||||
        background-color: #{$oc-blue-2};
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .Checkbox-box {
 | 
					 | 
				
			||||||
      width: 22px;
 | 
					 | 
				
			||||||
      height: 22px;
 | 
					 | 
				
			||||||
      padding: 0;
 | 
					 | 
				
			||||||
      flex: 0 0 auto;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      margin: 0 1em;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      display: flex;
 | 
					 | 
				
			||||||
      align-items: center;
 | 
					 | 
				
			||||||
      justify-content: center;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      box-shadow: 0 0 0 2px #{$oc-blue-7};
 | 
					 | 
				
			||||||
      background-color: transparent;
 | 
					 | 
				
			||||||
      border-radius: 4px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      color: #{$oc-blue-7};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      &:focus {
 | 
					 | 
				
			||||||
        box-shadow: 0 0 0 3px #{$oc-blue-7};
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      svg {
 | 
					 | 
				
			||||||
        display: none;
 | 
					 | 
				
			||||||
        width: 16px;
 | 
					 | 
				
			||||||
        height: 16px;
 | 
					 | 
				
			||||||
        stroke-width: 3px;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .Checkbox-label {
 | 
					 | 
				
			||||||
      display: flex;
 | 
					 | 
				
			||||||
      align-items: center;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .excalidraw-tooltip-icon {
 | 
					 | 
				
			||||||
      width: 1em;
 | 
					 | 
				
			||||||
      height: 1em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,30 +0,0 @@
 | 
				
			|||||||
import React from "react";
 | 
					 | 
				
			||||||
import clsx from "clsx";
 | 
					 | 
				
			||||||
import { checkIcon } from "./icons";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import "./CheckboxItem.scss";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const CheckboxItem: React.FC<{
 | 
					 | 
				
			||||||
  checked: boolean;
 | 
					 | 
				
			||||||
  onChange: (checked: boolean, event: React.MouseEvent) => void;
 | 
					 | 
				
			||||||
  className?: string;
 | 
					 | 
				
			||||||
}> = ({ children, checked, onChange, className }) => {
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div
 | 
					 | 
				
			||||||
      className={clsx("Checkbox", className, { "is-checked": checked })}
 | 
					 | 
				
			||||||
      onClick={(event) => {
 | 
					 | 
				
			||||||
        onChange(!checked, event);
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
          (event.currentTarget as HTMLDivElement).querySelector(
 | 
					 | 
				
			||||||
            ".Checkbox-box",
 | 
					 | 
				
			||||||
          ) as HTMLButtonElement
 | 
					 | 
				
			||||||
        ).focus();
 | 
					 | 
				
			||||||
      }}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
 | 
					 | 
				
			||||||
        {checkIcon}
 | 
					 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
      <div className="Checkbox-label">{children}</div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,43 +0,0 @@
 | 
				
			|||||||
import { useState } from "react";
 | 
					 | 
				
			||||||
import { t } from "../i18n";
 | 
					 | 
				
			||||||
import { useDeviceType } from "./App";
 | 
					 | 
				
			||||||
import { trash } from "./icons";
 | 
					 | 
				
			||||||
import { ToolButton } from "./ToolButton";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import ConfirmDialog from "./ConfirmDialog";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
 | 
					 | 
				
			||||||
  const [showDialog, setShowDialog] = useState(false);
 | 
					 | 
				
			||||||
  const toggleDialog = () => {
 | 
					 | 
				
			||||||
    setShowDialog(!showDialog);
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <>
 | 
					 | 
				
			||||||
      <ToolButton
 | 
					 | 
				
			||||||
        type="button"
 | 
					 | 
				
			||||||
        icon={trash}
 | 
					 | 
				
			||||||
        title={t("buttons.clearReset")}
 | 
					 | 
				
			||||||
        aria-label={t("buttons.clearReset")}
 | 
					 | 
				
			||||||
        showAriaLabel={useDeviceType().isMobile}
 | 
					 | 
				
			||||||
        onClick={toggleDialog}
 | 
					 | 
				
			||||||
        data-testid="clear-canvas-button"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {showDialog && (
 | 
					 | 
				
			||||||
        <ConfirmDialog
 | 
					 | 
				
			||||||
          onConfirm={() => {
 | 
					 | 
				
			||||||
            onConfirm();
 | 
					 | 
				
			||||||
            toggleDialog();
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          onCancel={toggleDialog}
 | 
					 | 
				
			||||||
          title={t("clearCanvasDialog.title")}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
 | 
					 | 
				
			||||||
        </ConfirmDialog>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default ClearCanvas;
 | 
					 | 
				
			||||||
@@ -1,7 +1,8 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
import { ToolButton } from "./ToolButton";
 | 
					import { ToolButton } from "./ToolButton";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { useDeviceType } from "../components/App";
 | 
					import useIsMobile from "../is-mobile";
 | 
				
			||||||
import { users } from "./icons";
 | 
					import { users } from "./icons";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import "./CollabButton.scss";
 | 
					import "./CollabButton.scss";
 | 
				
			||||||
@@ -26,7 +27,7 @@ const CollabButton = ({
 | 
				
			|||||||
        type="button"
 | 
					        type="button"
 | 
				
			||||||
        title={t("labels.liveCollaboration")}
 | 
					        title={t("labels.liveCollaboration")}
 | 
				
			||||||
        aria-label={t("labels.liveCollaboration")}
 | 
					        aria-label={t("labels.liveCollaboration")}
 | 
				
			||||||
        showAriaLabel={useDeviceType().isMobile}
 | 
					        showAriaLabel={useIsMobile()}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {collaboratorCount > 0 && (
 | 
					        {collaboratorCount > 0 && (
 | 
				
			||||||
          <div className="CollabButton-collaborators">{collaboratorCount}</div>
 | 
					          <div className="CollabButton-collaborators">{collaboratorCount}</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -46,7 +46,7 @@
 | 
				
			|||||||
    top: -11px;
 | 
					    top: -11px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .color-picker-content--default {
 | 
					  .color-picker-content {
 | 
				
			||||||
    padding: 0.5rem;
 | 
					    padding: 0.5rem;
 | 
				
			||||||
    display: grid;
 | 
					    display: grid;
 | 
				
			||||||
    grid-template-columns: repeat(5, auto);
 | 
					    grid-template-columns: repeat(5, auto);
 | 
				
			||||||
@@ -59,26 +59,6 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .color-picker-content--canvas {
 | 
					 | 
				
			||||||
    display: flex;
 | 
					 | 
				
			||||||
    flex-direction: column;
 | 
					 | 
				
			||||||
    padding: 0.25rem;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &-title {
 | 
					 | 
				
			||||||
      color: $oc-gray-6;
 | 
					 | 
				
			||||||
      font-size: 12px;
 | 
					 | 
				
			||||||
      padding: 0 0.25rem;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &-colors {
 | 
					 | 
				
			||||||
      padding: 0.5rem 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      .color-picker-swatch {
 | 
					 | 
				
			||||||
        margin: 0 0.25rem;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .color-picker-content .color-input-container {
 | 
					  .color-picker-content .color-input-container {
 | 
				
			||||||
    grid-column: 1 / span 5;
 | 
					    grid-column: 1 / span 5;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -180,7 +160,7 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .color-picker-input {
 | 
					  .color-picker-input {
 | 
				
			||||||
    width: 11ch; /* length of `transparent` */
 | 
					    width: 12ch; /* length of `transparent` + 1 */
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
    font-size: 1rem;
 | 
					    font-size: 1rem;
 | 
				
			||||||
    background-color: var(--input-bg-color);
 | 
					    background-color: var(--input-bg-color);
 | 
				
			||||||
@@ -238,7 +218,7 @@
 | 
				
			|||||||
      left: 2px;
 | 
					      left: 2px;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @include isMobile {
 | 
					    @media #{$is-mobile-query} {
 | 
				
			||||||
      display: none;
 | 
					      display: none;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,59 +1,11 @@
 | 
				
			|||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { Popover } from "./Popover";
 | 
					import { Popover } from "./Popover";
 | 
				
			||||||
import { isTransparent } from "../utils";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import "./ColorPicker.scss";
 | 
					import "./ColorPicker.scss";
 | 
				
			||||||
import { isArrowKey, KEYS } from "../keys";
 | 
					import { isArrowKey, KEYS } from "../keys";
 | 
				
			||||||
import { t, getLanguage } from "../i18n";
 | 
					import { t, getLanguage } from "../i18n";
 | 
				
			||||||
import { isWritableElement } from "../utils";
 | 
					import { isWritableElement } from "../utils";
 | 
				
			||||||
import colors from "../colors";
 | 
					import colors from "../colors";
 | 
				
			||||||
import { ExcalidrawElement } from "../element/types";
 | 
					 | 
				
			||||||
import { AppState } from "../types";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const MAX_CUSTOM_COLORS = 5;
 | 
					 | 
				
			||||||
const MAX_DEFAULT_COLORS = 15;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const getCustomColors = (
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[],
 | 
					 | 
				
			||||||
  type: "elementBackground" | "elementStroke",
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  const customColors: string[] = [];
 | 
					 | 
				
			||||||
  const updatedElements = elements
 | 
					 | 
				
			||||||
    .filter((element) => !element.isDeleted)
 | 
					 | 
				
			||||||
    .sort((ele1, ele2) => ele2.updated - ele1.updated);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let index = 0;
 | 
					 | 
				
			||||||
  const elementColorTypeMap = {
 | 
					 | 
				
			||||||
    elementBackground: "backgroundColor",
 | 
					 | 
				
			||||||
    elementStroke: "strokeColor",
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  const colorType = elementColorTypeMap[type] as
 | 
					 | 
				
			||||||
    | "backgroundColor"
 | 
					 | 
				
			||||||
    | "strokeColor";
 | 
					 | 
				
			||||||
  while (
 | 
					 | 
				
			||||||
    index < updatedElements.length &&
 | 
					 | 
				
			||||||
    customColors.length < MAX_CUSTOM_COLORS
 | 
					 | 
				
			||||||
  ) {
 | 
					 | 
				
			||||||
    const element = updatedElements[index];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (
 | 
					 | 
				
			||||||
      customColors.length < MAX_CUSTOM_COLORS &&
 | 
					 | 
				
			||||||
      isCustomColor(element[colorType], type) &&
 | 
					 | 
				
			||||||
      !customColors.includes(element[colorType])
 | 
					 | 
				
			||||||
    ) {
 | 
					 | 
				
			||||||
      customColors.push(element[colorType]);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    index++;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return customColors;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const isCustomColor = (
 | 
					 | 
				
			||||||
  color: string,
 | 
					 | 
				
			||||||
  type: "elementBackground" | "elementStroke",
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
  return !colors[type].includes(color);
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const isValidColor = (color: string) => {
 | 
					const isValidColor = (color: string) => {
 | 
				
			||||||
  const style = new Option().style;
 | 
					  const style = new Option().style;
 | 
				
			||||||
@@ -62,7 +14,7 @@ const isValidColor = (color: string) => {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getColor = (color: string): string | null => {
 | 
					const getColor = (color: string): string | null => {
 | 
				
			||||||
  if (isTransparent(color)) {
 | 
					  if (color === "transparent") {
 | 
				
			||||||
    return color;
 | 
					    return color;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -82,7 +34,6 @@ const keyBindings = [
 | 
				
			|||||||
  ["1", "2", "3", "4", "5"],
 | 
					  ["1", "2", "3", "4", "5"],
 | 
				
			||||||
  ["q", "w", "e", "r", "t"],
 | 
					  ["q", "w", "e", "r", "t"],
 | 
				
			||||||
  ["a", "s", "d", "f", "g"],
 | 
					  ["a", "s", "d", "f", "g"],
 | 
				
			||||||
  ["z", "x", "c", "v", "b"],
 | 
					 | 
				
			||||||
].flat();
 | 
					].flat();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Picker = ({
 | 
					const Picker = ({
 | 
				
			||||||
@@ -93,7 +44,6 @@ const Picker = ({
 | 
				
			|||||||
  label,
 | 
					  label,
 | 
				
			||||||
  showInput = true,
 | 
					  showInput = true,
 | 
				
			||||||
  type,
 | 
					  type,
 | 
				
			||||||
  elements,
 | 
					 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  colors: string[];
 | 
					  colors: string[];
 | 
				
			||||||
  color: string | null;
 | 
					  color: string | null;
 | 
				
			||||||
@@ -102,20 +52,12 @@ const Picker = ({
 | 
				
			|||||||
  label: string;
 | 
					  label: string;
 | 
				
			||||||
  showInput: boolean;
 | 
					  showInput: boolean;
 | 
				
			||||||
  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
					  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
				
			||||||
  elements: readonly ExcalidrawElement[];
 | 
					 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const firstItem = React.useRef<HTMLButtonElement>();
 | 
					  const firstItem = React.useRef<HTMLButtonElement>();
 | 
				
			||||||
  const activeItem = React.useRef<HTMLButtonElement>();
 | 
					  const activeItem = React.useRef<HTMLButtonElement>();
 | 
				
			||||||
  const gallery = React.useRef<HTMLDivElement>();
 | 
					  const gallery = React.useRef<HTMLDivElement>();
 | 
				
			||||||
  const colorInput = React.useRef<HTMLInputElement>();
 | 
					  const colorInput = React.useRef<HTMLInputElement>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [customColors] = React.useState(() => {
 | 
					 | 
				
			||||||
    if (type === "canvasBackground") {
 | 
					 | 
				
			||||||
      return [];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return getCustomColors(elements, type);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  React.useEffect(() => {
 | 
					  React.useEffect(() => {
 | 
				
			||||||
    // After the component is first mounted focus on first input
 | 
					    // After the component is first mounted focus on first input
 | 
				
			||||||
    if (activeItem.current) {
 | 
					    if (activeItem.current) {
 | 
				
			||||||
@@ -142,42 +84,23 @@ const Picker = ({
 | 
				
			|||||||
    } else if (isArrowKey(event.key)) {
 | 
					    } else if (isArrowKey(event.key)) {
 | 
				
			||||||
      const { activeElement } = document;
 | 
					      const { activeElement } = document;
 | 
				
			||||||
      const isRTL = getLanguage().rtl;
 | 
					      const isRTL = getLanguage().rtl;
 | 
				
			||||||
      let isCustom = false;
 | 
					      const index = Array.prototype.indexOf.call(
 | 
				
			||||||
      let index = Array.prototype.indexOf.call(
 | 
					        gallery!.current!.children,
 | 
				
			||||||
        gallery!.current!.querySelector(".color-picker-content--default")!
 | 
					 | 
				
			||||||
          .children,
 | 
					 | 
				
			||||||
        activeElement,
 | 
					        activeElement,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      if (index === -1) {
 | 
					 | 
				
			||||||
        index = Array.prototype.indexOf.call(
 | 
					 | 
				
			||||||
          gallery!.current!.querySelector(
 | 
					 | 
				
			||||||
            ".color-picker-content--canvas-colors",
 | 
					 | 
				
			||||||
          )!.children,
 | 
					 | 
				
			||||||
          activeElement,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        if (index !== -1) {
 | 
					 | 
				
			||||||
          isCustom = true;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const parentSelector = isCustom
 | 
					 | 
				
			||||||
        ? gallery!.current!.querySelector(
 | 
					 | 
				
			||||||
            ".color-picker-content--canvas-colors",
 | 
					 | 
				
			||||||
          )!
 | 
					 | 
				
			||||||
        : gallery!.current!.querySelector(".color-picker-content--default")!;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (index !== -1) {
 | 
					      if (index !== -1) {
 | 
				
			||||||
        const length = parentSelector!.children.length - (showInput ? 1 : 0);
 | 
					        const length = gallery!.current!.children.length - (showInput ? 1 : 0);
 | 
				
			||||||
        const nextIndex =
 | 
					        const nextIndex =
 | 
				
			||||||
          event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
 | 
					          event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
 | 
				
			||||||
            ? (index + 1) % length
 | 
					            ? (index + 1) % length
 | 
				
			||||||
            : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
 | 
					            : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
 | 
				
			||||||
            ? (length + index - 1) % length
 | 
					            ? (length + index - 1) % length
 | 
				
			||||||
            : !isCustom && event.key === KEYS.ARROW_DOWN
 | 
					            : event.key === KEYS.ARROW_DOWN
 | 
				
			||||||
            ? (index + 5) % length
 | 
					            ? (index + 5) % length
 | 
				
			||||||
            : !isCustom && event.key === KEYS.ARROW_UP
 | 
					            : event.key === KEYS.ARROW_UP
 | 
				
			||||||
            ? (length + index - 5) % length
 | 
					            ? (length + index - 5) % length
 | 
				
			||||||
            : index;
 | 
					            : index;
 | 
				
			||||||
        (parentSelector!.children![nextIndex] as HTMLElement)?.focus();
 | 
					        (gallery!.current!.children![nextIndex] as any).focus();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      event.preventDefault();
 | 
					      event.preventDefault();
 | 
				
			||||||
    } else if (
 | 
					    } else if (
 | 
				
			||||||
@@ -185,66 +108,13 @@ const Picker = ({
 | 
				
			|||||||
      !isWritableElement(event.target)
 | 
					      !isWritableElement(event.target)
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
      const index = keyBindings.indexOf(event.key.toLowerCase());
 | 
					      const index = keyBindings.indexOf(event.key.toLowerCase());
 | 
				
			||||||
      const isCustom = index >= MAX_DEFAULT_COLORS;
 | 
					      (gallery!.current!.children![index] as any).focus();
 | 
				
			||||||
      const parentSelector = isCustom
 | 
					 | 
				
			||||||
        ? gallery!.current!.querySelector(
 | 
					 | 
				
			||||||
            ".color-picker-content--canvas-colors",
 | 
					 | 
				
			||||||
          )!
 | 
					 | 
				
			||||||
        : gallery!.current!.querySelector(".color-picker-content--default")!;
 | 
					 | 
				
			||||||
      const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
 | 
					 | 
				
			||||||
      (parentSelector!.children![actualIndex] as HTMLElement)?.focus();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      event.preventDefault();
 | 
					      event.preventDefault();
 | 
				
			||||||
    } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
 | 
					    } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
 | 
				
			||||||
      event.preventDefault();
 | 
					      event.preventDefault();
 | 
				
			||||||
      onClose();
 | 
					      onClose();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    event.nativeEvent.stopImmediatePropagation();
 | 
					    event.nativeEvent.stopImmediatePropagation();
 | 
				
			||||||
    event.stopPropagation();
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const renderColors = (colors: Array<string>, custom: boolean = false) => {
 | 
					 | 
				
			||||||
    return colors.map((_color, i) => {
 | 
					 | 
				
			||||||
      const _colorWithoutHash = _color.replace("#", "");
 | 
					 | 
				
			||||||
      const keyBinding = custom
 | 
					 | 
				
			||||||
        ? keyBindings[i + MAX_DEFAULT_COLORS]
 | 
					 | 
				
			||||||
        : keyBindings[i];
 | 
					 | 
				
			||||||
      const label = custom
 | 
					 | 
				
			||||||
        ? _colorWithoutHash
 | 
					 | 
				
			||||||
        : t(`colors.${_colorWithoutHash}`);
 | 
					 | 
				
			||||||
      return (
 | 
					 | 
				
			||||||
        <button
 | 
					 | 
				
			||||||
          className="color-picker-swatch"
 | 
					 | 
				
			||||||
          onClick={(event) => {
 | 
					 | 
				
			||||||
            (event.currentTarget as HTMLButtonElement).focus();
 | 
					 | 
				
			||||||
            onChange(_color);
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          title={`${label}${
 | 
					 | 
				
			||||||
            !isTransparent(_color) ? ` (${_color})` : ""
 | 
					 | 
				
			||||||
          } — ${keyBinding.toUpperCase()}`}
 | 
					 | 
				
			||||||
          aria-label={label}
 | 
					 | 
				
			||||||
          aria-keyshortcuts={keyBindings[i]}
 | 
					 | 
				
			||||||
          style={{ color: _color }}
 | 
					 | 
				
			||||||
          key={_color}
 | 
					 | 
				
			||||||
          ref={(el) => {
 | 
					 | 
				
			||||||
            if (!custom && el && i === 0) {
 | 
					 | 
				
			||||||
              firstItem.current = el;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            if (el && _color === color) {
 | 
					 | 
				
			||||||
              activeItem.current = el;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          onFocus={() => {
 | 
					 | 
				
			||||||
            onChange(_color);
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          {isTransparent(_color) ? (
 | 
					 | 
				
			||||||
            <div className="color-picker-transparent"></div>
 | 
					 | 
				
			||||||
          ) : undefined}
 | 
					 | 
				
			||||||
          <span className="color-picker-keybinding">{keyBinding}</span>
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
@@ -266,20 +136,36 @@ const Picker = ({
 | 
				
			|||||||
        }}
 | 
					        }}
 | 
				
			||||||
        tabIndex={0}
 | 
					        tabIndex={0}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div className="color-picker-content--default">
 | 
					        {colors.map((_color, i) => (
 | 
				
			||||||
          {renderColors(colors)}
 | 
					          <button
 | 
				
			||||||
        </div>
 | 
					            className="color-picker-swatch"
 | 
				
			||||||
        {!!customColors.length && (
 | 
					            onClick={(event) => {
 | 
				
			||||||
          <div className="color-picker-content--canvas">
 | 
					              (event.currentTarget as HTMLButtonElement).focus();
 | 
				
			||||||
            <span className="color-picker-content--canvas-title">
 | 
					              onChange(_color);
 | 
				
			||||||
              {t("labels.canvasColors")}
 | 
					            }}
 | 
				
			||||||
            </span>
 | 
					            title={`${_color} — ${keyBindings[i].toUpperCase()}`}
 | 
				
			||||||
            <div className="color-picker-content--canvas-colors">
 | 
					            aria-label={_color}
 | 
				
			||||||
              {renderColors(customColors, true)}
 | 
					            aria-keyshortcuts={keyBindings[i]}
 | 
				
			||||||
            </div>
 | 
					            style={{ color: _color }}
 | 
				
			||||||
          </div>
 | 
					            key={_color}
 | 
				
			||||||
        )}
 | 
					            ref={(el) => {
 | 
				
			||||||
 | 
					              if (el && i === 0) {
 | 
				
			||||||
 | 
					                firstItem.current = el;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              if (el && _color === color) {
 | 
				
			||||||
 | 
					                activeItem.current = el;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					            onFocus={() => {
 | 
				
			||||||
 | 
					              onChange(_color);
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {_color === "transparent" ? (
 | 
				
			||||||
 | 
					              <div className="color-picker-transparent"></div>
 | 
				
			||||||
 | 
					            ) : undefined}
 | 
				
			||||||
 | 
					            <span className="color-picker-keybinding">{keyBindings[i]}</span>
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
        {showInput && (
 | 
					        {showInput && (
 | 
				
			||||||
          <ColorInput
 | 
					          <ColorInput
 | 
				
			||||||
            color={color}
 | 
					            color={color}
 | 
				
			||||||
@@ -351,20 +237,13 @@ export const ColorPicker = ({
 | 
				
			|||||||
  color,
 | 
					  color,
 | 
				
			||||||
  onChange,
 | 
					  onChange,
 | 
				
			||||||
  label,
 | 
					  label,
 | 
				
			||||||
  isActive,
 | 
					 | 
				
			||||||
  setActive,
 | 
					 | 
				
			||||||
  elements,
 | 
					 | 
				
			||||||
  appState,
 | 
					 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
					  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
				
			||||||
  color: string | null;
 | 
					  color: string | null;
 | 
				
			||||||
  onChange: (color: string) => void;
 | 
					  onChange: (color: string) => void;
 | 
				
			||||||
  label: string;
 | 
					  label: string;
 | 
				
			||||||
  isActive: boolean;
 | 
					 | 
				
			||||||
  setActive: (active: boolean) => void;
 | 
					 | 
				
			||||||
  elements: readonly ExcalidrawElement[];
 | 
					 | 
				
			||||||
  appState: AppState;
 | 
					 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
 | 
					  const [isActive, setActive] = React.useState(false);
 | 
				
			||||||
  const pickerButton = React.useRef<HTMLButtonElement>(null);
 | 
					  const pickerButton = React.useRef<HTMLButtonElement>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
@@ -405,7 +284,6 @@ export const ColorPicker = ({
 | 
				
			|||||||
              label={label}
 | 
					              label={label}
 | 
				
			||||||
              showInput={false}
 | 
					              showInput={false}
 | 
				
			||||||
              type={type}
 | 
					              type={type}
 | 
				
			||||||
              elements={elements}
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </Popover>
 | 
					          </Popover>
 | 
				
			||||||
        ) : null}
 | 
					        ) : null}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,37 +0,0 @@
 | 
				
			|||||||
@import "../css/variables.module";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.excalidraw {
 | 
					 | 
				
			||||||
  .confirm-dialog {
 | 
					 | 
				
			||||||
    &-buttons {
 | 
					 | 
				
			||||||
      display: flex;
 | 
					 | 
				
			||||||
      padding: 0.2rem 0;
 | 
					 | 
				
			||||||
      justify-content: flex-end;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    .ToolIcon__icon {
 | 
					 | 
				
			||||||
      min-width: 2.5rem;
 | 
					 | 
				
			||||||
      width: auto;
 | 
					 | 
				
			||||||
      font-size: 1rem;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .ToolIcon_type_button {
 | 
					 | 
				
			||||||
      margin-left: 0.8rem;
 | 
					 | 
				
			||||||
      padding: 0 0.5rem;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &__content {
 | 
					 | 
				
			||||||
      font-size: 1rem;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &--confirm.ToolIcon_type_button {
 | 
					 | 
				
			||||||
      background-color: $oc-red-6;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      &:hover {
 | 
					 | 
				
			||||||
        background-color: $oc-red-8;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      .ToolIcon__icon {
 | 
					 | 
				
			||||||
        color: $oc-white;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,52 +0,0 @@
 | 
				
			|||||||
import { t } from "../i18n";
 | 
					 | 
				
			||||||
import { Dialog, DialogProps } from "./Dialog";
 | 
					 | 
				
			||||||
import { ToolButton } from "./ToolButton";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import "./ConfirmDialog.scss";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
 | 
					 | 
				
			||||||
  onConfirm: () => void;
 | 
					 | 
				
			||||||
  onCancel: () => void;
 | 
					 | 
				
			||||||
  confirmText?: string;
 | 
					 | 
				
			||||||
  cancelText?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
const ConfirmDialog = (props: Props) => {
 | 
					 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    onConfirm,
 | 
					 | 
				
			||||||
    onCancel,
 | 
					 | 
				
			||||||
    children,
 | 
					 | 
				
			||||||
    confirmText = t("buttons.confirm"),
 | 
					 | 
				
			||||||
    cancelText = t("buttons.cancel"),
 | 
					 | 
				
			||||||
    className = "",
 | 
					 | 
				
			||||||
    ...rest
 | 
					 | 
				
			||||||
  } = props;
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <Dialog
 | 
					 | 
				
			||||||
      onCloseRequest={onCancel}
 | 
					 | 
				
			||||||
      small={true}
 | 
					 | 
				
			||||||
      {...rest}
 | 
					 | 
				
			||||||
      className={`confirm-dialog ${className}`}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      {children}
 | 
					 | 
				
			||||||
      <div className="confirm-dialog-buttons">
 | 
					 | 
				
			||||||
        <ToolButton
 | 
					 | 
				
			||||||
          type="button"
 | 
					 | 
				
			||||||
          title={cancelText}
 | 
					 | 
				
			||||||
          aria-label={cancelText}
 | 
					 | 
				
			||||||
          label={cancelText}
 | 
					 | 
				
			||||||
          onClick={onCancel}
 | 
					 | 
				
			||||||
          className="confirm-dialog--cancel"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
        <ToolButton
 | 
					 | 
				
			||||||
          type="button"
 | 
					 | 
				
			||||||
          title={confirmText}
 | 
					 | 
				
			||||||
          aria-label={confirmText}
 | 
					 | 
				
			||||||
          label={confirmText}
 | 
					 | 
				
			||||||
          onClick={onConfirm}
 | 
					 | 
				
			||||||
          className="confirm-dialog--confirm"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </Dialog>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
export default ConfirmDialog;
 | 
					 | 
				
			||||||
@@ -76,7 +76,7 @@
 | 
				
			|||||||
    z-index: 1;
 | 
					    z-index: 1;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @include isMobile {
 | 
					  @media #{$is-mobile-query} {
 | 
				
			||||||
    .context-menu-option {
 | 
					    .context-menu-option {
 | 
				
			||||||
      display: block;
 | 
					      display: block;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
					import { render, unmountComponentAtNode } from "react-dom";
 | 
				
			||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
import { Popover } from "./Popover";
 | 
					import { Popover } from "./Popover";
 | 
				
			||||||
@@ -11,7 +12,6 @@ import {
 | 
				
			|||||||
import { Action } from "../actions/types";
 | 
					import { Action } from "../actions/types";
 | 
				
			||||||
import { ActionManager } from "../actions/manager";
 | 
					import { ActionManager } from "../actions/manager";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					import { AppState } from "../types";
 | 
				
			||||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ContextMenuOption = "separator" | Action;
 | 
					export type ContextMenuOption = "separator" | Action;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,7 +22,6 @@ type ContextMenuProps = {
 | 
				
			|||||||
  left: number;
 | 
					  left: number;
 | 
				
			||||||
  actionManager: ActionManager;
 | 
					  actionManager: ActionManager;
 | 
				
			||||||
  appState: Readonly<AppState>;
 | 
					  appState: Readonly<AppState>;
 | 
				
			||||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ContextMenu = ({
 | 
					const ContextMenu = ({
 | 
				
			||||||
@@ -32,76 +31,68 @@ const ContextMenu = ({
 | 
				
			|||||||
  left,
 | 
					  left,
 | 
				
			||||||
  actionManager,
 | 
					  actionManager,
 | 
				
			||||||
  appState,
 | 
					  appState,
 | 
				
			||||||
  elements,
 | 
					 | 
				
			||||||
}: ContextMenuProps) => {
 | 
					}: ContextMenuProps) => {
 | 
				
			||||||
 | 
					  const isDarkTheme = !!document
 | 
				
			||||||
 | 
					    .querySelector(".excalidraw")
 | 
				
			||||||
 | 
					    ?.classList.contains("theme--dark");
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Popover
 | 
					    <div
 | 
				
			||||||
      onCloseRequest={onCloseRequest}
 | 
					      className={clsx("excalidraw", {
 | 
				
			||||||
      top={top}
 | 
					        "theme--dark theme--dark-background-none": isDarkTheme,
 | 
				
			||||||
      left={left}
 | 
					      })}
 | 
				
			||||||
      fitInViewport={true}
 | 
					 | 
				
			||||||
      offsetLeft={appState.offsetLeft}
 | 
					 | 
				
			||||||
      offsetTop={appState.offsetTop}
 | 
					 | 
				
			||||||
      viewportWidth={appState.width}
 | 
					 | 
				
			||||||
      viewportHeight={appState.height}
 | 
					 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <ul
 | 
					      <Popover
 | 
				
			||||||
        className="context-menu"
 | 
					        onCloseRequest={onCloseRequest}
 | 
				
			||||||
        onContextMenu={(event) => event.preventDefault()}
 | 
					        top={top}
 | 
				
			||||||
 | 
					        left={left}
 | 
				
			||||||
 | 
					        fitInViewport={true}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {options.map((option, idx) => {
 | 
					        <ul
 | 
				
			||||||
          if (option === "separator") {
 | 
					          className="context-menu"
 | 
				
			||||||
            return <hr key={idx} className="context-menu-option-separator" />;
 | 
					          onContextMenu={(event) => event.preventDefault()}
 | 
				
			||||||
          }
 | 
					        >
 | 
				
			||||||
 | 
					          {options.map((option, idx) => {
 | 
				
			||||||
          const actionName = option.name;
 | 
					            if (option === "separator") {
 | 
				
			||||||
          let label = "";
 | 
					              return <hr key={idx} className="context-menu-option-separator" />;
 | 
				
			||||||
          if (option.contextItemLabel) {
 | 
					 | 
				
			||||||
            if (typeof option.contextItemLabel === "function") {
 | 
					 | 
				
			||||||
              label = t(option.contextItemLabel(elements, appState));
 | 
					 | 
				
			||||||
            } else {
 | 
					 | 
				
			||||||
              label = t(option.contextItemLabel);
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          }
 | 
					
 | 
				
			||||||
          return (
 | 
					            const actionName = option.name;
 | 
				
			||||||
            <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
					            const label = option.contextItemLabel
 | 
				
			||||||
              <button
 | 
					              ? t(option.contextItemLabel)
 | 
				
			||||||
                className={clsx("context-menu-option", {
 | 
					              : "";
 | 
				
			||||||
                  dangerous: actionName === "deleteSelectedElements",
 | 
					            return (
 | 
				
			||||||
                  checkmark: option.checked?.(appState),
 | 
					              <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
				
			||||||
                })}
 | 
					                <button
 | 
				
			||||||
                onClick={() =>
 | 
					                  className={clsx("context-menu-option", {
 | 
				
			||||||
                  actionManager.executeAction(option, "contextMenu")
 | 
					                    dangerous: actionName === "deleteSelectedElements",
 | 
				
			||||||
                }
 | 
					                    checkmark: option.checked?.(appState),
 | 
				
			||||||
              >
 | 
					                  })}
 | 
				
			||||||
                <div className="context-menu-option__label">{label}</div>
 | 
					                  onClick={() => actionManager.executeAction(option)}
 | 
				
			||||||
                <kbd className="context-menu-option__shortcut">
 | 
					                >
 | 
				
			||||||
                  {actionName
 | 
					                  <div className="context-menu-option__label">{label}</div>
 | 
				
			||||||
                    ? getShortcutFromShortcutName(actionName as ShortcutName)
 | 
					                  <kbd className="context-menu-option__shortcut">
 | 
				
			||||||
                    : ""}
 | 
					                    {actionName
 | 
				
			||||||
                </kbd>
 | 
					                      ? getShortcutFromShortcutName(actionName as ShortcutName)
 | 
				
			||||||
              </button>
 | 
					                      : ""}
 | 
				
			||||||
            </li>
 | 
					                  </kbd>
 | 
				
			||||||
          );
 | 
					                </button>
 | 
				
			||||||
        })}
 | 
					              </li>
 | 
				
			||||||
      </ul>
 | 
					            );
 | 
				
			||||||
    </Popover>
 | 
					          })}
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      </Popover>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
 | 
					let contextMenuNode: HTMLDivElement;
 | 
				
			||||||
 | 
					const getContextMenuNode = (): HTMLDivElement => {
 | 
				
			||||||
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
 | 
					 | 
				
			||||||
  let contextMenuNode = contextMenuNodeByContainer.get(container);
 | 
					 | 
				
			||||||
  if (contextMenuNode) {
 | 
					  if (contextMenuNode) {
 | 
				
			||||||
    return contextMenuNode;
 | 
					    return contextMenuNode;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  contextMenuNode = document.createElement("div");
 | 
					  const div = document.createElement("div");
 | 
				
			||||||
  container
 | 
					  document.body.appendChild(div);
 | 
				
			||||||
    .querySelector(".excalidraw-contextMenuContainer")!
 | 
					  return (contextMenuNode = div);
 | 
				
			||||||
    .appendChild(contextMenuNode);
 | 
					 | 
				
			||||||
  contextMenuNodeByContainer.set(container, contextMenuNode);
 | 
					 | 
				
			||||||
  return contextMenuNode;
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ContextMenuParams = {
 | 
					type ContextMenuParams = {
 | 
				
			||||||
@@ -110,17 +101,10 @@ type ContextMenuParams = {
 | 
				
			|||||||
  left: ContextMenuProps["left"];
 | 
					  left: ContextMenuProps["left"];
 | 
				
			||||||
  actionManager: ContextMenuProps["actionManager"];
 | 
					  actionManager: ContextMenuProps["actionManager"];
 | 
				
			||||||
  appState: Readonly<AppState>;
 | 
					  appState: Readonly<AppState>;
 | 
				
			||||||
  container: HTMLElement;
 | 
					 | 
				
			||||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleClose = (container: HTMLElement) => {
 | 
					const handleClose = () => {
 | 
				
			||||||
  const contextMenuNode = contextMenuNodeByContainer.get(container);
 | 
					  unmountComponentAtNode(getContextMenuNode());
 | 
				
			||||||
  if (contextMenuNode) {
 | 
					 | 
				
			||||||
    unmountComponentAtNode(contextMenuNode);
 | 
					 | 
				
			||||||
    contextMenuNode.remove();
 | 
					 | 
				
			||||||
    contextMenuNodeByContainer.delete(container);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
@@ -137,12 +121,11 @@ export default {
 | 
				
			|||||||
          top={params.top}
 | 
					          top={params.top}
 | 
				
			||||||
          left={params.left}
 | 
					          left={params.left}
 | 
				
			||||||
          options={options}
 | 
					          options={options}
 | 
				
			||||||
          onCloseRequest={() => handleClose(params.container)}
 | 
					          onCloseRequest={handleClose}
 | 
				
			||||||
          actionManager={params.actionManager}
 | 
					          actionManager={params.actionManager}
 | 
				
			||||||
          appState={params.appState}
 | 
					          appState={params.appState}
 | 
				
			||||||
          elements={params.elements}
 | 
					 | 
				
			||||||
        />,
 | 
					        />,
 | 
				
			||||||
        getContextMenuNode(params.container),
 | 
					        getContextMenuNode(),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,32 +1,42 @@
 | 
				
			|||||||
import "./ToolIcon.scss";
 | 
					import "./ToolIcon.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { ToolButton } from "./ToolButton";
 | 
					
 | 
				
			||||||
import { THEME } from "../constants";
 | 
					export type Appearence = "light" | "dark";
 | 
				
			||||||
import { Theme } from "../element/types";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// We chose to use only explicit toggle and not a third option for system value,
 | 
					// We chose to use only explicit toggle and not a third option for system value,
 | 
				
			||||||
// but this could be added in the future.
 | 
					// but this could be added in the future.
 | 
				
			||||||
export const DarkModeToggle = (props: {
 | 
					export const DarkModeToggle = (props: {
 | 
				
			||||||
  value: Theme;
 | 
					  value: Appearence;
 | 
				
			||||||
  onChange: (value: Theme) => void;
 | 
					  onChange: (value: Appearence) => void;
 | 
				
			||||||
  title?: string;
 | 
					  title?: string;
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const title =
 | 
					  const title = props.title
 | 
				
			||||||
    props.title ||
 | 
					    ? props.title
 | 
				
			||||||
    (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
 | 
					    : props.value === "dark"
 | 
				
			||||||
 | 
					    ? t("buttons.lightMode")
 | 
				
			||||||
 | 
					    : t("buttons.darkMode");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <ToolButton
 | 
					    <label
 | 
				
			||||||
      type="icon"
 | 
					      className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
 | 
				
			||||||
      icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
 | 
					 | 
				
			||||||
      title={title}
 | 
					 | 
				
			||||||
      aria-label={title}
 | 
					 | 
				
			||||||
      onClick={() =>
 | 
					 | 
				
			||||||
        props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      data-testid="toggle-dark-mode"
 | 
					      data-testid="toggle-dark-mode"
 | 
				
			||||||
    />
 | 
					      title={title}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <input
 | 
				
			||||||
 | 
					        className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
 | 
				
			||||||
 | 
					        type="checkbox"
 | 
				
			||||||
 | 
					        onChange={(event) =>
 | 
				
			||||||
 | 
					          props.onChange(event.target.checked ? "dark" : "light")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        checked={props.value === "dark"}
 | 
				
			||||||
 | 
					        aria-label={title}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <div className="ToolIcon__icon">
 | 
				
			||||||
 | 
					        {props.value === "light" ? ICONS.MOON : ICONS.SUN}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,7 +31,7 @@
 | 
				
			|||||||
    padding: 0 16px 16px;
 | 
					    padding: 0 16px 16px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @include isMobile {
 | 
					  @media #{$is-mobile-query} {
 | 
				
			||||||
    .Dialog {
 | 
					    .Dialog {
 | 
				
			||||||
      --metric: calc(var(--space-factor) * 4);
 | 
					      --metric: calc(var(--space-factor) * 4);
 | 
				
			||||||
      --inset-left: #{"max(var(--metric), var(--sal))"};
 | 
					      --inset-left: #{"max(var(--metric), var(--sal))"};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,30 +1,23 @@
 | 
				
			|||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
import React, { useEffect, useState } from "react";
 | 
					import React, { useEffect } from "react";
 | 
				
			||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
					import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
				
			||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
import { useExcalidrawContainer, useDeviceType } from "../components/App";
 | 
					import useIsMobile from "../is-mobile";
 | 
				
			||||||
import { KEYS } from "../keys";
 | 
					import { KEYS } from "../keys";
 | 
				
			||||||
import "./Dialog.scss";
 | 
					import "./Dialog.scss";
 | 
				
			||||||
import { back, close } from "./icons";
 | 
					import { back, close } from "./icons";
 | 
				
			||||||
import { Island } from "./Island";
 | 
					import { Island } from "./Island";
 | 
				
			||||||
import { Modal } from "./Modal";
 | 
					import { Modal } from "./Modal";
 | 
				
			||||||
import { AppState } from "../types";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface DialogProps {
 | 
					export const Dialog = (props: {
 | 
				
			||||||
  children: React.ReactNode;
 | 
					  children: React.ReactNode;
 | 
				
			||||||
  className?: string;
 | 
					  className?: string;
 | 
				
			||||||
  small?: boolean;
 | 
					  small?: boolean;
 | 
				
			||||||
  onCloseRequest(): void;
 | 
					  onCloseRequest(): void;
 | 
				
			||||||
  title: React.ReactNode;
 | 
					  title: React.ReactNode;
 | 
				
			||||||
  autofocus?: boolean;
 | 
					  autofocus?: boolean;
 | 
				
			||||||
  theme?: AppState["theme"];
 | 
					}) => {
 | 
				
			||||||
  closeOnClickOutside?: boolean;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const Dialog = (props: DialogProps) => {
 | 
					 | 
				
			||||||
  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
					  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
				
			||||||
  const [lastActiveElement] = useState(document.activeElement);
 | 
					 | 
				
			||||||
  const { id } = useExcalidrawContainer();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (!islandNode) {
 | 
					    if (!islandNode) {
 | 
				
			||||||
@@ -72,29 +65,22 @@ export const Dialog = (props: DialogProps) => {
 | 
				
			|||||||
    return focusableElements ? Array.from(focusableElements) : [];
 | 
					    return focusableElements ? Array.from(focusableElements) : [];
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onClose = () => {
 | 
					 | 
				
			||||||
    (lastActiveElement as HTMLElement).focus();
 | 
					 | 
				
			||||||
    props.onCloseRequest();
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Modal
 | 
					    <Modal
 | 
				
			||||||
      className={clsx("Dialog", props.className)}
 | 
					      className={clsx("Dialog", props.className)}
 | 
				
			||||||
      labelledBy="dialog-title"
 | 
					      labelledBy="dialog-title"
 | 
				
			||||||
      maxWidth={props.small ? 550 : 800}
 | 
					      maxWidth={props.small ? 550 : 800}
 | 
				
			||||||
      onCloseRequest={onClose}
 | 
					      onCloseRequest={props.onCloseRequest}
 | 
				
			||||||
      theme={props.theme}
 | 
					 | 
				
			||||||
      closeOnClickOutside={props.closeOnClickOutside}
 | 
					 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <Island ref={setIslandNode}>
 | 
					      <Island ref={setIslandNode}>
 | 
				
			||||||
        <h2 id={`${id}-dialog-title`} className="Dialog__title">
 | 
					        <h2 id="dialog-title" className="Dialog__title">
 | 
				
			||||||
          <span className="Dialog__titleContent">{props.title}</span>
 | 
					          <span className="Dialog__titleContent">{props.title}</span>
 | 
				
			||||||
          <button
 | 
					          <button
 | 
				
			||||||
            className="Modal__close"
 | 
					            className="Modal__close"
 | 
				
			||||||
            onClick={onClose}
 | 
					            onClick={props.onCloseRequest}
 | 
				
			||||||
            aria-label={t("buttons.close")}
 | 
					            aria-label={t("buttons.close")}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            {useDeviceType().isMobile ? back : close}
 | 
					            {useIsMobile() ? back : close}
 | 
				
			||||||
          </button>
 | 
					          </button>
 | 
				
			||||||
        </h2>
 | 
					        </h2>
 | 
				
			||||||
        <div className="Dialog__content">{props.children}</div>
 | 
					        <div className="Dialog__content">{props.children}</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,6 @@ import React, { useState } from "react";
 | 
				
			|||||||
import { t } from "../i18n";
 | 
					import { t } from "../i18n";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Dialog } from "./Dialog";
 | 
					import { Dialog } from "./Dialog";
 | 
				
			||||||
import { useExcalidrawContainer } from "./App";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ErrorDialog = ({
 | 
					export const ErrorDialog = ({
 | 
				
			||||||
  message,
 | 
					  message,
 | 
				
			||||||
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
 | 
				
			|||||||
  onClose?: () => void;
 | 
					  onClose?: () => void;
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const [modalIsShown, setModalIsShown] = useState(!!message);
 | 
					  const [modalIsShown, setModalIsShown] = useState(!!message);
 | 
				
			||||||
  const { container: excalidrawContainer } = useExcalidrawContainer();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleClose = React.useCallback(() => {
 | 
					  const handleClose = React.useCallback(() => {
 | 
				
			||||||
    setModalIsShown(false);
 | 
					    setModalIsShown(false);
 | 
				
			||||||
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
 | 
				
			|||||||
    if (onClose) {
 | 
					    if (onClose) {
 | 
				
			||||||
      onClose();
 | 
					      onClose();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    // TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
 | 
					  }, [onClose]);
 | 
				
			||||||
    excalidrawContainer?.focus();
 | 
					 | 
				
			||||||
  }, [onClose, excalidrawContainer]);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
@@ -32,7 +28,14 @@ export const ErrorDialog = ({
 | 
				
			|||||||
          onCloseRequest={handleClose}
 | 
					          onCloseRequest={handleClose}
 | 
				
			||||||
          title={t("errorDialog.title")}
 | 
					          title={t("errorDialog.title")}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
 | 
					          <div>
 | 
				
			||||||
 | 
					            {message.split("\n").map((line) => (
 | 
				
			||||||
 | 
					              <>
 | 
				
			||||||
 | 
					                {line}
 | 
				
			||||||
 | 
					                <br />
 | 
				
			||||||
 | 
					              </>
 | 
				
			||||||
 | 
					            ))}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
        </Dialog>
 | 
					        </Dialog>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,7 +28,34 @@
 | 
				
			|||||||
    justify-content: space-between;
 | 
					    justify-content: space-between;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @include isMobile {
 | 
					  .ExportDialog__name {
 | 
				
			||||||
 | 
					    grid-column: project-name;
 | 
				
			||||||
 | 
					    margin: auto;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .TextInput {
 | 
				
			||||||
 | 
					      height: calc(1rem - 3px);
 | 
				
			||||||
 | 
					      width: 200px;
 | 
				
			||||||
 | 
					      overflow: hidden;
 | 
				
			||||||
 | 
					      text-align: center;
 | 
				
			||||||
 | 
					      margin-left: 8px;
 | 
				
			||||||
 | 
					      text-overflow: ellipsis;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &--readonly {
 | 
				
			||||||
 | 
					        background: none;
 | 
				
			||||||
 | 
					        border: none;
 | 
				
			||||||
 | 
					        &:hover {
 | 
				
			||||||
 | 
					          background: none;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        width: auto;
 | 
				
			||||||
 | 
					        max-width: 200px;
 | 
				
			||||||
 | 
					        padding-left: 2px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media #{$is-mobile-query} {
 | 
				
			||||||
    .ExportDialog {
 | 
					    .ExportDialog {
 | 
				
			||||||
      display: flex;
 | 
					      display: flex;
 | 
				
			||||||
      flex-direction: column;
 | 
					      flex-direction: column;
 | 
				
			||||||
@@ -57,63 +84,4 @@
 | 
				
			|||||||
      overflow-y: auto;
 | 
					      overflow-y: auto;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  .ExportDialog--json {
 | 
					 | 
				
			||||||
    .ExportDialog-cards {
 | 
					 | 
				
			||||||
      display: grid;
 | 
					 | 
				
			||||||
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 | 
					 | 
				
			||||||
      justify-items: center;
 | 
					 | 
				
			||||||
      row-gap: 2em;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      @media (max-width: 460px) {
 | 
					 | 
				
			||||||
        grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
 | 
					 | 
				
			||||||
        .Card-details {
 | 
					 | 
				
			||||||
          min-height: 40px;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      .ProjectName {
 | 
					 | 
				
			||||||
        width: fit-content;
 | 
					 | 
				
			||||||
        margin: 1em auto;
 | 
					 | 
				
			||||||
        align-items: flex-start;
 | 
					 | 
				
			||||||
        flex-direction: column;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        .TextInput {
 | 
					 | 
				
			||||||
          width: auto;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      .ProjectName-label {
 | 
					 | 
				
			||||||
        margin: 0.625em 0;
 | 
					 | 
				
			||||||
        font-weight: bold;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  button.ExportDialog-imageExportButton {
 | 
					 | 
				
			||||||
    width: 5rem;
 | 
					 | 
				
			||||||
    height: 5rem;
 | 
					 | 
				
			||||||
    margin: 0 0.2em;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    border-radius: 1rem;
 | 
					 | 
				
			||||||
    background-color: var(--button-color);
 | 
					 | 
				
			||||||
    box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
 | 
					 | 
				
			||||||
      0 6px 10px 0 rgba(0, 0, 0, 0.14);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    font-family: Cascadia;
 | 
					 | 
				
			||||||
    font-size: 1.8em;
 | 
					 | 
				
			||||||
    color: $oc-white;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &:hover {
 | 
					 | 
				
			||||||
      background-color: var(--button-color-darker);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    &:active {
 | 
					 | 
				
			||||||
      background-color: var(--button-color-darkest);
 | 
					 | 
				
			||||||
      box-shadow: none;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    svg {
 | 
					 | 
				
			||||||
      width: 0.9em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										285
									
								
								src/components/ExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								src/components/ExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,285 @@
 | 
				
			|||||||
 | 
					import React, { useEffect, useRef, useState } from "react";
 | 
				
			||||||
 | 
					import { render, unmountComponentAtNode } from "react-dom";
 | 
				
			||||||
 | 
					import { ActionsManagerInterface } from "../actions/types";
 | 
				
			||||||
 | 
					import { probablySupportsClipboardBlob } from "../clipboard";
 | 
				
			||||||
 | 
					import { canvasToBlob } from "../data/blob";
 | 
				
			||||||
 | 
					import { NonDeletedExcalidrawElement } from "../element/types";
 | 
				
			||||||
 | 
					import { CanvasError } from "../errors";
 | 
				
			||||||
 | 
					import { t } from "../i18n";
 | 
				
			||||||
 | 
					import useIsMobile from "../is-mobile";
 | 
				
			||||||
 | 
					import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
				
			||||||
 | 
					import { exportToCanvas, getExportSize } from "../scene/export";
 | 
				
			||||||
 | 
					import { AppState } from "../types";
 | 
				
			||||||
 | 
					import { Dialog } from "./Dialog";
 | 
				
			||||||
 | 
					import "./ExportDialog.scss";
 | 
				
			||||||
 | 
					import { clipboard, exportFile, link } from "./icons";
 | 
				
			||||||
 | 
					import Stack from "./Stack";
 | 
				
			||||||
 | 
					import { ToolButton } from "./ToolButton";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const scales = [1, 2, 3];
 | 
				
			||||||
 | 
					const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const supportsContextFilters =
 | 
				
			||||||
 | 
					  "filter" in document.createElement("canvas").getContext("2d")!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ErrorCanvasPreview = () => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <h3>{t("canvasError.cannotShowPreview")}</h3>
 | 
				
			||||||
 | 
					      <p>
 | 
				
			||||||
 | 
					        <span>{t("canvasError.canvasTooBig")}</span>
 | 
				
			||||||
 | 
					      </p>
 | 
				
			||||||
 | 
					      <em>({t("canvasError.canvasTooBigTip")})</em>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const renderPreview = (
 | 
				
			||||||
 | 
					  content: HTMLCanvasElement | Error,
 | 
				
			||||||
 | 
					  previewNode: HTMLDivElement,
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  unmountComponentAtNode(previewNode);
 | 
				
			||||||
 | 
					  previewNode.innerHTML = "";
 | 
				
			||||||
 | 
					  if (content instanceof HTMLCanvasElement) {
 | 
				
			||||||
 | 
					    previewNode.appendChild(content);
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    render(<ErrorCanvasPreview />, previewNode);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type ExportCB = (
 | 
				
			||||||
 | 
					  elements: readonly NonDeletedExcalidrawElement[],
 | 
				
			||||||
 | 
					  scale?: number,
 | 
				
			||||||
 | 
					) => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const ExportModal = ({
 | 
				
			||||||
 | 
					  elements,
 | 
				
			||||||
 | 
					  appState,
 | 
				
			||||||
 | 
					  exportPadding = 10,
 | 
				
			||||||
 | 
					  actionManager,
 | 
				
			||||||
 | 
					  onExportToPng,
 | 
				
			||||||
 | 
					  onExportToSvg,
 | 
				
			||||||
 | 
					  onExportToClipboard,
 | 
				
			||||||
 | 
					  onExportToBackend,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  appState: AppState;
 | 
				
			||||||
 | 
					  elements: readonly NonDeletedExcalidrawElement[];
 | 
				
			||||||
 | 
					  exportPadding?: number;
 | 
				
			||||||
 | 
					  actionManager: ActionsManagerInterface;
 | 
				
			||||||
 | 
					  onExportToPng: ExportCB;
 | 
				
			||||||
 | 
					  onExportToSvg: ExportCB;
 | 
				
			||||||
 | 
					  onExportToClipboard: ExportCB;
 | 
				
			||||||
 | 
					  onExportToBackend?: ExportCB;
 | 
				
			||||||
 | 
					  onCloseRequest: () => void;
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					  const someElementIsSelected = isSomeElementSelected(elements, appState);
 | 
				
			||||||
 | 
					  const [scale, setScale] = useState(defaultScale);
 | 
				
			||||||
 | 
					  const [exportSelected, setExportSelected] = useState(someElementIsSelected);
 | 
				
			||||||
 | 
					  const previewRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
 | 
					  const {
 | 
				
			||||||
 | 
					    exportBackground,
 | 
				
			||||||
 | 
					    viewBackgroundColor,
 | 
				
			||||||
 | 
					    shouldAddWatermark,
 | 
				
			||||||
 | 
					  } = appState;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const exportedElements = exportSelected
 | 
				
			||||||
 | 
					    ? getSelectedElements(elements, appState)
 | 
				
			||||||
 | 
					    : elements;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    setExportSelected(someElementIsSelected);
 | 
				
			||||||
 | 
					  }, [someElementIsSelected]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const previewNode = previewRef.current;
 | 
				
			||||||
 | 
					    if (!previewNode) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const canvas = exportToCanvas(exportedElements, appState, {
 | 
				
			||||||
 | 
					        exportBackground,
 | 
				
			||||||
 | 
					        viewBackgroundColor,
 | 
				
			||||||
 | 
					        exportPadding,
 | 
				
			||||||
 | 
					        scale,
 | 
				
			||||||
 | 
					        shouldAddWatermark,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // if converting to blob fails, there's some problem that will
 | 
				
			||||||
 | 
					      // likely prevent preview and export (e.g. canvas too big)
 | 
				
			||||||
 | 
					      canvasToBlob(canvas)
 | 
				
			||||||
 | 
					        .then(() => {
 | 
				
			||||||
 | 
					          renderPreview(canvas, previewNode);
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .catch((error) => {
 | 
				
			||||||
 | 
					          console.error(error);
 | 
				
			||||||
 | 
					          renderPreview(new CanvasError(), previewNode);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					      renderPreview(new CanvasError(), previewNode);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [
 | 
				
			||||||
 | 
					    appState,
 | 
				
			||||||
 | 
					    exportedElements,
 | 
				
			||||||
 | 
					    exportBackground,
 | 
				
			||||||
 | 
					    exportPadding,
 | 
				
			||||||
 | 
					    viewBackgroundColor,
 | 
				
			||||||
 | 
					    scale,
 | 
				
			||||||
 | 
					    shouldAddWatermark,
 | 
				
			||||||
 | 
					  ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="ExportDialog">
 | 
				
			||||||
 | 
					      <div className="ExportDialog__preview" ref={previewRef} />
 | 
				
			||||||
 | 
					      {supportsContextFilters &&
 | 
				
			||||||
 | 
					        actionManager.renderAction("exportWithDarkMode")}
 | 
				
			||||||
 | 
					      <Stack.Col gap={2} align="center">
 | 
				
			||||||
 | 
					        <div className="ExportDialog__actions">
 | 
				
			||||||
 | 
					          <Stack.Row gap={2}>
 | 
				
			||||||
 | 
					            <ToolButton
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					              label="PNG"
 | 
				
			||||||
 | 
					              title={t("buttons.exportToPng")}
 | 
				
			||||||
 | 
					              aria-label={t("buttons.exportToPng")}
 | 
				
			||||||
 | 
					              onClick={() => onExportToPng(exportedElements, scale)}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <ToolButton
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					              label="SVG"
 | 
				
			||||||
 | 
					              title={t("buttons.exportToSvg")}
 | 
				
			||||||
 | 
					              aria-label={t("buttons.exportToSvg")}
 | 
				
			||||||
 | 
					              onClick={() => onExportToSvg(exportedElements, scale)}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            {probablySupportsClipboardBlob && (
 | 
				
			||||||
 | 
					              <ToolButton
 | 
				
			||||||
 | 
					                type="button"
 | 
				
			||||||
 | 
					                icon={clipboard}
 | 
				
			||||||
 | 
					                title={t("buttons.copyPngToClipboard")}
 | 
				
			||||||
 | 
					                aria-label={t("buttons.copyPngToClipboard")}
 | 
				
			||||||
 | 
					                onClick={() => onExportToClipboard(exportedElements, scale)}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					            {onExportToBackend && (
 | 
				
			||||||
 | 
					              <ToolButton
 | 
				
			||||||
 | 
					                type="button"
 | 
				
			||||||
 | 
					                icon={link}
 | 
				
			||||||
 | 
					                title={t("buttons.getShareableLink")}
 | 
				
			||||||
 | 
					                aria-label={t("buttons.getShareableLink")}
 | 
				
			||||||
 | 
					                onClick={() => onExportToBackend(exportedElements)}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          </Stack.Row>
 | 
				
			||||||
 | 
					          <div className="ExportDialog__name">
 | 
				
			||||||
 | 
					            {actionManager.renderAction("changeProjectName")}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <Stack.Row gap={2}>
 | 
				
			||||||
 | 
					            {scales.map((s) => {
 | 
				
			||||||
 | 
					              const [width, height] = getExportSize(
 | 
				
			||||||
 | 
					                exportedElements,
 | 
				
			||||||
 | 
					                exportPadding,
 | 
				
			||||||
 | 
					                shouldAddWatermark,
 | 
				
			||||||
 | 
					                s,
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              const scaleButtonTitle = `${t(
 | 
				
			||||||
 | 
					                "buttons.scale",
 | 
				
			||||||
 | 
					              )} ${s}x (${width}x${height})`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              return (
 | 
				
			||||||
 | 
					                <ToolButton
 | 
				
			||||||
 | 
					                  key={s}
 | 
				
			||||||
 | 
					                  size="s"
 | 
				
			||||||
 | 
					                  type="radio"
 | 
				
			||||||
 | 
					                  icon={`${s}x`}
 | 
				
			||||||
 | 
					                  name="export-canvas-scale"
 | 
				
			||||||
 | 
					                  title={scaleButtonTitle}
 | 
				
			||||||
 | 
					                  aria-label={scaleButtonTitle}
 | 
				
			||||||
 | 
					                  id="export-canvas-scale"
 | 
				
			||||||
 | 
					                  checked={s === scale}
 | 
				
			||||||
 | 
					                  onChange={() => setScale(s)}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            })}
 | 
				
			||||||
 | 
					          </Stack.Row>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        {actionManager.renderAction("changeExportBackground")}
 | 
				
			||||||
 | 
					        {someElementIsSelected && (
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <label>
 | 
				
			||||||
 | 
					              <input
 | 
				
			||||||
 | 
					                type="checkbox"
 | 
				
			||||||
 | 
					                checked={exportSelected}
 | 
				
			||||||
 | 
					                onChange={(event) =>
 | 
				
			||||||
 | 
					                  setExportSelected(event.currentTarget.checked)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              />{" "}
 | 
				
			||||||
 | 
					              {t("labels.onlySelected")}
 | 
				
			||||||
 | 
					            </label>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					        {actionManager.renderAction("changeExportEmbedScene")}
 | 
				
			||||||
 | 
					        {actionManager.renderAction("changeShouldAddWatermark")}
 | 
				
			||||||
 | 
					      </Stack.Col>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ExportDialog = ({
 | 
				
			||||||
 | 
					  elements,
 | 
				
			||||||
 | 
					  appState,
 | 
				
			||||||
 | 
					  exportPadding = 10,
 | 
				
			||||||
 | 
					  actionManager,
 | 
				
			||||||
 | 
					  onExportToPng,
 | 
				
			||||||
 | 
					  onExportToSvg,
 | 
				
			||||||
 | 
					  onExportToClipboard,
 | 
				
			||||||
 | 
					  onExportToBackend,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  appState: AppState;
 | 
				
			||||||
 | 
					  elements: readonly NonDeletedExcalidrawElement[];
 | 
				
			||||||
 | 
					  exportPadding?: number;
 | 
				
			||||||
 | 
					  actionManager: ActionsManagerInterface;
 | 
				
			||||||
 | 
					  onExportToPng: ExportCB;
 | 
				
			||||||
 | 
					  onExportToSvg: ExportCB;
 | 
				
			||||||
 | 
					  onExportToClipboard: ExportCB;
 | 
				
			||||||
 | 
					  onExportToBackend?: ExportCB;
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					  const [modalIsShown, setModalIsShown] = useState(false);
 | 
				
			||||||
 | 
					  const triggerButton = useRef<HTMLButtonElement>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleClose = React.useCallback(() => {
 | 
				
			||||||
 | 
					    setModalIsShown(false);
 | 
				
			||||||
 | 
					    triggerButton.current?.focus();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <ToolButton
 | 
				
			||||||
 | 
					        onClick={() => {
 | 
				
			||||||
 | 
					          setModalIsShown(true);
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        data-testid="export-button"
 | 
				
			||||||
 | 
					        icon={exportFile}
 | 
				
			||||||
 | 
					        type="button"
 | 
				
			||||||
 | 
					        aria-label={t("buttons.export")}
 | 
				
			||||||
 | 
					        showAriaLabel={useIsMobile()}
 | 
				
			||||||
 | 
					        title={t("buttons.export")}
 | 
				
			||||||
 | 
					        ref={triggerButton}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      {modalIsShown && (
 | 
				
			||||||
 | 
					        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
 | 
				
			||||||
 | 
					          <ExportModal
 | 
				
			||||||
 | 
					            elements={elements}
 | 
				
			||||||
 | 
					            appState={appState}
 | 
				
			||||||
 | 
					            exportPadding={exportPadding}
 | 
				
			||||||
 | 
					            actionManager={actionManager}
 | 
				
			||||||
 | 
					            onExportToPng={onExportToPng}
 | 
				
			||||||
 | 
					            onExportToSvg={onExportToSvg}
 | 
				
			||||||
 | 
					            onExportToClipboard={onExportToClipboard}
 | 
				
			||||||
 | 
					            onExportToBackend={onExportToBackend}
 | 
				
			||||||
 | 
					            onCloseRequest={handleClose}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </Dialog>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
.excalidraw {
 | 
					.excalidraw {
 | 
				
			||||||
  .FixedSideContainer {
 | 
					  .FixedSideContainer {
 | 
				
			||||||
 | 
					    --margin: 0.25rem;
 | 
				
			||||||
    position: absolute;
 | 
					    position: absolute;
 | 
				
			||||||
    pointer-events: none;
 | 
					    pointer-events: none;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -9,9 +10,9 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .FixedSideContainer_side_top {
 | 
					  .FixedSideContainer_side_top {
 | 
				
			||||||
    left: var(--space-factor);
 | 
					    left: var(--margin);
 | 
				
			||||||
    top: var(--space-factor);
 | 
					    top: var(--margin);
 | 
				
			||||||
    right: var(--space-factor);
 | 
					    right: var(--margin);
 | 
				
			||||||
    z-index: 2;
 | 
					    z-index: 2;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,16 +23,16 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/* TODO: if these are used, make sure to implement RTL support
 | 
					/* TODO: if these are used, make sure to implement RTL support
 | 
				
			||||||
.FixedSideContainer_side_left {
 | 
					.FixedSideContainer_side_left {
 | 
				
			||||||
  left: var(--space-factor);
 | 
					  left: var(--margin);
 | 
				
			||||||
  top: var(--space-factor);
 | 
					  top: var(--margin);
 | 
				
			||||||
  bottom: var(--space-factor);
 | 
					  bottom: var(--margin);
 | 
				
			||||||
  z-index: 1;
 | 
					  z-index: 1;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.FixedSideContainer_side_right {
 | 
					.FixedSideContainer_side_right {
 | 
				
			||||||
  right: var(--space-factor);
 | 
					  right: var(--margin);
 | 
				
			||||||
  top: var(--space-factor);
 | 
					  top: var(--margin);
 | 
				
			||||||
  bottom: var(--space-factor);
 | 
					  bottom: var(--margin);
 | 
				
			||||||
  z-index: 3;
 | 
					  z-index: 3;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,22 +1,15 @@
 | 
				
			|||||||
import oc from "open-color";
 | 
					import oc from "open-color";
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { THEME } from "../../constants";
 | 
					 | 
				
			||||||
import { Theme } from "../../element/types";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// https://github.com/tholman/github-corners
 | 
					// https://github.com/tholman/github-corners
 | 
				
			||||||
export const GitHubCorner = React.memo(
 | 
					export const GitHubCorner = React.memo(
 | 
				
			||||||
  ({ theme, dir }: { theme: Theme; dir: string }) => (
 | 
					  ({ theme }: { theme: "light" | "dark" }) => (
 | 
				
			||||||
    <svg
 | 
					    <svg
 | 
				
			||||||
      xmlns="http://www.w3.org/2000/svg"
 | 
					      xmlns="http://www.w3.org/2000/svg"
 | 
				
			||||||
      width="40"
 | 
					      width="40"
 | 
				
			||||||
      height="40"
 | 
					      height="40"
 | 
				
			||||||
      viewBox="0 0 250 250"
 | 
					      viewBox="0 0 250 250"
 | 
				
			||||||
      className="rtl-mirror"
 | 
					      className="github-corner rtl-mirror"
 | 
				
			||||||
      style={{
 | 
					 | 
				
			||||||
        marginTop: "calc(var(--space-factor) * -1)",
 | 
					 | 
				
			||||||
        [dir === "rtl" ? "marginLeft" : "marginRight"]:
 | 
					 | 
				
			||||||
          "calc(var(--space-factor) * -1)",
 | 
					 | 
				
			||||||
      }}
 | 
					 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <a
 | 
					      <a
 | 
				
			||||||
        href="https://github.com/excalidraw/excalidraw"
 | 
					        href="https://github.com/excalidraw/excalidraw"
 | 
				
			||||||
@@ -26,18 +19,18 @@ export const GitHubCorner = React.memo(
 | 
				
			|||||||
      >
 | 
					      >
 | 
				
			||||||
        <path
 | 
					        <path
 | 
				
			||||||
          d="M0 0l115 115h15l12 27 108 108V0z"
 | 
					          d="M0 0l115 115h15l12 27 108 108V0z"
 | 
				
			||||||
          fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
 | 
					          fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <path
 | 
					        <path
 | 
				
			||||||
          className="octo-arm"
 | 
					          className="octo-arm"
 | 
				
			||||||
          d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
 | 
					          d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
 | 
				
			||||||
          style={{ transformOrigin: "130px 106px" }}
 | 
					          style={{ transformOrigin: "130px 106px" }}
 | 
				
			||||||
          fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
 | 
					          fill={theme === "light" ? oc.white : oc.black}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <path
 | 
					        <path
 | 
				
			||||||
          className="octo-body"
 | 
					          className="octo-body"
 | 
				
			||||||
          d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
 | 
					          d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
 | 
				
			||||||
          fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
 | 
					          fill={theme === "light" ? oc.white : oc.black}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
    </svg>
 | 
					    </svg>
 | 
				
			||||||
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
        <Section title={t("helpDialog.shortcuts")}>
 | 
					        <Section title={t("helpDialog.shortcuts")}>
 | 
				
			||||||
          <Columns>
 | 
					          <Columns>
 | 
				
			||||||
            <Column>
 | 
					            <Column>
 | 
				
			||||||
              <ShortcutIsland caption={t("helpDialog.tools")}>
 | 
					              <ShortcutIsland caption={t("helpDialog.shapes")}>
 | 
				
			||||||
                <Shortcut
 | 
					                <Shortcut
 | 
				
			||||||
                  label={t("toolBar.selection")}
 | 
					                  label={t("toolBar.selection")}
 | 
				
			||||||
                  shortcuts={["V", "1"]}
 | 
					                  shortcuts={["V", "1"]}
 | 
				
			||||||
@@ -149,27 +149,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
                  shortcuts={["R", "2"]}
 | 
					                  shortcuts={["R", "2"]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
 | 
					                <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
 | 
				
			||||||
                <Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
 | 
					                <Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
 | 
				
			||||||
                <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
					                <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
				
			||||||
                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
					                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
				
			||||||
                <Shortcut
 | 
					                <Shortcut
 | 
				
			||||||
                  label={t("toolBar.freedraw")}
 | 
					                  label={t("toolBar.draw")}
 | 
				
			||||||
                  shortcuts={["Shift + P", "X", "7"]}
 | 
					                  shortcuts={["Shift+P", "7"]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
					                <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
				
			||||||
                <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
 | 
					 | 
				
			||||||
                <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("toolBar.eraser")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("E")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("helpDialog.editSelectedShape")}
 | 
					 | 
				
			||||||
                  shortcuts={[
 | 
					 | 
				
			||||||
                    getShortcutKey("Enter"),
 | 
					 | 
				
			||||||
                    t("helpDialog.doubleClick"),
 | 
					 | 
				
			||||||
                  ]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					                <Shortcut
 | 
				
			||||||
                  label={t("helpDialog.textNewLine")}
 | 
					                  label={t("helpDialog.textNewLine")}
 | 
				
			||||||
                  shortcuts={[
 | 
					                  shortcuts={[
 | 
				
			||||||
@@ -209,10 +196,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
                  label={t("helpDialog.preventBinding")}
 | 
					                  label={t("helpDialog.preventBinding")}
 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd")]}
 | 
					                  shortcuts={[getShortcutKey("CtrlOrCmd")]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("toolBar.link")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              </ShortcutIsland>
 | 
					              </ShortcutIsland>
 | 
				
			||||||
              <ShortcutIsland caption={t("helpDialog.view")}>
 | 
					              <ShortcutIsland caption={t("helpDialog.view")}>
 | 
				
			||||||
                <Shortcut
 | 
					                <Shortcut
 | 
				
			||||||
@@ -248,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
                  label={t("labels.viewMode")}
 | 
					                  label={t("labels.viewMode")}
 | 
				
			||||||
                  shortcuts={[getShortcutKey("Alt+R")]}
 | 
					                  shortcuts={[getShortcutKey("Alt+R")]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.toggleTheme")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("Alt+Shift+D")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("stats.title")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("Alt+/")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              </ShortcutIsland>
 | 
					              </ShortcutIsland>
 | 
				
			||||||
            </Column>
 | 
					            </Column>
 | 
				
			||||||
            <Column>
 | 
					            <Column>
 | 
				
			||||||
@@ -268,18 +243,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
                  label={t("labels.multiSelect")}
 | 
					                  label={t("labels.multiSelect")}
 | 
				
			||||||
                  shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
 | 
					                  shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("helpDialog.deepSelect")}
 | 
					 | 
				
			||||||
                  shortcuts={[
 | 
					 | 
				
			||||||
                    getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
 | 
					 | 
				
			||||||
                  ]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("helpDialog.deepBoxSelect")}
 | 
					 | 
				
			||||||
                  shortcuts={[
 | 
					 | 
				
			||||||
                    getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
 | 
					 | 
				
			||||||
                  ]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					                <Shortcut
 | 
				
			||||||
                  label={t("labels.moveCanvas")}
 | 
					                  label={t("labels.moveCanvas")}
 | 
				
			||||||
                  shortcuts={[
 | 
					                  shortcuts={[
 | 
				
			||||||
@@ -363,10 +326,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
                    getShortcutKey(`Alt+${t("helpDialog.drag")}`),
 | 
					                    getShortcutKey(`Alt+${t("helpDialog.drag")}`),
 | 
				
			||||||
                  ]}
 | 
					                  ]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("helpDialog.toggleElementLock")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					                <Shortcut
 | 
				
			||||||
                  label={t("buttons.undo")}
 | 
					                  label={t("buttons.undo")}
 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
 | 
					                  shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
 | 
				
			||||||
@@ -390,30 +349,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
				
			|||||||
                  label={t("labels.ungroup")}
 | 
					                  label={t("labels.ungroup")}
 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
 | 
					                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.flipHorizontal")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("Shift+H")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.flipVertical")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("Shift+V")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.showStroke")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("S")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.showBackground")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("G")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.decreaseFontSize")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Shortcut
 | 
					 | 
				
			||||||
                  label={t("labels.increaseFontSize")}
 | 
					 | 
				
			||||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              </ShortcutIsland>
 | 
					              </ShortcutIsland>
 | 
				
			||||||
            </Column>
 | 
					            </Column>
 | 
				
			||||||
          </Columns>
 | 
					          </Columns>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
import { questionCircle } from "../components/icons";
 | 
					import { questionCircle } from "../components/icons";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type HelpIconProps = {
 | 
					type HelpIconProps = {
 | 
				
			||||||
@@ -8,13 +9,7 @@ type HelpIconProps = {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const HelpIcon = (props: HelpIconProps) => (
 | 
					export const HelpIcon = (props: HelpIconProps) => (
 | 
				
			||||||
  <button
 | 
					  <label title={`${props.title} — ?`} className="help-icon">
 | 
				
			||||||
    className="help-icon"
 | 
					    <div onClick={props.onClick}>{questionCircle}</div>
 | 
				
			||||||
    onClick={props.onClick}
 | 
					  </label>
 | 
				
			||||||
    type="button"
 | 
					 | 
				
			||||||
    title={`${props.title} — ?`}
 | 
					 | 
				
			||||||
    aria-label={props.title}
 | 
					 | 
				
			||||||
  >
 | 
					 | 
				
			||||||
    {questionCircle}
 | 
					 | 
				
			||||||
  </button>
 | 
					 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
 | 
				
			|||||||
    color: $oc-gray-6;
 | 
					    color: $oc-gray-6;
 | 
				
			||||||
    font-size: 0.8rem;
 | 
					    font-size: 0.8rem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @include isMobile {
 | 
					    @media #{$is-mobile-query} {
 | 
				
			||||||
      position: static;
 | 
					      position: static;
 | 
				
			||||||
      padding-right: 2em;
 | 
					      padding-right: 2em;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user