mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	Compare commits
	
		
			15 Commits
		
	
	
		
			draft/wond
			...
			kb/auto-sa
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					2bf886c941 | ||
| 
						 | 
					6215256787 | ||
| 
						 | 
					35d195e891 | ||
| 
						 | 
					9d3d7f3500 | ||
| 
						 | 
					0b32757085 | ||
| 
						 | 
					6442a45bd4 | ||
| 
						 | 
					d7a015cb3a | ||
| 
						 | 
					f68404fbed | ||
| 
						 | 
					01f5914a82 | ||
| 
						 | 
					5e1e16c150 | ||
| 
						 | 
					14537cbaba | ||
| 
						 | 
					92ac11c49d | ||
| 
						 | 
					90d68b3e0b | ||
| 
						 | 
					006aad052d | ||
| 
						 | 
					98a7707e26 | 
							
								
								
									
										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,8 +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
 | 
			
		||||
 | 
			
		||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
 | 
			
		||||
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,11 +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_SOCKET_SERVER_URL=https://oss-collab-us1.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"}'
 | 
			
		||||
 | 
			
		||||
# production-only vars
 | 
			
		||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
 | 
			
		||||
 
 | 
			
		||||
@@ -5,4 +5,3 @@ package-lock.json
 | 
			
		||||
firebase/
 | 
			
		||||
dist/
 | 
			
		||||
public/workbox
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": ["@excalidraw/eslint-config", "react-app"],
 | 
			
		||||
  "rules": {
 | 
			
		||||
    "import/no-anonymous-default-export": "off",
 | 
			
		||||
    "no-restricted-globals": "off"
 | 
			
		||||
    "import/no-anonymous-default-export": "off"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@@ -10,7 +10,6 @@ updates:
 | 
			
		||||
      - lipis
 | 
			
		||||
    assignees:
 | 
			
		||||
      - lipis
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/excalidraw/
 | 
			
		||||
@@ -22,7 +21,6 @@ updates:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    open-pull-requests-limit: 20
 | 
			
		||||
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: /src/packages/utils/
 | 
			
		||||
@@ -34,4 +32,3 @@ updates:
 | 
			
		||||
      - ad1992
 | 
			
		||||
    assignees:
 | 
			
		||||
      - 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 }}"
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/publish-docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -11,7 +11,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: docker/build-push-action@v2
 | 
			
		||||
      - uses: docker/build-push-action@v1
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -5,11 +5,9 @@
 | 
			
		||||
.env.test.local
 | 
			
		||||
.envrc
 | 
			
		||||
.eslintcache
 | 
			
		||||
.history
 | 
			
		||||
.idea
 | 
			
		||||
.vercel
 | 
			
		||||
.vscode
 | 
			
		||||
.yarn
 | 
			
		||||
*.log
 | 
			
		||||
*.tgz
 | 
			
		||||
build
 | 
			
		||||
@@ -23,7 +21,3 @@ static
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
src/packages/excalidraw/example/public/bundle.js
 | 
			
		||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
 | 
			
		||||
src/packages/excalidraw/example/public/excalidraw.development.js
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
#!/bin/sh
 | 
			
		||||
yarn lint-staged
 | 
			
		||||
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
 | 
			
		||||
COPY . .
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								README.md
									
									
									
									
									
								
							@@ -70,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.
 | 
			
		||||
 | 
			
		||||
> Note: Please ensure that the encryption key is 22 characters long.
 | 
			
		||||
 | 
			
		||||
## Shape libraries
 | 
			
		||||
 | 
			
		||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
 | 
			
		||||
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
 | 
			
		||||
#### Requirements
 | 
			
		||||
 | 
			
		||||
- [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)
 | 
			
		||||
 | 
			
		||||
#### Clone the repo
 | 
			
		||||
@@ -104,24 +102,6 @@ These instructions will get you a copy of the project up and running on your loc
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
| Command            | Description                       |
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,5 @@
 | 
			
		||||
  "firestore": {
 | 
			
		||||
    "rules": "firestore.rules",
 | 
			
		||||
    "indexes": "firestore.indexes.json"
 | 
			
		||||
  },
 | 
			
		||||
  "storage": {
 | 
			
		||||
    "rules": "storage.rules"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +0,0 @@
 | 
			
		||||
rules_version = '2';
 | 
			
		||||
service firebase.storage {
 | 
			
		||||
  match /b/{bucket}/o {
 | 
			
		||||
    match /{migrations} {
 | 
			
		||||
      match /{scenes}/{scene} {
 | 
			
		||||
      	allow get, write: if true;
 | 
			
		||||
        // redundant, but let's be explicit'
 | 
			
		||||
        allow list: if false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										65
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										65
									
								
								package.json
									
									
									
									
									
								
							@@ -19,28 +19,22 @@
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "@testing-library/jest-dom": "5.16.2",
 | 
			
		||||
    "@testing-library/react": "12.1.2",
 | 
			
		||||
    "@tldraw/vec": "1.4.3",
 | 
			
		||||
    "@types/jest": "27.4.0",
 | 
			
		||||
    "@types/pica": "5.1.3",
 | 
			
		||||
    "@types/react": "17.0.38",
 | 
			
		||||
    "@types/react-dom": "17.0.11",
 | 
			
		||||
    "@sentry/browser": "6.2.2",
 | 
			
		||||
    "@sentry/integrations": "6.2.1",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.10",
 | 
			
		||||
    "@testing-library/react": "11.2.5",
 | 
			
		||||
    "@types/jest": "26.0.22",
 | 
			
		||||
    "@types/react": "17.0.3",
 | 
			
		||||
    "@types/react-dom": "17.0.2",
 | 
			
		||||
    "@types/socket.io-client": "1.4.36",
 | 
			
		||||
    "browser-fs-access": "0.23.0",
 | 
			
		||||
    "browser-fs-access": "0.16.2",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "fake-indexeddb": "3.1.7",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.2",
 | 
			
		||||
    "idb-keyval": "6.0.3",
 | 
			
		||||
    "image-blob-reduce": "3.0.1",
 | 
			
		||||
    "firebase": "8.2.10",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.0.1",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "3.1.32",
 | 
			
		||||
    "open-color": "1.9.1",
 | 
			
		||||
    "nanoid": "3.1.22",
 | 
			
		||||
    "open-color": "1.8.0",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "perfect-freehand": "1.0.16",
 | 
			
		||||
    "png-chunk-text": "1.0.0",
 | 
			
		||||
    "png-chunks-encode": "1.0.0",
 | 
			
		||||
    "png-chunks-extract": "1.0.0",
 | 
			
		||||
@@ -49,37 +43,36 @@
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react-scripts": "4.0.3",
 | 
			
		||||
    "roughjs": "4.5.2",
 | 
			
		||||
    "sass": "1.49.7",
 | 
			
		||||
    "roughjs": "4.3.1",
 | 
			
		||||
    "sass": "1.32.8",
 | 
			
		||||
    "socket.io-client": "2.3.1",
 | 
			
		||||
    "typescript": "4.5.5"
 | 
			
		||||
    "typescript": "4.2.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
			
		||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
			
		||||
    "@types/chai": "4.3.0",
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/pako": "1.0.3",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.6",
 | 
			
		||||
    "chai": "4.3.6",
 | 
			
		||||
    "dotenv": "10.0.0",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "@types/pako": "1.0.1",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.5",
 | 
			
		||||
    "eslint-config-prettier": "8.1.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "firebase-tools": "9.23.0",
 | 
			
		||||
    "husky": "7.0.4",
 | 
			
		||||
    "firebase-tools": "9.6.1",
 | 
			
		||||
    "husky": "4.3.8",
 | 
			
		||||
    "jest-canvas-mock": "2.3.1",
 | 
			
		||||
    "lint-staged": "12.3.3",
 | 
			
		||||
    "lint-staged": "10.5.4",
 | 
			
		||||
    "pepjs": "0.5.3",
 | 
			
		||||
    "prettier": "2.5.1",
 | 
			
		||||
    "prettier": "2.2.1",
 | 
			
		||||
    "rewire": "5.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "resolutions": {
 | 
			
		||||
    "@typescript-eslint/typescript-estree": "5.10.2"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=14.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "homepage": ".",
 | 
			
		||||
  "husky": {
 | 
			
		||||
    "hooks": {
 | 
			
		||||
      "pre-commit": "lint-staged"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "jest": {
 | 
			
		||||
    "transformIgnorePatterns": [
 | 
			
		||||
      "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",
 | 
			
		||||
    "locales-coverage": "node scripts/build-locales-coverage.js",
 | 
			
		||||
    "locales-coverage:description": "node scripts/locales-coverage-description.js",
 | 
			
		||||
    "prepare": "husky install",
 | 
			
		||||
    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
 | 
			
		||||
    "start": "react-scripts start",
 | 
			
		||||
    "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:typecheck": "tsc",
 | 
			
		||||
    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
 | 
			
		||||
    "test": "yarn test:app",
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js"
 | 
			
		||||
    "test": "yarn test:app"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@@ -107,17 +107,15 @@
 | 
			
		||||
 | 
			
		||||
    <!-- FIXME: remove this when we update CRA (fix SW caching) -->
 | 
			
		||||
    <style>
 | 
			
		||||
      body,
 | 
			
		||||
      html {
 | 
			
		||||
      body {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
 | 
			
		||||
          Roboto, Helvetica, Arial, sans-serif;
 | 
			
		||||
        font-family: var(--ui-font);
 | 
			
		||||
        -webkit-text-size-adjust: 100%;
 | 
			
		||||
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        width: 100vw;
 | 
			
		||||
        height: 100vh;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .visually-hidden {
 | 
			
		||||
@@ -127,7 +125,6 @@
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        clip: rect(1px, 1px, 1px, 1px);
 | 
			
		||||
        white-space: nowrap; /* added line */
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage {
 | 
			
		||||
@@ -152,21 +149,6 @@
 | 
			
		||||
      }
 | 
			
		||||
      #root {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        -webkit-touch-callout: none;
 | 
			
		||||
        -webkit-user-select: none;
 | 
			
		||||
        -khtml-user-select: none;
 | 
			
		||||
        -moz-user-select: none;
 | 
			
		||||
        -ms-user-select: none;
 | 
			
		||||
        user-select: none;
 | 
			
		||||
 | 
			
		||||
        @media screen and (min-width: 1200px) {
 | 
			
		||||
          -webkit-touch-callout: default;
 | 
			
		||||
          -webkit-user-select: auto;
 | 
			
		||||
          -khtml-user-select: auto;
 | 
			
		||||
          -moz-user-select: auto;
 | 
			
		||||
          -ms-user-select: auto;
 | 
			
		||||
          user-select: auto;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
  </head>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,7 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "capture_links": "new_client",
 | 
			
		||||
  "share_target": {
 | 
			
		||||
    "action": "/web-share-target",
 | 
			
		||||
    "method": "POST",
 | 
			
		||||
 
 | 
			
		||||
@@ -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 files = readdirSync(`${__dirname}/../src/locales`);
 | 
			
		||||
 | 
			
		||||
const flatten = (object = {}, result = {}, extraKey = "") => {
 | 
			
		||||
  for (const key in object) {
 | 
			
		||||
    if (typeof object[key] !== "object") {
 | 
			
		||||
      result[extraKey + key] = object[key];
 | 
			
		||||
    } else {
 | 
			
		||||
      flatten(object[key], result, `${extraKey}${key}.`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
const flatten = (object) =>
 | 
			
		||||
  Object.keys(object).reduce(
 | 
			
		||||
    (initial, current) => ({ ...initial, ...object[current] }),
 | 
			
		||||
    {},
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
const locales = files.filter(
 | 
			
		||||
  (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 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(
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,10 @@ const THRESSHOLD = 85;
 | 
			
		||||
const crowdinMap = {
 | 
			
		||||
  "ar-SA": "en-ar",
 | 
			
		||||
  "bg-BG": "en-bg",
 | 
			
		||||
  "bn-BD": "en-bn",
 | 
			
		||||
  "ca-ES": "en-ca",
 | 
			
		||||
  "da-DK": "en-da",
 | 
			
		||||
  "de-DE": "en-de",
 | 
			
		||||
  "el-GR": "en-el",
 | 
			
		||||
  "es-ES": "en-es",
 | 
			
		||||
  "eu-ES": "en-eu",
 | 
			
		||||
  "fa-IR": "en-fa",
 | 
			
		||||
  "fi-FI": "en-fi",
 | 
			
		||||
  "fr-FR": "en-fr",
 | 
			
		||||
@@ -34,28 +31,18 @@ const crowdinMap = {
 | 
			
		||||
  "pt-PT": "en-pt",
 | 
			
		||||
  "ro-RO": "en-ro",
 | 
			
		||||
  "ru-RU": "en-ru",
 | 
			
		||||
  "si-LK": "en-silk",
 | 
			
		||||
  "sk-SK": "en-sk",
 | 
			
		||||
  "sv-SE": "en-sv",
 | 
			
		||||
  "ta-IN": "en-ta",
 | 
			
		||||
  "tr-TR": "en-tr",
 | 
			
		||||
  "uk-UA": "en-uk",
 | 
			
		||||
  "zh-CN": "en-zhcn",
 | 
			
		||||
  "zh-HK": "en-zhhk",
 | 
			
		||||
  "zh-TW": "en-zhtw",
 | 
			
		||||
  "lt-LT": "en-lt",
 | 
			
		||||
  "lv-LV": "en-lv",
 | 
			
		||||
  "cs-CZ": "en-cs",
 | 
			
		||||
  "kk-KZ": "en-kk",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flags = {
 | 
			
		||||
  "ar-SA": "🇸🇦",
 | 
			
		||||
  "bg-BG": "🇧🇬",
 | 
			
		||||
  "bn-BD": "🇧🇩",
 | 
			
		||||
  "ca-ES": "🏳",
 | 
			
		||||
  "cs-CZ": "🇨🇿",
 | 
			
		||||
  "da-DK": "🇩🇰",
 | 
			
		||||
  "de-DE": "🇩🇪",
 | 
			
		||||
  "el-GR": "🇬🇷",
 | 
			
		||||
  "es-ES": "🇪🇸",
 | 
			
		||||
@@ -69,10 +56,7 @@ const flags = {
 | 
			
		||||
  "it-IT": "🇮🇹",
 | 
			
		||||
  "ja-JP": "🇯🇵",
 | 
			
		||||
  "kab-KAB": "🏳",
 | 
			
		||||
  "kk-KZ": "🇰🇿",
 | 
			
		||||
  "ko-KR": "🇰🇷",
 | 
			
		||||
  "lt-LT": "🇱🇹",
 | 
			
		||||
  "lv-LV": "🇱🇻",
 | 
			
		||||
  "my-MM": "🇲🇲",
 | 
			
		||||
  "nb-NO": "🇳🇴",
 | 
			
		||||
  "nl-NL": "🇳🇱",
 | 
			
		||||
@@ -84,28 +68,21 @@ const flags = {
 | 
			
		||||
  "pt-PT": "🇵🇹",
 | 
			
		||||
  "ro-RO": "🇷🇴",
 | 
			
		||||
  "ru-RU": "🇷🇺",
 | 
			
		||||
  "si-LK": "🇱🇰",
 | 
			
		||||
  "sk-SK": "🇸🇰",
 | 
			
		||||
  "sv-SE": "🇸🇪",
 | 
			
		||||
  "ta-IN": "🇮🇳",
 | 
			
		||||
  "tr-TR": "🇹🇷",
 | 
			
		||||
  "uk-UA": "🇺🇦",
 | 
			
		||||
  "zh-CN": "🇨🇳",
 | 
			
		||||
  "zh-HK": "🇭🇰",
 | 
			
		||||
  "zh-TW": "🇹🇼",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const languages = {
 | 
			
		||||
  "ar-SA": "العربية",
 | 
			
		||||
  "bg-BG": "Български",
 | 
			
		||||
  "bn-BD": "Bengali",
 | 
			
		||||
  "ca-ES": "Català",
 | 
			
		||||
  "cs-CZ": "Česky",
 | 
			
		||||
  "da-DK": "Dansk",
 | 
			
		||||
  "de-DE": "Deutsch",
 | 
			
		||||
  "el-GR": "Ελληνικά",
 | 
			
		||||
  "es-ES": "Español",
 | 
			
		||||
  "eu-ES": "Euskara",
 | 
			
		||||
  "fa-IR": "فارسی",
 | 
			
		||||
  "fi-FI": "Suomi",
 | 
			
		||||
  "fr-FR": "Français",
 | 
			
		||||
@@ -116,10 +93,7 @@ const languages = {
 | 
			
		||||
  "it-IT": "Italiano",
 | 
			
		||||
  "ja-JP": "日本語",
 | 
			
		||||
  "kab-KAB": "Taqbaylit",
 | 
			
		||||
  "kk-KZ": "Қазақ тілі",
 | 
			
		||||
  "ko-KR": "한국어",
 | 
			
		||||
  "lt-LT": "Lietuvių",
 | 
			
		||||
  "lv-LV": "Latviešu",
 | 
			
		||||
  "my-MM": "Burmese",
 | 
			
		||||
  "nb-NO": "Norsk bokmål",
 | 
			
		||||
  "nl-NL": "Nederlands",
 | 
			
		||||
@@ -131,14 +105,11 @@ const languages = {
 | 
			
		||||
  "pt-PT": "Português",
 | 
			
		||||
  "ro-RO": "Română",
 | 
			
		||||
  "ru-RU": "Русский",
 | 
			
		||||
  "si-LK": "සිංහල",
 | 
			
		||||
  "sk-SK": "Slovenčina",
 | 
			
		||||
  "sv-SE": "Svenska",
 | 
			
		||||
  "ta-IN": "Tamil",
 | 
			
		||||
  "tr-TR": "Türkçe",
 | 
			
		||||
  "uk-UA": "Українська",
 | 
			
		||||
  "zh-CN": "简体中文",
 | 
			
		||||
  "zh-HK": "繁體中文 (香港)",
 | 
			
		||||
  "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,97 +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 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 prNumber = commit.match(/\(#([0-9]*)\)/)[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);
 | 
			
		||||
  });
 | 
			
		||||
  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,58 +2,20 @@ import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      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
 | 
			
		||||
      .loadLibrary()
 | 
			
		||||
      .then((items) => {
 | 
			
		||||
        return app.library.saveLibrary([
 | 
			
		||||
          {
 | 
			
		||||
            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,
 | 
			
		||||
          },
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
    Library.loadLibrary().then((items) => {
 | 
			
		||||
      Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
 | 
			
		||||
    });
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: "labels.addToLibrary",
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { alignElements, Alignment } from "../align";
 | 
			
		||||
import {
 | 
			
		||||
  AlignBottomIcon,
 | 
			
		||||
@@ -8,13 +9,13 @@ import {
 | 
			
		||||
  CenterVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const enableActionGroup = (
 | 
			
		||||
@@ -34,11 +35,9 @@ const alignSelectedElements = (
 | 
			
		||||
 | 
			
		||||
  const updatedElements = alignElements(selectedElements, alignment);
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map(
 | 
			
		||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
			
		||||
  );
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const actionAlignTop = register({
 | 
			
		||||
 
 | 
			
		||||
@@ -1,29 +1,28 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { THEME, ZOOM_STEP } from "../constants";
 | 
			
		||||
import { ZOOM_STEP } from "../constants";
 | 
			
		||||
import { getCommonBounds, getNonDeletedElements } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { getStateForZoom } from "../scene/zoom";
 | 
			
		||||
import { getNewZoom } from "../scene/zoom";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getDefaultAppState } from "../appState";
 | 
			
		||||
import ClearCanvas from "../components/ClearCanvas";
 | 
			
		||||
 | 
			
		||||
export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
  name: "changeViewBackgroundColor",
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, ...value },
 | 
			
		||||
      commitToHistory: !!value.viewBackgroundColor,
 | 
			
		||||
      appState: { ...appState, viewBackgroundColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => {
 | 
			
		||||
@@ -33,11 +32,7 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
          label={t("labels.canvasBackground")}
 | 
			
		||||
          type="canvasBackground"
 | 
			
		||||
          color={appState.viewBackgroundColor}
 | 
			
		||||
          onChange={(color) => updateData({ viewBackgroundColor: color })}
 | 
			
		||||
          isActive={appState.openPopup === "canvasColorPicker"}
 | 
			
		||||
          setActive={(active) =>
 | 
			
		||||
            updateData({ openPopup: active ? "canvasColorPicker" : null })
 | 
			
		||||
          }
 | 
			
		||||
          onChange={(color) => updateData(color)}
 | 
			
		||||
          data-testid="canvas-background-picker"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -47,48 +42,55 @@ export const actionChangeViewBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionClearCanvas = register({
 | 
			
		||||
  name: "clearCanvas",
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    app.imageCache.clear();
 | 
			
		||||
  perform: (elements, appState: AppState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) =>
 | 
			
		||||
        newElementWith(element, { isDeleted: true }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...getDefaultAppState(),
 | 
			
		||||
        files: {},
 | 
			
		||||
        theme: appState.theme,
 | 
			
		||||
        elementLocked: appState.elementLocked,
 | 
			
		||||
        penMode: appState.penMode,
 | 
			
		||||
        penDetected: appState.penDetected,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        shouldAddWatermark: appState.shouldAddWatermark,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
        elementType:
 | 
			
		||||
          appState.elementType === "image" ? "selection" : appState.elementType,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
 | 
			
		||||
  PanelComponent: ({ 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);
 | 
			
		||||
        }
 | 
			
		||||
      }}
 | 
			
		||||
      data-testid="clear-canvas-button"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionZoomIn = register({
 | 
			
		||||
  name: "zoomIn",
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    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 {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...getStateForZoom(
 | 
			
		||||
          {
 | 
			
		||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
			
		||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
			
		||||
            nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
 | 
			
		||||
          },
 | 
			
		||||
          appState,
 | 
			
		||||
        ),
 | 
			
		||||
        zoom,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
@@ -102,7 +104,6 @@ export const actionZoomIn = register({
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size="small"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -112,18 +113,18 @@ export const actionZoomIn = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomOut = register({
 | 
			
		||||
  name: "zoomOut",
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    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 {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...getStateForZoom(
 | 
			
		||||
          {
 | 
			
		||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
			
		||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
			
		||||
            nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
 | 
			
		||||
          },
 | 
			
		||||
          appState,
 | 
			
		||||
        ),
 | 
			
		||||
        zoom,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
@@ -137,7 +138,6 @@ export const actionZoomOut = register({
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
      size="small"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -147,37 +147,33 @@ export const actionZoomOut = register({
 | 
			
		||||
 | 
			
		||||
export const actionResetZoom = register({
 | 
			
		||||
  name: "resetZoom",
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...getStateForZoom(
 | 
			
		||||
        zoom: getNewZoom(
 | 
			
		||||
          1 as NormalizedZoomValue,
 | 
			
		||||
          appState.zoom,
 | 
			
		||||
          { left: appState.offsetLeft, top: appState.offsetTop },
 | 
			
		||||
          {
 | 
			
		||||
            viewportX: appState.width / 2 + appState.offsetLeft,
 | 
			
		||||
            viewportY: appState.height / 2 + appState.offsetTop,
 | 
			
		||||
            nextZoom: getNormalizedZoom(1),
 | 
			
		||||
            x: appState.width / 2,
 | 
			
		||||
            y: appState.height / 2,
 | 
			
		||||
          },
 | 
			
		||||
          appState,
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        type="button"
 | 
			
		||||
        className="reset-zoom-button"
 | 
			
		||||
        title={t("buttons.resetZoom")}
 | 
			
		||||
        aria-label={t("buttons.resetZoom")}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          updateData(null);
 | 
			
		||||
        }}
 | 
			
		||||
        size="small"
 | 
			
		||||
      >
 | 
			
		||||
        {(appState.zoom.value * 100).toFixed(0)}%
 | 
			
		||||
      </ToolButton>
 | 
			
		||||
    </Tooltip>
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={resetZoom}
 | 
			
		||||
      title={t("buttons.resetZoom")}
 | 
			
		||||
      aria-label={t("buttons.resetZoom")}
 | 
			
		||||
      onClick={() => {
 | 
			
		||||
        updateData(null);
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
 | 
			
		||||
@@ -216,12 +212,14 @@ const zoomToFitElements = (
 | 
			
		||||
      ? getCommonBounds(selectedElements)
 | 
			
		||||
      : getCommonBounds(nonDeletedElements);
 | 
			
		||||
 | 
			
		||||
  const newZoom = {
 | 
			
		||||
    value: zoomValueToFitBoundsOnViewport(commonBounds, {
 | 
			
		||||
      width: appState.width,
 | 
			
		||||
      height: appState.height,
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
  const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
 | 
			
		||||
    width: appState.width,
 | 
			
		||||
    height: appState.height,
 | 
			
		||||
  });
 | 
			
		||||
  const newZoom = getNewZoom(zoomValue, appState.zoom, {
 | 
			
		||||
    left: appState.offsetLeft,
 | 
			
		||||
    top: appState.offsetTop,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const [x1, y1, x2, y2] = commonBounds;
 | 
			
		||||
  const centerX = (x1 + x2) / 2;
 | 
			
		||||
@@ -262,28 +260,3 @@ export const actionZoomToFit = register({
 | 
			
		||||
    !event.altKey &&
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD],
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionToggleTheme = register({
 | 
			
		||||
  name: "toggleTheme",
 | 
			
		||||
  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,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionCopy = register({
 | 
			
		||||
  name: "copy",
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState, app.files);
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    copyToClipboard(getNonDeletedElements(elements), appState);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
@@ -25,7 +25,7 @@ export const actionCut = register({
 | 
			
		||||
  name: "cut",
 | 
			
		||||
  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",
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
 | 
			
		||||
@@ -42,7 +42,6 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
@@ -51,13 +50,13 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.files,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
@@ -82,7 +81,6 @@ export const actionCopyAsPng = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await exportCanvas(
 | 
			
		||||
@@ -91,7 +89,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.files,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
@@ -108,7 +106,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        appState: {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { trash } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
@@ -11,7 +12,6 @@ import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getElementsInGroup } from "../groups";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
const deleteSelectedElements = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -22,12 +22,6 @@ const deleteSelectedElements = (
 | 
			
		||||
      if (appState.selectedElementIds[el.id]) {
 | 
			
		||||
        return newElementWith(el, { isDeleted: true });
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        isBoundToContainer(el) &&
 | 
			
		||||
        appState.selectedElementIds[el.containerId]
 | 
			
		||||
      ) {
 | 
			
		||||
        return newElementWith(el, { isDeleted: true });
 | 
			
		||||
      }
 | 
			
		||||
      return el;
 | 
			
		||||
    }),
 | 
			
		||||
    appState: {
 | 
			
		||||
@@ -62,7 +56,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
        elementId,
 | 
			
		||||
        selectedPointsIndices,
 | 
			
		||||
        activePointIndex,
 | 
			
		||||
        startBindingElement,
 | 
			
		||||
        endBindingElement,
 | 
			
		||||
      } = appState.editingLinearElement;
 | 
			
		||||
@@ -72,7 +66,8 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        // case: no point selected → delete whole element
 | 
			
		||||
        selectedPointsIndices == null ||
 | 
			
		||||
        activePointIndex == null ||
 | 
			
		||||
        activePointIndex === -1 ||
 | 
			
		||||
        // case: deleting last remaining point
 | 
			
		||||
        element.points.length < 2
 | 
			
		||||
      ) {
 | 
			
		||||
@@ -92,17 +87,15 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      // We cannot do this inside `movePoint` because it is also called
 | 
			
		||||
      // when deleting the uncommitted point (which hasn't caused any binding)
 | 
			
		||||
      const binding = {
 | 
			
		||||
        startBindingElement: selectedPointsIndices?.includes(0)
 | 
			
		||||
          ? null
 | 
			
		||||
          : startBindingElement,
 | 
			
		||||
        endBindingElement: selectedPointsIndices?.includes(
 | 
			
		||||
          element.points.length - 1,
 | 
			
		||||
        )
 | 
			
		||||
          ? null
 | 
			
		||||
          : endBindingElement,
 | 
			
		||||
        startBindingElement:
 | 
			
		||||
          activePointIndex === 0 ? null : startBindingElement,
 | 
			
		||||
        endBindingElement:
 | 
			
		||||
          activePointIndex === element.points.length - 1
 | 
			
		||||
            ? null
 | 
			
		||||
            : endBindingElement,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      LinearElementEditor.deletePoints(element, selectedPointsIndices);
 | 
			
		||||
      LinearElementEditor.movePoint(element, activePointIndex, "delete");
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
@@ -111,17 +104,17 @@ export const actionDeleteSelected = register({
 | 
			
		||||
          editingLinearElement: {
 | 
			
		||||
            ...appState.editingLinearElement,
 | 
			
		||||
            ...binding,
 | 
			
		||||
            selectedPointsIndices:
 | 
			
		||||
              selectedPointsIndices?.[0] > 0
 | 
			
		||||
                ? [selectedPointsIndices[0] - 1]
 | 
			
		||||
                : [0],
 | 
			
		||||
            activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    let { elements: nextElements, appState: nextAppState } =
 | 
			
		||||
      deleteSelectedElements(elements, appState);
 | 
			
		||||
 | 
			
		||||
    let {
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      appState: nextAppState,
 | 
			
		||||
    } = deleteSelectedElements(elements, appState);
 | 
			
		||||
    fixBindingsAfterDeletion(
 | 
			
		||||
      nextElements,
 | 
			
		||||
      elements.filter(({ id }) => appState.selectedElementIds[id]),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,17 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  DistributeHorizontallyIcon,
 | 
			
		||||
  DistributeVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { distributeElements, Distribution } from "../disitrubte";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { getElementMap, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES } from "../keys";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const enableActionGroup = (
 | 
			
		||||
@@ -30,11 +31,9 @@ const distributeSelectedElements = (
 | 
			
		||||
 | 
			
		||||
  const updatedElements = distributeElements(selectedElements, distribution);
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map(
 | 
			
		||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
			
		||||
  );
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const distributeHorizontally = register({
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,15 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { duplicateElement, getNonDeletedElements } from "../element";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { clone } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  selectGroupsForSelectedElements,
 | 
			
		||||
  getSelectedGroupForElement,
 | 
			
		||||
@@ -17,23 +19,41 @@ import { AppState } from "../types";
 | 
			
		||||
import { fixBindingsAfterDuplication } from "../element/binding";
 | 
			
		||||
import { ActionResult } from "./types";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { bindTextToShapeAfterDuplication } from "../element/textElement";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionDuplicateSelection = register({
 | 
			
		||||
  name: "duplicateSelection",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    // duplicate selected point(s) if editing a line
 | 
			
		||||
    // duplicate point if selected while editing multi-point element
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const ret = LinearElementEditor.duplicateSelectedPoints(appState);
 | 
			
		||||
 | 
			
		||||
      if (!ret) {
 | 
			
		||||
      const { activePointIndex, elementId } = appState.editingLinearElement;
 | 
			
		||||
      const element = LinearElementEditor.getElement(elementId);
 | 
			
		||||
      if (!element || activePointIndex === null) {
 | 
			
		||||
        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 {
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          editingLinearElement: {
 | 
			
		||||
            ...appState.editingLinearElement,
 | 
			
		||||
            activePointIndex: activePointIndex + 1,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        elements,
 | 
			
		||||
        appState: ret.appState,
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
@@ -87,12 +107,9 @@ const duplicateElements = (
 | 
			
		||||
  const finalElements: ExcalidrawElement[] = [];
 | 
			
		||||
 | 
			
		||||
  let index = 0;
 | 
			
		||||
  const selectedElementIds = arrayToMap(
 | 
			
		||||
    getSelectedElements(elements, appState, true),
 | 
			
		||||
  );
 | 
			
		||||
  while (index < elements.length) {
 | 
			
		||||
    const element = elements[index];
 | 
			
		||||
    if (selectedElementIds.get(element.id)) {
 | 
			
		||||
    if (appState.selectedElementIds[element.id]) {
 | 
			
		||||
      if (element.groupIds.length) {
 | 
			
		||||
        const groupId = getSelectedGroupForElement(appState, element);
 | 
			
		||||
        // if group selected, duplicate it atomically
 | 
			
		||||
@@ -114,11 +131,7 @@ const duplicateElements = (
 | 
			
		||||
    }
 | 
			
		||||
    index++;
 | 
			
		||||
  }
 | 
			
		||||
  bindTextToShapeAfterDuplication(
 | 
			
		||||
    finalElements,
 | 
			
		||||
    oldElements,
 | 
			
		||||
    oldIdToDuplicatedId,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
@@ -128,9 +141,7 @@ const duplicateElements = (
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        selectedElementIds: newElements.reduce((acc, element) => {
 | 
			
		||||
          if (!isBoundToContainer(element)) {
 | 
			
		||||
            acc[element.id] = true;
 | 
			
		||||
          }
 | 
			
		||||
          acc[element.id] = true;
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {} as any),
 | 
			
		||||
      },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +1,17 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { load, questionCircle, saveAs } from "../components/icons";
 | 
			
		||||
import { load, questionCircle, save, saveAs } from "../components/icons";
 | 
			
		||||
import { ProjectName } from "../components/ProjectName";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import "../components/ToolIcon.scss";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
 | 
			
		||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
			
		||||
import { resaveAsImageWithScene } from "../data/resave";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
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";
 | 
			
		||||
import { supported } from "browser-fs-access";
 | 
			
		||||
 | 
			
		||||
export const actionChangeProjectName = register({
 | 
			
		||||
  name: "changeProjectName",
 | 
			
		||||
@@ -39,54 +31,6 @@ export const actionChangeProjectName = register({
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeExportScale = register({
 | 
			
		||||
  name: "changeExportScale",
 | 
			
		||||
  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({
 | 
			
		||||
  name: "changeExportBackground",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
@@ -96,12 +40,14 @@ export const actionChangeExportBackground = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <CheckboxItem
 | 
			
		||||
      checked={appState.exportBackground}
 | 
			
		||||
      onChange={(checked) => updateData(checked)}
 | 
			
		||||
    >
 | 
			
		||||
    <label>
 | 
			
		||||
      <input
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        checked={appState.exportBackground}
 | 
			
		||||
        onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
      />{" "}
 | 
			
		||||
      {t("labels.withBackground")}
 | 
			
		||||
    </CheckboxItem>
 | 
			
		||||
    </label>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -114,35 +60,57 @@ export const actionChangeExportEmbedScene = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) => (
 | 
			
		||||
    <CheckboxItem
 | 
			
		||||
      checked={appState.exportEmbedScene}
 | 
			
		||||
      onChange={(checked) => updateData(checked)}
 | 
			
		||||
    >
 | 
			
		||||
    <label style={{ display: "flex" }}>
 | 
			
		||||
      <input
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        checked={appState.exportEmbedScene}
 | 
			
		||||
        onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
      />{" "}
 | 
			
		||||
      {t("labels.exportEmbedScene")}
 | 
			
		||||
      <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
 | 
			
		||||
        <div className="excalidraw-tooltip-icon">{questionCircle}</div>
 | 
			
		||||
      <Tooltip
 | 
			
		||||
        label={t("labels.exportEmbedScene_details")}
 | 
			
		||||
        position="above"
 | 
			
		||||
        long={true}
 | 
			
		||||
      >
 | 
			
		||||
        <div className="TooltipIcon">{questionCircle}</div>
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </CheckboxItem>
 | 
			
		||||
    </label>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionSaveToActiveFile = register({
 | 
			
		||||
  name: "saveToActiveFile",
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
export const actionChangeShouldAddWatermark = register({
 | 
			
		||||
  name: "changeShouldAddWatermark",
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = isImageFileHandle(appState.fileHandle)
 | 
			
		||||
        ? await resaveAsImageWithScene(elements, appState, app.files)
 | 
			
		||||
        : await saveAsJSON(elements, appState, app.files);
 | 
			
		||||
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(elements, appState);
 | 
			
		||||
      return {
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          fileHandle,
 | 
			
		||||
          toastMessage: fileHandleExists
 | 
			
		||||
            ? fileHandle?.name
 | 
			
		||||
            ? fileHandle.name
 | 
			
		||||
              ? t("toast.fileSavedToFilename").replace(
 | 
			
		||||
                  "{filename}",
 | 
			
		||||
                  `"${fileHandle.name}"`,
 | 
			
		||||
@@ -151,43 +119,40 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
            : null,
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn(error);
 | 
			
		||||
      }
 | 
			
		||||
      return { commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
 | 
			
		||||
  PanelComponent: ({ updateData, appState }) => (
 | 
			
		||||
    <ActiveFile
 | 
			
		||||
      onSave={() => updateData(null)}
 | 
			
		||||
      fileName={appState.fileHandle?.name}
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={save}
 | 
			
		||||
      title={t("buttons.save")}
 | 
			
		||||
      aria-label={t("buttons.save")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-button"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionSaveFileToDisk = register({
 | 
			
		||||
  name: "saveFileToDisk",
 | 
			
		||||
  perform: async (elements, appState, value, app) => {
 | 
			
		||||
export const actionSaveAsScene = register({
 | 
			
		||||
  name: "saveAsScene",
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(
 | 
			
		||||
        elements,
 | 
			
		||||
        {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          fileHandle: null,
 | 
			
		||||
        },
 | 
			
		||||
        app.files,
 | 
			
		||||
      );
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(elements, {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        fileHandle: null,
 | 
			
		||||
      });
 | 
			
		||||
      return { commitToHistory: false, appState: { ...appState, fileHandle } };
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name !== "AbortError") {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn(error);
 | 
			
		||||
      }
 | 
			
		||||
      return { commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
@@ -201,7 +166,7 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
      title={t("buttons.saveAs")}
 | 
			
		||||
      aria-label={t("buttons.saveAs")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      hidden={!nativeFileSystemSupported}
 | 
			
		||||
      hidden={!supported}
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-as-button"
 | 
			
		||||
    />
 | 
			
		||||
@@ -210,28 +175,24 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  perform: async (elements, appState, _, app) => {
 | 
			
		||||
  perform: async (elements, appState) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
        files,
 | 
			
		||||
      } = await loadFromJSON(appState, elements);
 | 
			
		||||
      } = await loadFromJSON(appState);
 | 
			
		||||
      return {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
        files,
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.name === "AbortError") {
 | 
			
		||||
        console.warn(error);
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
        appState: { ...appState, errorMessage: error.message },
 | 
			
		||||
        files: app.files,
 | 
			
		||||
        commitToHistory: false,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
@@ -268,12 +229,46 @@ export const actionExportWithDarkMode = register({
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <DarkModeToggle
 | 
			
		||||
        value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
 | 
			
		||||
        onChange={(theme: Theme) => {
 | 
			
		||||
          updateData(theme === THEME.DARK);
 | 
			
		||||
        value={appState.exportWithDarkMode ? "dark" : "light"}
 | 
			
		||||
        onChange={(theme: Appearence) => {
 | 
			
		||||
          updateData(theme === "dark");
 | 
			
		||||
        }}
 | 
			
		||||
        title={t("labels.toggleExportColorScheme")}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionToggleAutosave = register({
 | 
			
		||||
  name: "toggleAutosave",
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("toggle", "autosave");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        autosave: !appState.autosave,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData }) =>
 | 
			
		||||
    supported && appState.fileHandle ? (
 | 
			
		||||
      <label style={{ display: "flex" }}>
 | 
			
		||||
        <input
 | 
			
		||||
          type="checkbox"
 | 
			
		||||
          checked={appState.autosave}
 | 
			
		||||
          onChange={(event) => updateData(event.target.checked)}
 | 
			
		||||
        />{" "}
 | 
			
		||||
        {t("labels.toggleAutosave")}
 | 
			
		||||
        <Tooltip
 | 
			
		||||
          label={t("labels.toggleAutosave_details")}
 | 
			
		||||
          position="above"
 | 
			
		||||
          long={true}
 | 
			
		||||
        >
 | 
			
		||||
          <div className="TooltipIcon">{questionCircle}</div>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </label>
 | 
			
		||||
    ) : (
 | 
			
		||||
      <></>
 | 
			
		||||
    ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { isInvisiblySmallElement } from "../element";
 | 
			
		||||
import { resetCursor } from "../utils";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { done } from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -17,10 +18,13 @@ import { isBindingElement } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionFinalize = register({
 | 
			
		||||
  name: "finalize",
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
			
		||||
  perform: (elements, appState, _, { canvas }) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const { elementId, startBindingElement, endBindingElement } =
 | 
			
		||||
        appState.editingLinearElement;
 | 
			
		||||
      const {
 | 
			
		||||
        elementId,
 | 
			
		||||
        startBindingElement,
 | 
			
		||||
        endBindingElement,
 | 
			
		||||
      } = appState.editingLinearElement;
 | 
			
		||||
      const element = LinearElementEditor.getElement(elementId);
 | 
			
		||||
 | 
			
		||||
      if (element) {
 | 
			
		||||
@@ -46,25 +50,20 @@ export const actionFinalize = register({
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let newElements = elements;
 | 
			
		||||
 | 
			
		||||
    if (appState.pendingImageElement) {
 | 
			
		||||
      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (window.document.activeElement instanceof HTMLElement) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
      window.document.activeElement.blur();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const multiPointElement = appState.multiElement
 | 
			
		||||
      ? appState.multiElement
 | 
			
		||||
      : appState.editingElement?.type === "freedraw"
 | 
			
		||||
      : appState.editingElement?.type === "draw"
 | 
			
		||||
      ? appState.editingElement
 | 
			
		||||
      : null;
 | 
			
		||||
 | 
			
		||||
    if (multiPointElement) {
 | 
			
		||||
      // pen and mouse have hover
 | 
			
		||||
      if (
 | 
			
		||||
        multiPointElement.type !== "freedraw" &&
 | 
			
		||||
        multiPointElement.type !== "draw" &&
 | 
			
		||||
        appState.lastPointerDownWith !== "touch"
 | 
			
		||||
      ) {
 | 
			
		||||
        const { points, lastCommittedPoint } = multiPointElement;
 | 
			
		||||
@@ -87,7 +86,7 @@ export const actionFinalize = register({
 | 
			
		||||
      const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
 | 
			
		||||
      if (
 | 
			
		||||
        multiPointElement.type === "line" ||
 | 
			
		||||
        multiPointElement.type === "freedraw"
 | 
			
		||||
        multiPointElement.type === "draw"
 | 
			
		||||
      ) {
 | 
			
		||||
        if (isLoop) {
 | 
			
		||||
          const linePoints = multiPointElement.points;
 | 
			
		||||
@@ -119,24 +118,22 @@ export const actionFinalize = register({
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "freedraw") {
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "draw") {
 | 
			
		||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "freedraw") ||
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "draw") ||
 | 
			
		||||
      !multiPointElement
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor(canvas);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      elements: newElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        elementType:
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "freedraw") &&
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "draw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.elementType
 | 
			
		||||
            : "selection",
 | 
			
		||||
@@ -148,15 +145,14 @@ export const actionFinalize = register({
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "freedraw"
 | 
			
		||||
          appState.elementType !== "draw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
              }
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
        pendingImageElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
      commitToHistory: appState.elementType === "draw",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,14 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { getElementMap, 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 { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { updateBoundElements } from "../element/binding";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const enableActionFlipHorizontal = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -84,11 +83,9 @@ const flipSelectedElements = (
 | 
			
		||||
    flipDirection,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = arrayToMap(updatedElements);
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map(
 | 
			
		||||
    (element) => updatedElementsMap.get(element.id) || element,
 | 
			
		||||
  );
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flipElements = (
 | 
			
		||||
@@ -96,13 +93,13 @@ const flipElements = (
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  flipDirection: "horizontal" | "vertical",
 | 
			
		||||
): ExcalidrawElement[] => {
 | 
			
		||||
  elements.forEach((element) => {
 | 
			
		||||
    flipElement(element, appState);
 | 
			
		||||
  for (let i = 0; i < elements.length; i++) {
 | 
			
		||||
    flipElement(elements[i], appState);
 | 
			
		||||
    // If vertical flip, rotate an extra 180
 | 
			
		||||
    if (flipDirection === "vertical") {
 | 
			
		||||
      rotateElement(element, Math.PI);
 | 
			
		||||
      rotateElement(elements[i], Math.PI);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  }
 | 
			
		||||
  return elements;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -117,7 +114,7 @@ const flipElement = (
 | 
			
		||||
  const originalAngle = normalizeAngle(element.angle);
 | 
			
		||||
 | 
			
		||||
  let finalOffsetX = 0;
 | 
			
		||||
  if (isLinearElement(element) || isFreeDrawElement(element)) {
 | 
			
		||||
  if (isLinearElement(element)) {
 | 
			
		||||
    finalOffsetX =
 | 
			
		||||
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
 | 
			
		||||
      element.width;
 | 
			
		||||
@@ -145,9 +142,10 @@ const flipElement = (
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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]] },
 | 
			
		||||
    for (let i = 1; i < element.points.length; i++) {
 | 
			
		||||
      LinearElementEditor.movePoint(element, i, [
 | 
			
		||||
        -element.points[i][0],
 | 
			
		||||
        element.points[i][1],
 | 
			
		||||
      ]);
 | 
			
		||||
    }
 | 
			
		||||
    LinearElementEditor.normalizePoints(element);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { UngroupIcon, GroupIcon } from "../components/icons";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
@@ -17,9 +18,8 @@ import {
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 | 
			
		||||
  if (elements.length >= 2) {
 | 
			
		||||
@@ -45,7 +45,6 @@ const enableActionGroup = (
 | 
			
		||||
  const selectedElements = getSelectedElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
    appState,
 | 
			
		||||
    true,
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
 | 
			
		||||
@@ -58,7 +57,6 @@ export const actionGroup = register({
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
    if (selectedElements.length < 2) {
 | 
			
		||||
      // nothing to group
 | 
			
		||||
@@ -86,9 +84,8 @@ export const actionGroup = register({
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const newGroupId = randomId();
 | 
			
		||||
    const selectElementIds = arrayToMap(selectedElements);
 | 
			
		||||
    const updatedElements = elements.map((element) => {
 | 
			
		||||
      if (!selectElementIds.get(element.id)) {
 | 
			
		||||
      if (!appState.selectedElementIds[element.id]) {
 | 
			
		||||
        return element;
 | 
			
		||||
      }
 | 
			
		||||
      return newElementWith(element, {
 | 
			
		||||
@@ -103,8 +100,9 @@ export const actionGroup = register({
 | 
			
		||||
    // to the z order of the highest element in the layer stack
 | 
			
		||||
    const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
 | 
			
		||||
    const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
 | 
			
		||||
    const lastGroupElementIndex =
 | 
			
		||||
      updatedElements.lastIndexOf(lastElementInGroup);
 | 
			
		||||
    const lastGroupElementIndex = updatedElements.lastIndexOf(
 | 
			
		||||
      lastElementInGroup,
 | 
			
		||||
    );
 | 
			
		||||
    const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
 | 
			
		||||
    const elementsBeforeGroup = updatedElements
 | 
			
		||||
      .slice(0, lastGroupElementIndex)
 | 
			
		||||
@@ -152,12 +150,7 @@ export const actionUngroup = register({
 | 
			
		||||
    if (groupIds.length === 0) {
 | 
			
		||||
      return { appState, elements, commitToHistory: false };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
 | 
			
		||||
    const nextElements = elements.map((element) => {
 | 
			
		||||
      if (isBoundToContainer(element)) {
 | 
			
		||||
        boundTextElementIds.push(element.id);
 | 
			
		||||
      }
 | 
			
		||||
      const nextGroupIds = removeFromSelectedGroups(
 | 
			
		||||
        element.groupIds,
 | 
			
		||||
        appState.selectedGroupIds,
 | 
			
		||||
@@ -169,19 +162,11 @@ export const actionUngroup = register({
 | 
			
		||||
        groupIds: nextGroupIds,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const updateAppState = selectGroupsForSelectedElements(
 | 
			
		||||
      { ...appState, selectedGroupIds: {} },
 | 
			
		||||
      getNonDeletedElements(nextElements),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // remove binded text elements from selection
 | 
			
		||||
    boundTextElementIds.forEach(
 | 
			
		||||
      (id) => (updateAppState.selectedElementIds[id] = false),
 | 
			
		||||
    );
 | 
			
		||||
    return {
 | 
			
		||||
      appState: updateAppState,
 | 
			
		||||
 | 
			
		||||
      appState: selectGroupsForSelectedElements(
 | 
			
		||||
        { ...appState, selectedGroupIds: {} },
 | 
			
		||||
        getNonDeletedElements(nextElements),
 | 
			
		||||
      ),
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,15 @@
 | 
			
		||||
import { Action, ActionResult } from "./types";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { undo, redo } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import History, { HistoryEntry } from "../history";
 | 
			
		||||
import { SceneHistory, HistoryEntry } from "../history";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isWindows, KEYS } from "../keys";
 | 
			
		||||
import { getElementMap } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const writeData = (
 | 
			
		||||
  prevElements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -27,17 +28,17 @@ const writeData = (
 | 
			
		||||
      return { commitToHistory };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevElementMap = arrayToMap(prevElements);
 | 
			
		||||
    const prevElementMap = getElementMap(prevElements);
 | 
			
		||||
    const nextElements = data.elements;
 | 
			
		||||
    const nextElementMap = arrayToMap(nextElements);
 | 
			
		||||
    const nextElementMap = getElementMap(nextElements);
 | 
			
		||||
 | 
			
		||||
    const deletedElements = prevElements.filter(
 | 
			
		||||
      (prevElement) => !nextElementMap.has(prevElement.id),
 | 
			
		||||
      (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
 | 
			
		||||
    );
 | 
			
		||||
    const elements = nextElements
 | 
			
		||||
      .map((nextElement) =>
 | 
			
		||||
        newElementWith(
 | 
			
		||||
          prevElementMap.get(nextElement.id) || nextElement,
 | 
			
		||||
          prevElementMap[nextElement.id] || nextElement,
 | 
			
		||||
          nextElement,
 | 
			
		||||
        ),
 | 
			
		||||
      )
 | 
			
		||||
@@ -58,7 +59,7 @@ const writeData = (
 | 
			
		||||
  return { commitToHistory };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ActionCreator = (history: History) => Action;
 | 
			
		||||
type ActionCreator = (history: SceneHistory) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
    event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
    event.key.toLowerCase() === KEYS.Z &&
 | 
			
		||||
    !event.shiftKey,
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => (
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={undo}
 | 
			
		||||
      aria-label={t("buttons.undo")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  commitToHistory: () => false,
 | 
			
		||||
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      event.key.toLowerCase() === KEYS.Z) ||
 | 
			
		||||
    (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => (
 | 
			
		||||
  PanelComponent: ({ updateData }) => (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon={redo}
 | 
			
		||||
      aria-label={t("buttons.redo")}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      size={data?.size || "medium"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
  commitToHistory: () => false,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { menu, palette } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
@@ -69,10 +70,7 @@ export const actionFullScreen = register({
 | 
			
		||||
 | 
			
		||||
export const actionShortcuts = register({
 | 
			
		||||
  name: "toggleShortcuts",
 | 
			
		||||
  perform: (_elements, appState, _, { focusContainer }) => {
 | 
			
		||||
    if (appState.showHelpDialog) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
    }
 | 
			
		||||
  perform: (_elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { getClientColors, getClientInitials } from "../clients";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => {
 | 
			
		||||
    const clientId: string | undefined = data?.id;
 | 
			
		||||
  PanelComponent: ({ appState, updateData, id }) => {
 | 
			
		||||
    const clientId = id;
 | 
			
		||||
    if (!clientId) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AppState } from "../../src/types";
 | 
			
		||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
@@ -6,20 +7,12 @@ import {
 | 
			
		||||
  ArrowheadArrowIcon,
 | 
			
		||||
  ArrowheadBarIcon,
 | 
			
		||||
  ArrowheadDotIcon,
 | 
			
		||||
  ArrowheadTriangleIcon,
 | 
			
		||||
  ArrowheadNoneIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  FillCrossHatchIcon,
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  FillSolidIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  FontFamilyHandDrawnIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  SloppinessArchitectIcon,
 | 
			
		||||
  SloppinessArtistIcon,
 | 
			
		||||
  SloppinessCartoonistIcon,
 | 
			
		||||
@@ -27,67 +20,52 @@ import {
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  StrokeStyleSolidIcon,
 | 
			
		||||
  StrokeWidthIcon,
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  FontFamilyHandDrawnIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  TextAlignRightIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  FONT_FAMILY,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  getNonDeletedElements,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  getContainerElement,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  isBoundToContainer,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isLinearElementType,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  Arrowhead,
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  FontFamilyValues,
 | 
			
		||||
  FontFamily,
 | 
			
		||||
  TextAlign,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getLanguage, t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { randomInteger } from "../random";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
  getCommonAttributeOfSelectedElements,
 | 
			
		||||
  getSelectedElements,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 | 
			
		||||
 | 
			
		||||
const changeProperty = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  callback: (element: ExcalidrawElement) => ExcalidrawElement,
 | 
			
		||||
  includeBoundText = false,
 | 
			
		||||
) => {
 | 
			
		||||
  const selectedElementIds = arrayToMap(
 | 
			
		||||
    getSelectedElements(elements, appState, includeBoundText),
 | 
			
		||||
  );
 | 
			
		||||
  return elements.map((element) => {
 | 
			
		||||
    if (
 | 
			
		||||
      selectedElementIds.get(element.id) ||
 | 
			
		||||
      appState.selectedElementIds[element.id] ||
 | 
			
		||||
      element.id === appState.editingElement?.id
 | 
			
		||||
    ) {
 | 
			
		||||
      return callback(element);
 | 
			
		||||
@@ -117,103 +95,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),
 | 
			
		||||
            appState,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          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({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
        elements: changeProperty(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (el) => {
 | 
			
		||||
            return hasStrokeColor(el.type)
 | 
			
		||||
              ? newElementWith(el, {
 | 
			
		||||
                  strokeColor: value.currentItemStrokeColor,
 | 
			
		||||
                })
 | 
			
		||||
              : el;
 | 
			
		||||
          },
 | 
			
		||||
          true,
 | 
			
		||||
        ),
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...value,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: !!value.currentItemStrokeColor,
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
          strokeColor: value,
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: { ...appState, currentItemStrokeColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
@@ -228,11 +120,7 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
          (element) => element.strokeColor,
 | 
			
		||||
          appState.currentItemStrokeColor,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(color) => updateData({ currentItemStrokeColor: color })}
 | 
			
		||||
        isActive={appState.openPopup === "strokeColorPicker"}
 | 
			
		||||
        setActive={(active) =>
 | 
			
		||||
          updateData({ openPopup: active ? "strokeColorPicker" : null })
 | 
			
		||||
        }
 | 
			
		||||
        onChange={updateData}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -242,18 +130,13 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
  name: "changeBackgroundColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemBackgroundColor && {
 | 
			
		||||
        elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
          newElementWith(el, {
 | 
			
		||||
            backgroundColor: value.currentItemBackgroundColor,
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        ...value,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: !!value.currentItemBackgroundColor,
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
          backgroundColor: value,
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
      appState: { ...appState, currentItemBackgroundColor: value },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
@@ -268,11 +151,7 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
          (element) => element.backgroundColor,
 | 
			
		||||
          appState.currentItemBackgroundColor,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(color) => updateData({ currentItemBackgroundColor: color })}
 | 
			
		||||
        isActive={appState.openPopup === "backgroundColorPicker"}
 | 
			
		||||
        setActive={(active) =>
 | 
			
		||||
          updateData({ openPopup: active ? "backgroundColorPicker" : null })
 | 
			
		||||
        }
 | 
			
		||||
        onChange={updateData}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -521,7 +400,24 @@ export const actionChangeOpacity = register({
 | 
			
		||||
export const actionChangeFontSize = register({
 | 
			
		||||
  name: "changeFontSize",
 | 
			
		||||
  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 }) => (
 | 
			
		||||
    <fieldset>
 | 
			
		||||
@@ -533,40 +429,27 @@ export const actionChangeFontSize = register({
 | 
			
		||||
            value: 16,
 | 
			
		||||
            text: t("labels.small"),
 | 
			
		||||
            icon: <FontSizeSmallIcon theme={appState.theme} />,
 | 
			
		||||
            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(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => {
 | 
			
		||||
            if (isTextElement(element)) {
 | 
			
		||||
              return element.fontSize;
 | 
			
		||||
            }
 | 
			
		||||
            const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
            if (boundTextElement) {
 | 
			
		||||
              return boundTextElement.fontSize;
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
          },
 | 
			
		||||
          (element) => isTextElement(element) && element.fontSize,
 | 
			
		||||
          appState.currentItemFontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(value) => updateData(value)}
 | 
			
		||||
@@ -575,71 +458,21 @@ export const actionChangeFontSize = register({
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionDecreaseFontSize = register({
 | 
			
		||||
  name: "decreaseFontSize",
 | 
			
		||||
  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",
 | 
			
		||||
  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({
 | 
			
		||||
  name: "changeFontFamily",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (oldElement) => {
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              {
 | 
			
		||||
                fontFamily: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isTextElement(el)) {
 | 
			
		||||
          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
			
		||||
            fontFamily: value,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(element);
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
          return oldElement;
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
        return el;
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemFontFamily: value,
 | 
			
		||||
@@ -648,23 +481,19 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    const options: {
 | 
			
		||||
      value: FontFamilyValues;
 | 
			
		||||
      text: string;
 | 
			
		||||
      icon: JSX.Element;
 | 
			
		||||
    }[] = [
 | 
			
		||||
    const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Virgil,
 | 
			
		||||
        value: 1,
 | 
			
		||||
        text: t("labels.handDrawn"),
 | 
			
		||||
        icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Helvetica,
 | 
			
		||||
        value: 2,
 | 
			
		||||
        text: t("labels.normal"),
 | 
			
		||||
        icon: <FontFamilyNormalIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Cascadia,
 | 
			
		||||
        value: 3,
 | 
			
		||||
        text: t("labels.code"),
 | 
			
		||||
        icon: <FontFamilyCodeIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
@@ -673,22 +502,13 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.fontFamily")}</legend>
 | 
			
		||||
        <ButtonIconSelect<FontFamilyValues | false>
 | 
			
		||||
        <ButtonIconSelect<FontFamily | false>
 | 
			
		||||
          group="font-family"
 | 
			
		||||
          options={options}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) => {
 | 
			
		||||
              if (isTextElement(element)) {
 | 
			
		||||
                return element.fontFamily;
 | 
			
		||||
              }
 | 
			
		||||
              const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
              if (boundTextElement) {
 | 
			
		||||
                return boundTextElement.fontFamily;
 | 
			
		||||
              }
 | 
			
		||||
              return null;
 | 
			
		||||
            },
 | 
			
		||||
            (element) => isTextElement(element) && element.fontFamily,
 | 
			
		||||
            appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
          )}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
@@ -702,29 +522,17 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
  name: "changeTextAlign",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (oldElement) => {
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              {
 | 
			
		||||
                textAlign: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isTextElement(el)) {
 | 
			
		||||
          const element: ExcalidrawTextElement = newElementWith(el, {
 | 
			
		||||
            textAlign: value,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(element);
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
          return oldElement;
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
        return el;
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemTextAlign: value,
 | 
			
		||||
@@ -757,16 +565,7 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => {
 | 
			
		||||
            if (isTextElement(element)) {
 | 
			
		||||
              return element.textAlign;
 | 
			
		||||
            }
 | 
			
		||||
            const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
            if (boundTextElement) {
 | 
			
		||||
              return boundTextElement.textAlign;
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
          },
 | 
			
		||||
          (element) => isTextElement(element) && element.textAlign,
 | 
			
		||||
          appState.currentItemTextAlign,
 | 
			
		||||
        )}
 | 
			
		||||
        onChange={(value) => updateData(value)}
 | 
			
		||||
@@ -911,14 +710,6 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
                icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
 | 
			
		||||
                keyBinding: "r",
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                value: "triangle",
 | 
			
		||||
                text: t("labels.arrowhead_triangle"),
 | 
			
		||||
                icon: (
 | 
			
		||||
                  <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
 | 
			
		||||
                ),
 | 
			
		||||
                keyBinding: "t",
 | 
			
		||||
              },
 | 
			
		||||
            ]}
 | 
			
		||||
            value={getFormValue<Arrowhead | null>(
 | 
			
		||||
              elements,
 | 
			
		||||
@@ -961,14 +752,6 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
                keyBinding: "r",
 | 
			
		||||
                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>(
 | 
			
		||||
              elements,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { selectGroupsForSelectedElements } from "../groups";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
 | 
			
		||||
export const actionSelectAll = register({
 | 
			
		||||
  name: "selectAll",
 | 
			
		||||
@@ -15,10 +15,7 @@ export const actionSelectAll = register({
 | 
			
		||||
          ...appState,
 | 
			
		||||
          editingGroupId: null,
 | 
			
		||||
          selectedElementIds: elements.reduce((map, element) => {
 | 
			
		||||
            if (
 | 
			
		||||
              !element.isDeleted &&
 | 
			
		||||
              !(isTextElement(element) && element.containerId)
 | 
			
		||||
            ) {
 | 
			
		||||
            if (!element.isDeleted) {
 | 
			
		||||
              map[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
            return map;
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,6 @@ import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { getContainerElement } from "../element/textElement";
 | 
			
		||||
 | 
			
		||||
// `copiedStyles` is exported only for tests.
 | 
			
		||||
export let copiedStyles: string = "{}";
 | 
			
		||||
@@ -56,18 +55,13 @@ export const actionPasteStyles = register({
 | 
			
		||||
            opacity: pastedElement?.opacity,
 | 
			
		||||
            roughness: pastedElement?.roughness,
 | 
			
		||||
          });
 | 
			
		||||
          if (isTextElement(newElement) && isTextElement(element)) {
 | 
			
		||||
          if (isTextElement(newElement)) {
 | 
			
		||||
            mutateElement(newElement, {
 | 
			
		||||
              fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
 | 
			
		||||
              fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              element,
 | 
			
		||||
              getContainerElement(element),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement);
 | 
			
		||||
          }
 | 
			
		||||
          return newElement;
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
@@ -14,6 +13,4 @@ export const actionToggleStats = register({
 | 
			
		||||
  },
 | 
			
		||||
  checked: (appState) => appState.showStats,
 | 
			
		||||
  contextItemLabel: "stats.title",
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        viewModeEnabled: !this.checked!(appState),
 | 
			
		||||
        selectedElementIds: {},
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: false,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,44 +0,0 @@
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { getBoundTextElement, measureText } from "../element/textElement";
 | 
			
		||||
import { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -26,7 +26,6 @@ export {
 | 
			
		||||
  actionZoomOut,
 | 
			
		||||
  actionResetZoom,
 | 
			
		||||
  actionZoomToFit,
 | 
			
		||||
  actionToggleTheme,
 | 
			
		||||
} from "./actionCanvas";
 | 
			
		||||
 | 
			
		||||
export { actionFinalize } from "./actionFinalize";
 | 
			
		||||
@@ -34,8 +33,9 @@ export { actionFinalize } from "./actionFinalize";
 | 
			
		||||
export {
 | 
			
		||||
  actionChangeProjectName,
 | 
			
		||||
  actionChangeExportBackground,
 | 
			
		||||
  actionSaveToActiveFile,
 | 
			
		||||
  actionSaveFileToDisk,
 | 
			
		||||
  actionToggleAutosave,
 | 
			
		||||
  actionSaveScene,
 | 
			
		||||
  actionSaveAsScene,
 | 
			
		||||
  actionLoadScene,
 | 
			
		||||
} from "./actionExport";
 | 
			
		||||
 | 
			
		||||
@@ -80,5 +80,3 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
			
		||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
			
		||||
 | 
			
		||||
export { actionToggleStats } from "./actionToggleStats";
 | 
			
		||||
export { actionUnbindText } from "./actionUnbindText";
 | 
			
		||||
export { actionLink } from "../element/Hyperlink";
 | 
			
		||||
 
 | 
			
		||||
@@ -5,12 +5,15 @@ import {
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
  ActionResult,
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { AppProps, AppState } from "../types";
 | 
			
		||||
import { MODES } from "../constants";
 | 
			
		||||
 | 
			
		||||
// This is the <App> component, but for now we don't care about anything but its
 | 
			
		||||
// `canvas` state.
 | 
			
		||||
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
 | 
			
		||||
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
 | 
			
		||||
@@ -18,13 +21,13 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
 | 
			
		||||
  getAppState: () => Readonly<AppState>;
 | 
			
		||||
  getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
 | 
			
		||||
  app: AppClassProperties;
 | 
			
		||||
  app: App;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    updater: UpdaterFn,
 | 
			
		||||
    getAppState: () => AppState,
 | 
			
		||||
    getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
 | 
			
		||||
    app: AppClassProperties,
 | 
			
		||||
    app: App,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.updater = (actionResult) => {
 | 
			
		||||
      if (actionResult && "then" in actionResult) {
 | 
			
		||||
@@ -48,7 +51,7 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
    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)
 | 
			
		||||
      .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
 | 
			
		||||
@@ -98,10 +101,11 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @param data additional data sent to the PanelComponent
 | 
			
		||||
   */
 | 
			
		||||
  renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
 | 
			
		||||
  // Id is an attribute that we can use to pass in data like keys.
 | 
			
		||||
  // This is needed for dynamically generated action components
 | 
			
		||||
  // like the user list. We can use this key to extract more
 | 
			
		||||
  // data from app state. This is an alternative to generic prop hell!
 | 
			
		||||
  renderAction = (name: ActionName, id?: string) => {
 | 
			
		||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
@@ -129,8 +133,8 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
          elements={this.getElementsIncludingDeleted()}
 | 
			
		||||
          appState={this.getAppState()}
 | 
			
		||||
          updateData={updateData}
 | 
			
		||||
          id={id}
 | 
			
		||||
          appProps={this.app.props}
 | 
			
		||||
          data={data}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -2,9 +2,7 @@ import { Action } from "./types";
 | 
			
		||||
 | 
			
		||||
export let actions: readonly Action[] = [];
 | 
			
		||||
 | 
			
		||||
export const register = <T extends Action>(action: T) => {
 | 
			
		||||
export const register = (action: Action): Action => {
 | 
			
		||||
  actions = actions.concat(action);
 | 
			
		||||
  return action as T & {
 | 
			
		||||
    keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
 | 
			
		||||
  };
 | 
			
		||||
  return action;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -25,8 +25,7 @@ export type ShortcutName =
 | 
			
		||||
  | "addToLibrary"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "link";
 | 
			
		||||
  | "flipVertical";
 | 
			
		||||
 | 
			
		||||
const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  cut: [getShortcutKey("CtrlOrCmd+X")],
 | 
			
		||||
@@ -58,12 +57,11 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
 | 
			
		||||
  gridMode: [getShortcutKey("CtrlOrCmd+'")],
 | 
			
		||||
  zenMode: [getShortcutKey("Alt+Z")],
 | 
			
		||||
  stats: [getShortcutKey("Alt+/")],
 | 
			
		||||
  stats: [],
 | 
			
		||||
  addToLibrary: [],
 | 
			
		||||
  flipHorizontal: [getShortcutKey("Shift+H")],
 | 
			
		||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
			
		||||
  viewMode: [getShortcutKey("Alt+R")],
 | 
			
		||||
  link: [getShortcutKey("CtrlOrCmd+K")],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,6 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import {
 | 
			
		||||
  AppClassProperties,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
			
		||||
import { AppState, ExcalidrawProps } from "../types";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
export type ActionResult =
 | 
			
		||||
@@ -16,10 +10,8 @@ export type ActionResult =
 | 
			
		||||
        AppState,
 | 
			
		||||
        "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
      > | null;
 | 
			
		||||
      files?: BinaryFiles | null;
 | 
			
		||||
      commitToHistory: boolean;
 | 
			
		||||
      syncHistory?: boolean;
 | 
			
		||||
      replaceFiles?: boolean;
 | 
			
		||||
    }
 | 
			
		||||
  | false;
 | 
			
		||||
 | 
			
		||||
@@ -27,7 +19,7 @@ type ActionFn = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  formData: any,
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
  app: { canvas: HTMLCanvasElement | null },
 | 
			
		||||
) => ActionResult | Promise<ActionResult>;
 | 
			
		||||
 | 
			
		||||
export type UpdaterFn = (res: ActionResult) => void;
 | 
			
		||||
@@ -53,13 +45,13 @@ export type ActionName =
 | 
			
		||||
  | "changeBackgroundColor"
 | 
			
		||||
  | "changeFillStyle"
 | 
			
		||||
  | "changeStrokeWidth"
 | 
			
		||||
  | "changeStrokeShape"
 | 
			
		||||
  | "changeSloppiness"
 | 
			
		||||
  | "changeStrokeStyle"
 | 
			
		||||
  | "changeArrowhead"
 | 
			
		||||
  | "changeOpacity"
 | 
			
		||||
  | "changeFontSize"
 | 
			
		||||
  | "toggleCanvasMenu"
 | 
			
		||||
  | "toggleAutosave"
 | 
			
		||||
  | "toggleEditMenu"
 | 
			
		||||
  | "undo"
 | 
			
		||||
  | "redo"
 | 
			
		||||
@@ -67,9 +59,9 @@ export type ActionName =
 | 
			
		||||
  | "changeProjectName"
 | 
			
		||||
  | "changeExportBackground"
 | 
			
		||||
  | "changeExportEmbedScene"
 | 
			
		||||
  | "changeExportScale"
 | 
			
		||||
  | "saveToActiveFile"
 | 
			
		||||
  | "saveFileToDisk"
 | 
			
		||||
  | "changeShouldAddWatermark"
 | 
			
		||||
  | "saveScene"
 | 
			
		||||
  | "saveAsScene"
 | 
			
		||||
  | "loadScene"
 | 
			
		||||
  | "duplicateSelection"
 | 
			
		||||
  | "deleteSelectedElements"
 | 
			
		||||
@@ -100,37 +92,25 @@ export type ActionName =
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "exportWithDarkMode"
 | 
			
		||||
  | "toggleTheme"
 | 
			
		||||
  | "increaseFontSize"
 | 
			
		||||
  | "decreaseFontSize"
 | 
			
		||||
  | "unbindText"
 | 
			
		||||
  | "link";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  updateData: (formData?: any) => void;
 | 
			
		||||
  appProps: ExcalidrawProps;
 | 
			
		||||
  data?: Partial<{ id: string; size: ToolButtonSize }>;
 | 
			
		||||
};
 | 
			
		||||
  | "exportWithDarkMode";
 | 
			
		||||
 | 
			
		||||
export interface Action {
 | 
			
		||||
  name: ActionName;
 | 
			
		||||
  PanelComponent?: React.FC<PanelComponentProps>;
 | 
			
		||||
  PanelComponent?: React.FC<{
 | 
			
		||||
    elements: readonly ExcalidrawElement[];
 | 
			
		||||
    appState: AppState;
 | 
			
		||||
    updateData: (formData?: any) => void;
 | 
			
		||||
    appProps: ExcalidrawProps;
 | 
			
		||||
    id?: string;
 | 
			
		||||
  }>;
 | 
			
		||||
  perform: ActionFn;
 | 
			
		||||
  keyPriority?: number;
 | 
			
		||||
  keyTest?: (
 | 
			
		||||
    event: React.KeyboardEvent | KeyboardEvent,
 | 
			
		||||
    event: KeyboardEvent,
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  contextItemLabel?:
 | 
			
		||||
    | string
 | 
			
		||||
    | ((
 | 
			
		||||
        elements: readonly ExcalidrawElement[],
 | 
			
		||||
        appState: Readonly<AppState>,
 | 
			
		||||
      ) => string);
 | 
			
		||||
  contextItemLabel?: string;
 | 
			
		||||
  contextItemPredicate?: (
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
@@ -141,7 +121,6 @@ export interface Action {
 | 
			
		||||
export interface ActionsManagerInterface {
 | 
			
		||||
  actions: Record<ActionName, Action>;
 | 
			
		||||
  registerAction: (action: Action) => void;
 | 
			
		||||
  handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
 | 
			
		||||
  handleKeyDown: (event: KeyboardEvent) => boolean;
 | 
			
		||||
  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
			
		||||
  executeAction: (action: Action) => void;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								src/align.ts
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								src/align.ts
									
									
									
									
									
								
							@@ -1,7 +1,13 @@
 | 
			
		||||
import { ExcalidrawElement } from "./element/types";
 | 
			
		||||
import { newElementWith } from "./element/mutateElement";
 | 
			
		||||
import { Box, getCommonBoundingBox } from "./element/bounds";
 | 
			
		||||
import { getMaximumGroups } from "./groups";
 | 
			
		||||
import { getCommonBounds } from "./element";
 | 
			
		||||
 | 
			
		||||
interface Box {
 | 
			
		||||
  minX: number;
 | 
			
		||||
  minY: number;
 | 
			
		||||
  maxX: number;
 | 
			
		||||
  maxY: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Alignment {
 | 
			
		||||
  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 = (
 | 
			
		||||
  group: ExcalidrawElement[],
 | 
			
		||||
  selectionBoundingBox: Box,
 | 
			
		||||
@@ -60,3 +88,8 @@ const calculateTranslation = (
 | 
			
		||||
      (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
 | 
			
		||||
  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
 | 
			
		||||
  return { minX, minY, maxX, maxY };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										179
									
								
								src/appState.ts
									
									
									
									
									
								
							
							
						
						
									
										179
									
								
								src/appState.ts
									
									
									
									
									
								
							@@ -3,23 +3,18 @@ import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
  EXPORT_SCALES,
 | 
			
		||||
  THEME,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { t } from "./i18n";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "./types";
 | 
			
		||||
import { getDateTime } from "./utils";
 | 
			
		||||
 | 
			
		||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
 | 
			
		||||
  ? devicePixelRatio
 | 
			
		||||
  : 1;
 | 
			
		||||
 | 
			
		||||
export const getDefaultAppState = (): Omit<
 | 
			
		||||
  AppState,
 | 
			
		||||
  "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
> => {
 | 
			
		||||
  return {
 | 
			
		||||
    theme: THEME.LIGHT,
 | 
			
		||||
    autosave: false,
 | 
			
		||||
    theme: "light",
 | 
			
		||||
    collaborators: new Map(),
 | 
			
		||||
    currentChartType: "bar",
 | 
			
		||||
    currentItemBackgroundColor: "transparent",
 | 
			
		||||
@@ -43,11 +38,8 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    editingLinearElement: null,
 | 
			
		||||
    elementLocked: false,
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    penMode: false,
 | 
			
		||||
    penDetected: false,
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
    exportBackground: true,
 | 
			
		||||
    exportScale: defaultExportScale,
 | 
			
		||||
    exportEmbedScene: false,
 | 
			
		||||
    exportWithDarkMode: false,
 | 
			
		||||
    fileHandle: null,
 | 
			
		||||
@@ -61,7 +53,6 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    multiElement: null,
 | 
			
		||||
    name: `${t("labels.untitled")}-${getDateTime()}`,
 | 
			
		||||
    openMenu: null,
 | 
			
		||||
    openPopup: null,
 | 
			
		||||
    pasteDialog: { shown: false, data: null },
 | 
			
		||||
    previousSelectedElementIds: {},
 | 
			
		||||
    resizingElement: null,
 | 
			
		||||
@@ -71,6 +62,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    selectedElementIds: {},
 | 
			
		||||
    selectedGroupIds: {},
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    shouldAddWatermark: false,
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showHelpDialog: false,
 | 
			
		||||
    showStats: false,
 | 
			
		||||
@@ -79,12 +71,8 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    toastMessage: null,
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: {
 | 
			
		||||
      value: 1 as NormalizedZoomValue,
 | 
			
		||||
    },
 | 
			
		||||
    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
 | 
			
		||||
    viewModeEnabled: false,
 | 
			
		||||
    pendingImageElement: null,
 | 
			
		||||
    showHyperlinkPopup: false,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -98,89 +86,78 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
    browser: boolean;
 | 
			
		||||
    /** whether to keep when exporting to file/database */
 | 
			
		||||
    export: boolean;
 | 
			
		||||
    /** server (shareLink/collab/...) */
 | 
			
		||||
    server: boolean;
 | 
			
		||||
  },
 | 
			
		||||
  T extends Record<keyof AppState, Values>,
 | 
			
		||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
 | 
			
		||||
  config)({
 | 
			
		||||
  theme: { browser: true, export: false, server: false },
 | 
			
		||||
  collaborators: { browser: false, export: false, server: false },
 | 
			
		||||
  currentChartType: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemBackgroundColor: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemEndArrowhead: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemFillStyle: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemFontFamily: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemFontSize: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemLinearStrokeSharpness: {
 | 
			
		||||
    browser: true,
 | 
			
		||||
    export: false,
 | 
			
		||||
    server: false,
 | 
			
		||||
  },
 | 
			
		||||
  currentItemOpacity: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemRoughness: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeColor: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeSharpness: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeStyle: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeWidth: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemTextAlign: { browser: true, export: false, server: false },
 | 
			
		||||
  cursorButton: { browser: true, export: false, server: false },
 | 
			
		||||
  draggingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false, server: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false, server: false },
 | 
			
		||||
  elementLocked: { browser: true, export: false, server: false },
 | 
			
		||||
  elementType: { browser: true, export: false, server: false },
 | 
			
		||||
  penMode: { browser: false, export: false, server: false },
 | 
			
		||||
  penDetected: { browser: false, export: false, server: false },
 | 
			
		||||
  errorMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false, server: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false, server: false },
 | 
			
		||||
  exportScale: { browser: true, export: false, server: false },
 | 
			
		||||
  exportWithDarkMode: { browser: true, export: false, server: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false, server: false },
 | 
			
		||||
  gridSize: { browser: true, export: true, server: true },
 | 
			
		||||
  height: { browser: false, export: false, server: false },
 | 
			
		||||
  isBindingEnabled: { browser: false, export: false, server: false },
 | 
			
		||||
  isLibraryOpen: { browser: false, export: false, server: false },
 | 
			
		||||
  isLoading: { browser: false, export: false, server: false },
 | 
			
		||||
  isResizing: { browser: false, export: false, server: false },
 | 
			
		||||
  isRotating: { browser: false, export: false, server: false },
 | 
			
		||||
  lastPointerDownWith: { browser: true, export: false, server: false },
 | 
			
		||||
  multiElement: { browser: false, export: false, server: false },
 | 
			
		||||
  name: { browser: true, export: false, server: false },
 | 
			
		||||
  offsetLeft: { browser: false, export: false, server: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false, server: false },
 | 
			
		||||
  openMenu: { browser: true, export: false, server: false },
 | 
			
		||||
  openPopup: { browser: false, export: false, server: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false, server: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  scrolledOutside: { browser: true, export: false, server: false },
 | 
			
		||||
  scrollX: { browser: true, export: false, server: false },
 | 
			
		||||
  scrollY: { browser: true, export: false, server: false },
 | 
			
		||||
  selectedElementIds: { browser: true, export: false, server: false },
 | 
			
		||||
  selectedGroupIds: { browser: true, export: false, server: false },
 | 
			
		||||
  selectionElement: { browser: false, export: false, server: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
 | 
			
		||||
  showHelpDialog: { browser: false, export: false, server: false },
 | 
			
		||||
  showStats: { browser: true, export: false, server: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false, server: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false, server: false },
 | 
			
		||||
  toastMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  viewBackgroundColor: { browser: true, export: true, server: true },
 | 
			
		||||
  width: { browser: false, export: false, server: false },
 | 
			
		||||
  zenModeEnabled: { browser: true, export: false, server: false },
 | 
			
		||||
  zoom: { browser: true, export: false, server: false },
 | 
			
		||||
  viewModeEnabled: { browser: false, export: false, server: false },
 | 
			
		||||
  pendingImageElement: { browser: false, export: false, server: false },
 | 
			
		||||
  showHyperlinkPopup: { browser: false, export: false, server: false },
 | 
			
		||||
  T extends Record<keyof AppState, Values>
 | 
			
		||||
>(
 | 
			
		||||
  config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
 | 
			
		||||
) => config)({
 | 
			
		||||
  autosave: { browser: true, export: false },
 | 
			
		||||
  theme: { browser: true, export: false },
 | 
			
		||||
  collaborators: { browser: false, export: false },
 | 
			
		||||
  currentChartType: { browser: true, export: false },
 | 
			
		||||
  currentItemBackgroundColor: { browser: true, export: false },
 | 
			
		||||
  currentItemEndArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemFillStyle: { browser: true, export: false },
 | 
			
		||||
  currentItemFontFamily: { browser: true, export: false },
 | 
			
		||||
  currentItemFontSize: { browser: true, export: false },
 | 
			
		||||
  currentItemLinearStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemOpacity: { browser: true, export: false },
 | 
			
		||||
  currentItemRoughness: { browser: true, export: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeColor: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeSharpness: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeStyle: { browser: true, export: false },
 | 
			
		||||
  currentItemStrokeWidth: { browser: true, export: false },
 | 
			
		||||
  currentItemTextAlign: { browser: true, export: false },
 | 
			
		||||
  cursorButton: { browser: true, export: false },
 | 
			
		||||
  draggingElement: { browser: false, export: false },
 | 
			
		||||
  editingElement: { browser: false, export: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false },
 | 
			
		||||
  elementLocked: { browser: true, export: false },
 | 
			
		||||
  elementType: { browser: true, export: false },
 | 
			
		||||
  errorMessage: { browser: false, export: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false },
 | 
			
		||||
  exportWithDarkMode: { browser: true, export: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false },
 | 
			
		||||
  gridSize: { browser: true, export: true },
 | 
			
		||||
  height: { browser: false, export: false },
 | 
			
		||||
  isBindingEnabled: { browser: false, export: false },
 | 
			
		||||
  isLibraryOpen: { browser: false, export: false },
 | 
			
		||||
  isLoading: { browser: false, export: false },
 | 
			
		||||
  isResizing: { browser: false, export: false },
 | 
			
		||||
  isRotating: { browser: false, export: false },
 | 
			
		||||
  lastPointerDownWith: { browser: true, export: false },
 | 
			
		||||
  multiElement: { browser: false, export: false },
 | 
			
		||||
  name: { browser: true, export: false },
 | 
			
		||||
  offsetLeft: { browser: false, export: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false },
 | 
			
		||||
  openMenu: { browser: true, export: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false },
 | 
			
		||||
  scrolledOutside: { browser: true, export: false },
 | 
			
		||||
  scrollX: { browser: true, export: false },
 | 
			
		||||
  scrollY: { browser: true, export: false },
 | 
			
		||||
  selectedElementIds: { browser: true, export: false },
 | 
			
		||||
  selectedGroupIds: { browser: true, export: false },
 | 
			
		||||
  selectionElement: { browser: false, export: false },
 | 
			
		||||
  shouldAddWatermark: { browser: true, export: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false },
 | 
			
		||||
  showHelpDialog: { browser: false, export: false },
 | 
			
		||||
  showStats: { browser: true, export: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false },
 | 
			
		||||
  toastMessage: { browser: false, export: false },
 | 
			
		||||
  viewBackgroundColor: { browser: true, export: true },
 | 
			
		||||
  width: { browser: false, export: false },
 | 
			
		||||
  zenModeEnabled: { browser: true, export: false },
 | 
			
		||||
  zoom: { browser: true, export: false },
 | 
			
		||||
  viewModeEnabled: { browser: false, export: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const _clearAppStateForStorage = <
 | 
			
		||||
  ExportType extends "export" | "browser" | "server",
 | 
			
		||||
>(
 | 
			
		||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
 | 
			
		||||
  appState: Partial<AppState>,
 | 
			
		||||
  exportType: ExportType,
 | 
			
		||||
) => {
 | 
			
		||||
@@ -193,10 +170,8 @@ const _clearAppStateForStorage = <
 | 
			
		||||
  for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
 | 
			
		||||
    const propConfig = APP_STATE_STORAGE_CONF[key];
 | 
			
		||||
    if (propConfig?.[exportType]) {
 | 
			
		||||
      const nextValue = appState[key];
 | 
			
		||||
 | 
			
		||||
      // https://github.com/microsoft/TypeScript/issues/31445
 | 
			
		||||
      (stateForExport as any)[key] = nextValue;
 | 
			
		||||
      // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
 | 
			
		||||
      stateForExport[key] = appState[key];
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return stateForExport;
 | 
			
		||||
@@ -209,7 +184,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
 | 
			
		||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
 | 
			
		||||
  return _clearAppStateForStorage(appState, "export");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
 | 
			
		||||
  return _clearAppStateForStorage(appState, "server");
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -3,26 +3,17 @@ import {
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "./element/types";
 | 
			
		||||
import { getSelectedElements } from "./scene";
 | 
			
		||||
import { AppState, BinaryFiles } from "./types";
 | 
			
		||||
import { AppState } from "./types";
 | 
			
		||||
import { SVG_EXPORT_TAG } from "./scene/export";
 | 
			
		||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 | 
			
		||||
import { isInitializedImageElement } from "./element/typeChecks";
 | 
			
		||||
import { canvasToBlob } from "./data/blob";
 | 
			
		||||
import { EXPORT_DATA_TYPES } from "./constants";
 | 
			
		||||
 | 
			
		||||
type ElementsClipboard = {
 | 
			
		||||
  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
			
		||||
  elements: ExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface ClipboardData {
 | 
			
		||||
  spreadsheet?: Spreadsheet;
 | 
			
		||||
  elements?: readonly ExcalidrawElement[];
 | 
			
		||||
  files?: BinaryFiles;
 | 
			
		||||
  text?: string;
 | 
			
		||||
  errorMessage?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let CLIPBOARD = "";
 | 
			
		||||
let PREFER_APP_CLIPBOARD = false;
 | 
			
		||||
 | 
			
		||||
@@ -40,7 +31,7 @@ export const probablySupportsClipboardBlob =
 | 
			
		||||
 | 
			
		||||
const clipboardContainsElements = (
 | 
			
		||||
  contents: any,
 | 
			
		||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
 | 
			
		||||
): contents is { elements: ExcalidrawElement[] } => {
 | 
			
		||||
  if (
 | 
			
		||||
    [
 | 
			
		||||
      EXPORT_DATA_TYPES.excalidraw,
 | 
			
		||||
@@ -56,26 +47,17 @@ const clipboardContainsElements = (
 | 
			
		||||
export const copyToClipboard = async (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  files: BinaryFiles,
 | 
			
		||||
) => {
 | 
			
		||||
  // select binded text elements when copying
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
  const contents: ElementsClipboard = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
			
		||||
    elements: selectedElements,
 | 
			
		||||
    files: selectedElements.reduce((acc, element) => {
 | 
			
		||||
      if (isInitializedImageElement(element) && files[element.fileId]) {
 | 
			
		||||
        acc[element.fileId] = files[element.fileId];
 | 
			
		||||
      }
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {} as BinaryFiles),
 | 
			
		||||
    elements: getSelectedElements(elements, appState),
 | 
			
		||||
  };
 | 
			
		||||
  const json = JSON.stringify(contents);
 | 
			
		||||
  CLIPBOARD = json;
 | 
			
		||||
  try {
 | 
			
		||||
    PREFER_APP_CLIPBOARD = false;
 | 
			
		||||
    await copyTextToSystemClipboard(json);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    PREFER_APP_CLIPBOARD = true;
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
@@ -88,7 +70,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    return JSON.parse(CLIPBOARD);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    return {};
 | 
			
		||||
  }
 | 
			
		||||
@@ -128,7 +110,12 @@ const getSystemClipboard = async (
 | 
			
		||||
 */
 | 
			
		||||
export const parseClipboard = async (
 | 
			
		||||
  event: ClipboardEvent | null,
 | 
			
		||||
): Promise<ClipboardData> => {
 | 
			
		||||
): Promise<{
 | 
			
		||||
  spreadsheet?: Spreadsheet;
 | 
			
		||||
  elements?: readonly ExcalidrawElement[];
 | 
			
		||||
  text?: string;
 | 
			
		||||
  errorMessage?: string;
 | 
			
		||||
}> => {
 | 
			
		||||
  const systemClipboard = await getSystemClipboard(event);
 | 
			
		||||
 | 
			
		||||
  // if system clipboard empty, couldn't be resolved, or contains previously
 | 
			
		||||
@@ -150,10 +137,7 @@ export const parseClipboard = async (
 | 
			
		||||
  try {
 | 
			
		||||
    const systemClipboardData = JSON.parse(systemClipboard);
 | 
			
		||||
    if (clipboardContainsElements(systemClipboardData)) {
 | 
			
		||||
      return {
 | 
			
		||||
        elements: systemClipboardData.elements,
 | 
			
		||||
        files: systemClipboardData.files,
 | 
			
		||||
      };
 | 
			
		||||
      return { elements: systemClipboardData.elements };
 | 
			
		||||
    }
 | 
			
		||||
    return appClipboardData;
 | 
			
		||||
  } catch {
 | 
			
		||||
@@ -166,9 +150,10 @@ export const parseClipboard = async (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
 | 
			
		||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
 | 
			
		||||
  const blob = await canvasToBlob(canvas);
 | 
			
		||||
  await navigator.clipboard.write([
 | 
			
		||||
    new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
 | 
			
		||||
    new window.ClipboardItem({ "image/png": blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -180,7 +165,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
 | 
			
		||||
      // not focused
 | 
			
		||||
      await navigator.clipboard.writeText(text || "");
 | 
			
		||||
      copied = true;
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -220,7 +205,7 @@ const copyTextViaExecCommand = (text: string) => {
 | 
			
		||||
    textarea.setSelectionRange(0, textarea.value.length);
 | 
			
		||||
 | 
			
		||||
    success = document.execCommand("copy");
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,15 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement, PointerType } from "../element/types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import {
 | 
			
		||||
  canChangeSharpness,
 | 
			
		||||
  canHaveArrowheads,
 | 
			
		||||
  getTargetElements,
 | 
			
		||||
  hasBackground,
 | 
			
		||||
  hasStrokeStyle,
 | 
			
		||||
  hasStrokeWidth,
 | 
			
		||||
  hasStroke,
 | 
			
		||||
  hasText,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
@@ -18,7 +17,6 @@ import { AppState, Zoom } from "../types";
 | 
			
		||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
 | 
			
		||||
export const SelectedShapeActions = ({
 | 
			
		||||
  appState,
 | 
			
		||||
@@ -49,36 +47,16 @@ export const SelectedShapeActions = ({
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    targetElements.some((element) => hasBackground(element.type));
 | 
			
		||||
 | 
			
		||||
  let commonSelectedType: string | null = targetElements[0]?.type || null;
 | 
			
		||||
 | 
			
		||||
  for (const element of targetElements) {
 | 
			
		||||
    if (element.type !== commonSelectedType) {
 | 
			
		||||
      commonSelectedType = null;
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="panelColumn">
 | 
			
		||||
      {((hasStrokeColor(elementType) &&
 | 
			
		||||
        elementType !== "image" &&
 | 
			
		||||
        commonSelectedType !== "image") ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeColor")}
 | 
			
		||||
      {renderAction("changeStrokeColor")}
 | 
			
		||||
      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
			
		||||
      {showFillIcons && renderAction("changeFillStyle")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeWidth(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeWidth(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeWidth")}
 | 
			
		||||
 | 
			
		||||
      {(elementType === "freedraw" ||
 | 
			
		||||
        targetElements.some((element) => element.type === "freedraw")) &&
 | 
			
		||||
        renderAction("changeStrokeShape")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeStyle(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeStyle(element.type))) && (
 | 
			
		||||
      {(hasStroke(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStroke(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeStrokeWidth")}
 | 
			
		||||
          {renderAction("changeStrokeStyle")}
 | 
			
		||||
          {renderAction("changeSloppiness")}
 | 
			
		||||
        </>
 | 
			
		||||
@@ -158,7 +136,6 @@ export const SelectedShapeActions = ({
 | 
			
		||||
            {renderAction("deleteSelectedElements")}
 | 
			
		||||
            {renderAction("group")}
 | 
			
		||||
            {renderAction("ungroup")}
 | 
			
		||||
            {targetElements.length === 1 && renderAction("link")}
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -166,24 +143,31 @@ 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 = ({
 | 
			
		||||
  canvas,
 | 
			
		||||
  elementType,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  isLibraryOpen,
 | 
			
		||||
}: {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  onImageAction: (data: { pointerType: PointerType | null }) => void;
 | 
			
		||||
  isLibraryOpen: boolean;
 | 
			
		||||
}) => (
 | 
			
		||||
  <>
 | 
			
		||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
      const label = t(`toolBar.${value}`);
 | 
			
		||||
      const letter = key && (typeof key === "string" ? key : key[0]);
 | 
			
		||||
      const shortcut = letter
 | 
			
		||||
        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
 | 
			
		||||
        : `${index + 1}`;
 | 
			
		||||
      const letter = typeof key === "string" ? key : key[0];
 | 
			
		||||
      const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
 | 
			
		||||
        index + 1
 | 
			
		||||
      }`;
 | 
			
		||||
      return (
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          className="Shape"
 | 
			
		||||
@@ -197,20 +181,31 @@ export const ShapesSwitcher = ({
 | 
			
		||||
          aria-label={capitalizeString(label)}
 | 
			
		||||
          aria-keyshortcuts={shortcut}
 | 
			
		||||
          data-testid={value}
 | 
			
		||||
          onChange={({ pointerType }) => {
 | 
			
		||||
          onChange={() => {
 | 
			
		||||
            setAppState({
 | 
			
		||||
              elementType: value,
 | 
			
		||||
              multiElement: null,
 | 
			
		||||
              selectedElementIds: {},
 | 
			
		||||
            });
 | 
			
		||||
            setCursorForShape(canvas, value);
 | 
			
		||||
            if (value === "image") {
 | 
			
		||||
              onImageAction({ pointerType });
 | 
			
		||||
            }
 | 
			
		||||
            setAppState({});
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    })}
 | 
			
		||||
    <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 });
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  </>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@@ -223,9 +218,12 @@ export const ZoomActions = ({
 | 
			
		||||
}) => (
 | 
			
		||||
  <Stack.Col gap={1}>
 | 
			
		||||
    <Stack.Row gap={1} align="center">
 | 
			
		||||
      {renderAction("zoomOut")}
 | 
			
		||||
      {renderAction("zoomIn")}
 | 
			
		||||
      {renderAction("zoomOut")}
 | 
			
		||||
      {renderAction("resetZoom")}
 | 
			
		||||
      <div style={{ marginInlineStart: 4 }}>
 | 
			
		||||
        {(zoom.value * 100).toFixed(0)}%
 | 
			
		||||
      </div>
 | 
			
		||||
    </Stack.Row>
 | 
			
		||||
  </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
											
										
									
								
							@@ -1,6 +1,7 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { DarkModeToggle } from "./DarkModeToggle";
 | 
			
		||||
 | 
			
		||||
export const BackgroundPickerAndDarkModeToggle = ({
 | 
			
		||||
  appState,
 | 
			
		||||
@@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
 | 
			
		||||
}) => (
 | 
			
		||||
  <div style={{ display: "flex" }}>
 | 
			
		||||
    {actionManager.renderAction("changeViewBackgroundColor")}
 | 
			
		||||
    {showThemeBtn && actionManager.renderAction("toggleTheme")}
 | 
			
		||||
    {showThemeBtn && (
 | 
			
		||||
      <div style={{ marginInlineStart: "0.25rem" }}>
 | 
			
		||||
        <DarkModeToggle
 | 
			
		||||
          value={appState.theme}
 | 
			
		||||
          onChange={(theme) => {
 | 
			
		||||
            setAppState({ theme });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    )}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
// 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,
 | 
			
		||||
  group,
 | 
			
		||||
}: {
 | 
			
		||||
  options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
 | 
			
		||||
  options: { value: T; text: string; icon: JSX.Element }[];
 | 
			
		||||
  value: T | null;
 | 
			
		||||
  onChange: (value: T) => void;
 | 
			
		||||
  group: string;
 | 
			
		||||
@@ -24,7 +25,6 @@ export const ButtonIconSelect = <T extends Object>({
 | 
			
		||||
          name={group}
 | 
			
		||||
          onChange={() => onChange(option.value)}
 | 
			
		||||
          checked={value === option.value}
 | 
			
		||||
          data-testid={option.testId}
 | 
			
		||||
        />
 | 
			
		||||
        {option.icon}
 | 
			
		||||
      </label>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
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 { useIsMobile } 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={useIsMobile()}
 | 
			
		||||
        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 { ToolButton } from "./ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { users } from "./icons";
 | 
			
		||||
 | 
			
		||||
import "./CollabButton.scss";
 | 
			
		||||
 
 | 
			
		||||
@@ -160,7 +160,7 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .color-picker-input {
 | 
			
		||||
    width: 11ch; /* length of `transparent` */
 | 
			
		||||
    width: 12ch; /* length of `transparent` + 1 */
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    background-color: var(--input-bg-color);
 | 
			
		||||
@@ -218,7 +218,7 @@
 | 
			
		||||
      left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Popover } from "./Popover";
 | 
			
		||||
import { isTransparent } from "../utils";
 | 
			
		||||
 | 
			
		||||
import "./ColorPicker.scss";
 | 
			
		||||
import { isArrowKey, KEYS } from "../keys";
 | 
			
		||||
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getColor = (color: string): string | null => {
 | 
			
		||||
  if (isTransparent(color)) {
 | 
			
		||||
  if (color === "transparent") {
 | 
			
		||||
    return color;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -116,7 +115,6 @@ const Picker = ({
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -138,41 +136,36 @@ const Picker = ({
 | 
			
		||||
        }}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
      >
 | 
			
		||||
        {colors.map((_color, i) => {
 | 
			
		||||
          const _colorWithoutHash = _color.replace("#", "");
 | 
			
		||||
          return (
 | 
			
		||||
            <button
 | 
			
		||||
              className="color-picker-swatch"
 | 
			
		||||
              onClick={(event) => {
 | 
			
		||||
                (event.currentTarget as HTMLButtonElement).focus();
 | 
			
		||||
                onChange(_color);
 | 
			
		||||
              }}
 | 
			
		||||
              title={`${t(`colors.${_colorWithoutHash}`)}${
 | 
			
		||||
                !isTransparent(_color) ? ` (${_color})` : ""
 | 
			
		||||
              } — ${keyBindings[i].toUpperCase()}`}
 | 
			
		||||
              aria-label={t(`colors.${_colorWithoutHash}`)}
 | 
			
		||||
              aria-keyshortcuts={keyBindings[i]}
 | 
			
		||||
              style={{ color: _color }}
 | 
			
		||||
              key={_color}
 | 
			
		||||
              ref={(el) => {
 | 
			
		||||
                if (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">{keyBindings[i]}</span>
 | 
			
		||||
            </button>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
        {colors.map((_color, i) => (
 | 
			
		||||
          <button
 | 
			
		||||
            className="color-picker-swatch"
 | 
			
		||||
            onClick={(event) => {
 | 
			
		||||
              (event.currentTarget as HTMLButtonElement).focus();
 | 
			
		||||
              onChange(_color);
 | 
			
		||||
            }}
 | 
			
		||||
            title={`${_color} — ${keyBindings[i].toUpperCase()}`}
 | 
			
		||||
            aria-label={_color}
 | 
			
		||||
            aria-keyshortcuts={keyBindings[i]}
 | 
			
		||||
            style={{ color: _color }}
 | 
			
		||||
            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 && (
 | 
			
		||||
          <ColorInput
 | 
			
		||||
            color={color}
 | 
			
		||||
@@ -244,16 +237,13 @@ export const ColorPicker = ({
 | 
			
		||||
  color,
 | 
			
		||||
  onChange,
 | 
			
		||||
  label,
 | 
			
		||||
  isActive,
 | 
			
		||||
  setActive,
 | 
			
		||||
}: {
 | 
			
		||||
  type: "canvasBackground" | "elementBackground" | "elementStroke";
 | 
			
		||||
  color: string | null;
 | 
			
		||||
  onChange: (color: string) => void;
 | 
			
		||||
  label: string;
 | 
			
		||||
  isActive: boolean;
 | 
			
		||||
  setActive: (active: boolean) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [isActive, setActive] = React.useState(false);
 | 
			
		||||
  const pickerButton = React.useRef<HTMLButtonElement>(null);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .context-menu-option {
 | 
			
		||||
      display: block;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { render, unmountComponentAtNode } from "react-dom";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { Popover } from "./Popover";
 | 
			
		||||
@@ -11,7 +12,6 @@ import {
 | 
			
		||||
import { Action } from "../actions/types";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export type ContextMenuOption = "separator" | Action;
 | 
			
		||||
 | 
			
		||||
@@ -22,7 +22,6 @@ type ContextMenuProps = {
 | 
			
		||||
  left: number;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ContextMenu = ({
 | 
			
		||||
@@ -32,7 +31,6 @@ const ContextMenu = ({
 | 
			
		||||
  left,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
  elements,
 | 
			
		||||
}: ContextMenuProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover
 | 
			
		||||
@@ -40,10 +38,6 @@ const ContextMenu = ({
 | 
			
		||||
      top={top}
 | 
			
		||||
      left={left}
 | 
			
		||||
      fitInViewport={true}
 | 
			
		||||
      offsetLeft={appState.offsetLeft}
 | 
			
		||||
      offsetTop={appState.offsetTop}
 | 
			
		||||
      viewportWidth={appState.width}
 | 
			
		||||
      viewportHeight={appState.height}
 | 
			
		||||
    >
 | 
			
		||||
      <ul
 | 
			
		||||
        className="context-menu"
 | 
			
		||||
@@ -55,14 +49,9 @@ const ContextMenu = ({
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const actionName = option.name;
 | 
			
		||||
          let label = "";
 | 
			
		||||
          if (option.contextItemLabel) {
 | 
			
		||||
            if (typeof option.contextItemLabel === "function") {
 | 
			
		||||
              label = t(option.contextItemLabel(elements, appState));
 | 
			
		||||
            } else {
 | 
			
		||||
              label = t(option.contextItemLabel);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          const label = option.contextItemLabel
 | 
			
		||||
            ? t(option.contextItemLabel)
 | 
			
		||||
            : "";
 | 
			
		||||
          return (
 | 
			
		||||
            <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
			
		||||
              <button
 | 
			
		||||
@@ -109,7 +98,6 @@ type ContextMenuParams = {
 | 
			
		||||
  actionManager: ContextMenuProps["actionManager"];
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
  container: HTMLElement;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleClose = (container: HTMLElement) => {
 | 
			
		||||
@@ -138,7 +126,6 @@ export default {
 | 
			
		||||
          onCloseRequest={() => handleClose(params.container)}
 | 
			
		||||
          actionManager={params.actionManager}
 | 
			
		||||
          appState={params.appState}
 | 
			
		||||
          elements={params.elements}
 | 
			
		||||
        />,
 | 
			
		||||
        getContextMenuNode(params.container),
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,42 @@
 | 
			
		||||
import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { THEME } from "../constants";
 | 
			
		||||
import { Theme } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export type Appearence = "light" | "dark";
 | 
			
		||||
 | 
			
		||||
// We chose to use only explicit toggle and not a third option for system value,
 | 
			
		||||
// but this could be added in the future.
 | 
			
		||||
export const DarkModeToggle = (props: {
 | 
			
		||||
  value: Theme;
 | 
			
		||||
  onChange: (value: Theme) => void;
 | 
			
		||||
  value: Appearence;
 | 
			
		||||
  onChange: (value: Appearence) => void;
 | 
			
		||||
  title?: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const title =
 | 
			
		||||
    props.title ||
 | 
			
		||||
    (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
 | 
			
		||||
  const title = props.title
 | 
			
		||||
    ? props.title
 | 
			
		||||
    : props.value === "dark"
 | 
			
		||||
    ? t("buttons.lightMode")
 | 
			
		||||
    : t("buttons.darkMode");
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ToolButton
 | 
			
		||||
      type="icon"
 | 
			
		||||
      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)
 | 
			
		||||
      }
 | 
			
		||||
    <label
 | 
			
		||||
      className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
 | 
			
		||||
      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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .Dialog {
 | 
			
		||||
      --metric: calc(var(--space-factor) * 4);
 | 
			
		||||
      --inset-left: #{"max(var(--metric), var(--sal))"};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,30 +1,23 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import React, { useEffect } from "react";
 | 
			
		||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import "./Dialog.scss";
 | 
			
		||||
import { back, close } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import { Modal } from "./Modal";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export interface DialogProps {
 | 
			
		||||
export const Dialog = (props: {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  small?: boolean;
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  title: React.ReactNode;
 | 
			
		||||
  autofocus?: boolean;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
  closeOnClickOutside?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Dialog = (props: DialogProps) => {
 | 
			
		||||
}) => {
 | 
			
		||||
  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
			
		||||
  const [lastActiveElement] = useState(document.activeElement);
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!islandNode) {
 | 
			
		||||
@@ -72,26 +65,19 @@ export const Dialog = (props: DialogProps) => {
 | 
			
		||||
    return focusableElements ? Array.from(focusableElements) : [];
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onClose = () => {
 | 
			
		||||
    (lastActiveElement as HTMLElement).focus();
 | 
			
		||||
    props.onCloseRequest();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      className={clsx("Dialog", props.className)}
 | 
			
		||||
      labelledBy="dialog-title"
 | 
			
		||||
      maxWidth={props.small ? 550 : 800}
 | 
			
		||||
      onCloseRequest={onClose}
 | 
			
		||||
      theme={props.theme}
 | 
			
		||||
      closeOnClickOutside={props.closeOnClickOutside}
 | 
			
		||||
      onCloseRequest={props.onCloseRequest}
 | 
			
		||||
    >
 | 
			
		||||
      <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>
 | 
			
		||||
          <button
 | 
			
		||||
            className="Modal__close"
 | 
			
		||||
            onClick={onClose}
 | 
			
		||||
            onClick={props.onCloseRequest}
 | 
			
		||||
            aria-label={t("buttons.close")}
 | 
			
		||||
          >
 | 
			
		||||
            {useIsMobile() ? back : close}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ import React, { useState } from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
 | 
			
		||||
export const ErrorDialog = ({
 | 
			
		||||
  message,
 | 
			
		||||
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
 | 
			
		||||
  onClose?: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(!!message);
 | 
			
		||||
  const { container: excalidrawContainer } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
 | 
			
		||||
    if (onClose) {
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    // TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
 | 
			
		||||
    excalidrawContainer?.focus();
 | 
			
		||||
  }, [onClose, excalidrawContainer]);
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,34 @@
 | 
			
		||||
    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 {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
@@ -57,63 +84,4 @@
 | 
			
		||||
      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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										286
									
								
								src/components/ExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										286
									
								
								src/components/ExportDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,286 @@
 | 
			
		||||
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("toggleAutosave")}
 | 
			
		||||
        {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 {
 | 
			
		||||
  .FixedSideContainer {
 | 
			
		||||
    --margin: 0.25rem;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
  }
 | 
			
		||||
@@ -9,9 +10,9 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .FixedSideContainer_side_top {
 | 
			
		||||
    left: var(--space-factor);
 | 
			
		||||
    top: var(--space-factor);
 | 
			
		||||
    right: var(--space-factor);
 | 
			
		||||
    left: var(--margin);
 | 
			
		||||
    top: var(--margin);
 | 
			
		||||
    right: var(--margin);
 | 
			
		||||
    z-index: 2;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -22,16 +23,16 @@
 | 
			
		||||
 | 
			
		||||
/* TODO: if these are used, make sure to implement RTL support
 | 
			
		||||
.FixedSideContainer_side_left {
 | 
			
		||||
  left: var(--space-factor);
 | 
			
		||||
  top: var(--space-factor);
 | 
			
		||||
  bottom: var(--space-factor);
 | 
			
		||||
  left: var(--margin);
 | 
			
		||||
  top: var(--margin);
 | 
			
		||||
  bottom: var(--margin);
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.FixedSideContainer_side_right {
 | 
			
		||||
  right: var(--space-factor);
 | 
			
		||||
  top: var(--space-factor);
 | 
			
		||||
  bottom: var(--space-factor);
 | 
			
		||||
  right: var(--margin);
 | 
			
		||||
  top: var(--margin);
 | 
			
		||||
  bottom: var(--margin);
 | 
			
		||||
  z-index: 3;
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,15 @@
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { THEME } from "../../constants";
 | 
			
		||||
import { Theme } from "../../element/types";
 | 
			
		||||
 | 
			
		||||
// https://github.com/tholman/github-corners
 | 
			
		||||
export const GitHubCorner = React.memo(
 | 
			
		||||
  ({ theme, dir }: { theme: Theme; dir: string }) => (
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) => (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      width="40"
 | 
			
		||||
      height="40"
 | 
			
		||||
      viewBox="0 0 250 250"
 | 
			
		||||
      className="rtl-mirror"
 | 
			
		||||
      style={{
 | 
			
		||||
        marginTop: "calc(var(--space-factor) * -1)",
 | 
			
		||||
        [dir === "rtl" ? "marginLeft" : "marginRight"]:
 | 
			
		||||
          "calc(var(--space-factor) * -1)",
 | 
			
		||||
      }}
 | 
			
		||||
      className="github-corner rtl-mirror"
 | 
			
		||||
    >
 | 
			
		||||
      <a
 | 
			
		||||
        href="https://github.com/excalidraw/excalidraw"
 | 
			
		||||
@@ -26,18 +19,18 @@ export const GitHubCorner = React.memo(
 | 
			
		||||
      >
 | 
			
		||||
        <path
 | 
			
		||||
          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
 | 
			
		||||
          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"
 | 
			
		||||
          style={{ transformOrigin: "130px 106px" }}
 | 
			
		||||
          fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
 | 
			
		||||
          fill={theme === "light" ? oc.white : oc.black}
 | 
			
		||||
        />
 | 
			
		||||
        <path
 | 
			
		||||
          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"
 | 
			
		||||
          fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
 | 
			
		||||
          fill={theme === "light" ? oc.white : oc.black}
 | 
			
		||||
        />
 | 
			
		||||
      </a>
 | 
			
		||||
    </svg>
 | 
			
		||||
@@ -153,19 +153,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
 | 
			
		||||
                <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.freedraw")}
 | 
			
		||||
                  shortcuts={["Shift + P", "X", "7"]}
 | 
			
		||||
                  label={t("toolBar.draw")}
 | 
			
		||||
                  shortcuts={["Shift+P", "7"]}
 | 
			
		||||
                />
 | 
			
		||||
                <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("helpDialog.editSelectedShape")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey("Enter"),
 | 
			
		||||
                    t("helpDialog.doubleClick"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.textNewLine")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -205,10 +196,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("helpDialog.preventBinding")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("toolBar.link")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
              <ShortcutIsland caption={t("helpDialog.view")}>
 | 
			
		||||
                <Shortcut
 | 
			
		||||
@@ -244,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.viewMode")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+R")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.toggleTheme")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+Shift+D")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("stats.title")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Alt+/")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
            <Column>
 | 
			
		||||
@@ -264,18 +243,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.multiSelect")}
 | 
			
		||||
                  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
 | 
			
		||||
                  label={t("labels.moveCanvas")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -390,22 +357,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  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>
 | 
			
		||||
            </Column>
 | 
			
		||||
          </Columns>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { questionCircle } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
type HelpIconProps = {
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
 | 
			
		||||
    color: $oc-gray-6;
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      position: static;
 | 
			
		||||
      padding-right: 2em;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +1,21 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
 | 
			
		||||
import "./HintViewer.scss";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  isImageElement,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isTextBindableContainer,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
 | 
			
		||||
interface HintViewerProps {
 | 
			
		||||
interface Hint {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  isMobile: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
  const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
 | 
			
		||||
  const multiMode = appState.multiElement !== null;
 | 
			
		||||
 | 
			
		||||
  if (elementType === "arrow" || elementType === "line") {
 | 
			
		||||
    if (!multiMode) {
 | 
			
		||||
      return t("hints.linearElement");
 | 
			
		||||
@@ -29,7 +23,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
    return t("hints.linearElementMulti");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "freedraw") {
 | 
			
		||||
  if (elementType === "draw") {
 | 
			
		||||
    return t("hints.freeDraw");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -37,12 +31,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
    return t("hints.text");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (appState.elementType === "image" && appState.pendingImageElement) {
 | 
			
		||||
    return t("hints.placeImage");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    isResizing &&
 | 
			
		||||
    lastPointerDownWith === "mouse" &&
 | 
			
		||||
@@ -52,62 +41,29 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
 | 
			
		||||
    if (isLinearElement(targetElement) && targetElement.points.length === 2) {
 | 
			
		||||
      return t("hints.lockAngle");
 | 
			
		||||
    }
 | 
			
		||||
    return isImageElement(targetElement)
 | 
			
		||||
      ? t("hints.resizeImage")
 | 
			
		||||
      : t("hints.resize");
 | 
			
		||||
    return t("hints.resize");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isRotating && lastPointerDownWith === "mouse") {
 | 
			
		||||
    return t("hints.rotate");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
 | 
			
		||||
    return t("hints.text_selected");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (appState.editingElement && isTextElement(appState.editingElement)) {
 | 
			
		||||
    return t("hints.text_editing");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "selection") {
 | 
			
		||||
    if (
 | 
			
		||||
      appState.draggingElement?.type === "selection" &&
 | 
			
		||||
      !appState.editingElement &&
 | 
			
		||||
      !appState.editingLinearElement
 | 
			
		||||
    ) {
 | 
			
		||||
      return t("hints.deepBoxSelect");
 | 
			
		||||
    }
 | 
			
		||||
    if (!selectedElements.length && !isMobile) {
 | 
			
		||||
      return t("hints.canvasPanning");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1) {
 | 
			
		||||
    if (isLinearElement(selectedElements[0])) {
 | 
			
		||||
      if (appState.editingLinearElement) {
 | 
			
		||||
        return appState.editingLinearElement.selectedPointsIndices
 | 
			
		||||
          ? t("hints.lineEditor_pointSelected")
 | 
			
		||||
          : t("hints.lineEditor_nothingSelected");
 | 
			
		||||
      }
 | 
			
		||||
      return t("hints.lineEditor_info");
 | 
			
		||||
    }
 | 
			
		||||
    if (isTextBindableContainer(selectedElements[0])) {
 | 
			
		||||
      return t("hints.bindTextToElement");
 | 
			
		||||
  if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      return appState.editingLinearElement.activePointIndex
 | 
			
		||||
        ? t("hints.lineEditor_pointSelected")
 | 
			
		||||
        : t("hints.lineEditor_nothingSelected");
 | 
			
		||||
    }
 | 
			
		||||
    return t("hints.lineEditor_info");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const HintViewer = ({
 | 
			
		||||
  appState,
 | 
			
		||||
  elements,
 | 
			
		||||
  isMobile,
 | 
			
		||||
}: HintViewerProps) => {
 | 
			
		||||
export const HintViewer = ({ appState, elements }: Hint) => {
 | 
			
		||||
  let hint = getHints({
 | 
			
		||||
    appState,
 | 
			
		||||
    elements,
 | 
			
		||||
    isMobile,
 | 
			
		||||
  });
 | 
			
		||||
  if (!hint) {
 | 
			
		||||
    return null;
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    &:focus-visible {
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: transparent;
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
      & svg {
 | 
			
		||||
@@ -90,7 +90,7 @@
 | 
			
		||||
  .picker-content {
 | 
			
		||||
    padding: 0.5rem;
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: repeat(3, auto);
 | 
			
		||||
    grid-auto-flow: column;
 | 
			
		||||
    grid-gap: 0.5rem;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
@@ -111,7 +111,7 @@
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      left: 2px;
 | 
			
		||||
    }
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -88,7 +88,6 @@ function Picker<T>({
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 
 | 
			
		||||
@@ -1,273 +0,0 @@
 | 
			
		||||
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 "./App";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { exportToCanvas } from "../scene/export";
 | 
			
		||||
import { AppState, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { clipboard, exportImage } from "./icons";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import OpenColor from "open-color";
 | 
			
		||||
import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
 | 
			
		||||
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 ExportButton: React.FC<{
 | 
			
		||||
  color: keyof OpenColor;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  title: string;
 | 
			
		||||
  shade?: number;
 | 
			
		||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      className="ExportDialog-imageExportButton"
 | 
			
		||||
      style={{
 | 
			
		||||
        ["--button-color" as any]: OpenColor[color][shade],
 | 
			
		||||
        ["--button-color-darker" as any]: OpenColor[color][shade + 1],
 | 
			
		||||
        ["--button-color-darkest" as any]: OpenColor[color][shade + 2],
 | 
			
		||||
      }}
 | 
			
		||||
      title={title}
 | 
			
		||||
      aria-label={title}
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ImageExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const someElementIsSelected = isSomeElementSelected(elements, appState);
 | 
			
		||||
  const [exportSelected, setExportSelected] = useState(someElementIsSelected);
 | 
			
		||||
  const previewRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const { exportBackground, viewBackgroundColor } = appState;
 | 
			
		||||
 | 
			
		||||
  const exportedElements = exportSelected
 | 
			
		||||
    ? getSelectedElements(elements, appState, true)
 | 
			
		||||
    : elements;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setExportSelected(someElementIsSelected);
 | 
			
		||||
  }, [someElementIsSelected]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const previewNode = previewRef.current;
 | 
			
		||||
    if (!previewNode) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    exportToCanvas(exportedElements, appState, files, {
 | 
			
		||||
      exportBackground,
 | 
			
		||||
      viewBackgroundColor,
 | 
			
		||||
      exportPadding,
 | 
			
		||||
    })
 | 
			
		||||
      .then((canvas) => {
 | 
			
		||||
        // if converting to blob fails, there's some problem that will
 | 
			
		||||
        // likely prevent preview and export (e.g. canvas too big)
 | 
			
		||||
        return canvasToBlob(canvas).then(() => {
 | 
			
		||||
          renderPreview(canvas, previewNode);
 | 
			
		||||
        });
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
        renderPreview(new CanvasError(), previewNode);
 | 
			
		||||
      });
 | 
			
		||||
  }, [
 | 
			
		||||
    appState,
 | 
			
		||||
    files,
 | 
			
		||||
    exportedElements,
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    exportPadding,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ExportDialog">
 | 
			
		||||
      <div className="ExportDialog__preview" ref={previewRef} />
 | 
			
		||||
      {supportsContextFilters &&
 | 
			
		||||
        actionManager.renderAction("exportWithDarkMode")}
 | 
			
		||||
      <div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "grid",
 | 
			
		||||
            gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
 | 
			
		||||
            // dunno why this is needed, but when the items wrap it creates
 | 
			
		||||
            // an overflow
 | 
			
		||||
            overflow: "hidden",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {actionManager.renderAction("changeExportBackground")}
 | 
			
		||||
          {someElementIsSelected && (
 | 
			
		||||
            <CheckboxItem
 | 
			
		||||
              checked={exportSelected}
 | 
			
		||||
              onChange={(checked) => setExportSelected(checked)}
 | 
			
		||||
            >
 | 
			
		||||
              {t("labels.onlySelected")}
 | 
			
		||||
            </CheckboxItem>
 | 
			
		||||
          )}
 | 
			
		||||
          {actionManager.renderAction("changeExportEmbedScene")}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
 | 
			
		||||
        <Stack.Row gap={2}>
 | 
			
		||||
          {actionManager.renderAction("changeExportScale")}
 | 
			
		||||
        </Stack.Row>
 | 
			
		||||
        <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
          justifyContent: "center",
 | 
			
		||||
          margin: ".6em 0",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {!nativeFileSystemSupported &&
 | 
			
		||||
          actionManager.renderAction("changeProjectName")}
 | 
			
		||||
      </div>
 | 
			
		||||
      <Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
 | 
			
		||||
        <ExportButton
 | 
			
		||||
          color="indigo"
 | 
			
		||||
          title={t("buttons.exportToPng")}
 | 
			
		||||
          aria-label={t("buttons.exportToPng")}
 | 
			
		||||
          onClick={() => onExportToPng(exportedElements)}
 | 
			
		||||
        >
 | 
			
		||||
          PNG
 | 
			
		||||
        </ExportButton>
 | 
			
		||||
        <ExportButton
 | 
			
		||||
          color="red"
 | 
			
		||||
          title={t("buttons.exportToSvg")}
 | 
			
		||||
          aria-label={t("buttons.exportToSvg")}
 | 
			
		||||
          onClick={() => onExportToSvg(exportedElements)}
 | 
			
		||||
        >
 | 
			
		||||
          SVG
 | 
			
		||||
        </ExportButton>
 | 
			
		||||
        {probablySupportsClipboardBlob && (
 | 
			
		||||
          <ExportButton
 | 
			
		||||
            title={t("buttons.copyPngToClipboard")}
 | 
			
		||||
            onClick={() => onExportToClipboard(exportedElements)}
 | 
			
		||||
            color="gray"
 | 
			
		||||
            shade={7}
 | 
			
		||||
          >
 | 
			
		||||
            {clipboard}
 | 
			
		||||
          </ExportButton>
 | 
			
		||||
        )}
 | 
			
		||||
      </Stack.Row>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ImageExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  exportPadding?: number;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onExportToPng: ExportCB;
 | 
			
		||||
  onExportToSvg: ExportCB;
 | 
			
		||||
  onExportToClipboard: ExportCB;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        data-testid="image-export-button"
 | 
			
		||||
        icon={exportImage}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.exportImage")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        title={t("buttons.exportImage")}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
 | 
			
		||||
          <ImageExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            files={files}
 | 
			
		||||
            exportPadding={exportPadding}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onExportToPng={onExportToPng}
 | 
			
		||||
            onExportToSvg={onExportToSvg}
 | 
			
		||||
            onExportToClipboard={onExportToClipboard}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
          />
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,25 +1,30 @@
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
  children: React.ReactElement;
 | 
			
		||||
}
 | 
			
		||||
interface State {
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
}
 | 
			
		||||
export class InitializeApp extends React.Component<Props, State> {
 | 
			
		||||
  public state: { isLoading: boolean } = {
 | 
			
		||||
    isLoading: true,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
export const InitializeApp = (props: Props) => {
 | 
			
		||||
  const [loading, setLoading] = useState(true);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const updateLang = async () => {
 | 
			
		||||
      await setLanguage(currentLang);
 | 
			
		||||
    };
 | 
			
		||||
  async componentDidMount() {
 | 
			
		||||
    const currentLang =
 | 
			
		||||
      languages.find((lang) => lang.code === props.langCode) || defaultLang;
 | 
			
		||||
    updateLang();
 | 
			
		||||
    setLoading(false);
 | 
			
		||||
  }, [props.langCode]);
 | 
			
		||||
      languages.find((lang) => lang.code === this.props.langCode) ||
 | 
			
		||||
      defaultLang;
 | 
			
		||||
    await setLanguage(currentLang);
 | 
			
		||||
    this.setState({
 | 
			
		||||
      isLoading: false,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return loading ? <LoadingMessage /> : props.children;
 | 
			
		||||
};
 | 
			
		||||
  public render() {
 | 
			
		||||
    return this.state.isLoading ? <LoadingMessage /> : this.props.children;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,9 @@
 | 
			
		||||
  .Island {
 | 
			
		||||
    --padding: 0;
 | 
			
		||||
    background-color: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: saturate(100%) blur(10px);
 | 
			
		||||
    box-shadow: var(--shadow-island);
 | 
			
		||||
    border-radius: var(--border-radius-lg);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    padding: calc(var(--padding) * var(--space-factor));
 | 
			
		||||
    position: relative;
 | 
			
		||||
    transition: box-shadow 0.5s ease-in-out;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,135 +0,0 @@
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { exportFile, exportToFileIcon, link } from "./icons";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { actionSaveFileToDisk } from "../actions/actionExport";
 | 
			
		||||
import { Card } from "./Card";
 | 
			
		||||
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
 | 
			
		||||
export type ExportCB = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  scale?: number,
 | 
			
		||||
) => void;
 | 
			
		||||
 | 
			
		||||
const JSONExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  onCloseRequest: () => void;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
}) => {
 | 
			
		||||
  const { onExportToBackend } = exportOpts;
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ExportDialog ExportDialog--json">
 | 
			
		||||
      <div className="ExportDialog-cards">
 | 
			
		||||
        {exportOpts.saveFileToDisk && (
 | 
			
		||||
          <Card color="lime">
 | 
			
		||||
            <div className="Card-icon">{exportToFileIcon}</div>
 | 
			
		||||
            <h2>{t("exportDialog.disk_title")}</h2>
 | 
			
		||||
            <div className="Card-details">
 | 
			
		||||
              {t("exportDialog.disk_details")}
 | 
			
		||||
              {!nativeFileSystemSupported &&
 | 
			
		||||
                actionManager.renderAction("changeProjectName")}
 | 
			
		||||
            </div>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              className="Card-button"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("exportDialog.disk_button")}
 | 
			
		||||
              aria-label={t("exportDialog.disk_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                actionManager.executeAction(actionSaveFileToDisk);
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
        {onExportToBackend && (
 | 
			
		||||
          <Card color="pink">
 | 
			
		||||
            <div className="Card-icon">{link}</div>
 | 
			
		||||
            <h2>{t("exportDialog.link_title")}</h2>
 | 
			
		||||
            <div className="Card-details">{t("exportDialog.link_details")}</div>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              className="Card-button"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("exportDialog.link_button")}
 | 
			
		||||
              aria-label={t("exportDialog.link_button")}
 | 
			
		||||
              showAriaLabel={true}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                onExportToBackend(elements, appState, files, canvas)
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
        {exportOpts.renderCustomUI &&
 | 
			
		||||
          exportOpts.renderCustomUI(elements, appState, files, canvas)}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const JSONExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  actionManager: ActionsManagerInterface;
 | 
			
		||||
  exportOpts: ExportOpts;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [modalIsShown, setModalIsShown] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleClose = React.useCallback(() => {
 | 
			
		||||
    setModalIsShown(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        data-testid="json-export-button"
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.export")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        title={t("buttons.export")}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
 | 
			
		||||
          <JSONExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            files={files}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
            exportOpts={exportOpts}
 | 
			
		||||
            canvas={canvas}
 | 
			
		||||
          />
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,20 +1,89 @@
 | 
			
		||||
@import "open-color/open-color";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .layer-ui__library {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    .layer-ui__library-header {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      margin: 2px 0;
 | 
			
		||||
 | 
			
		||||
      button {
 | 
			
		||||
        // 2px from the left to account for focus border of left-most button
 | 
			
		||||
        margin: 0 2px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      a {
 | 
			
		||||
        margin-inline-start: auto;
 | 
			
		||||
        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
 | 
			
		||||
        padding-inline-end: 18px;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__library-message {
 | 
			
		||||
    padding: 10px 20px;
 | 
			
		||||
    max-width: 200px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__library-items {
 | 
			
		||||
    max-height: 50vh;
 | 
			
		||||
    overflow: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__wrapper {
 | 
			
		||||
    z-index: var(--zIndex-layerUI);
 | 
			
		||||
 | 
			
		||||
    &__top-right {
 | 
			
		||||
    .encrypted-icon {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      margin-inline-start: 15px;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      border-radius: var(--space-factor);
 | 
			
		||||
      color: $oc-green-9;
 | 
			
		||||
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 1.2rem;
 | 
			
		||||
        height: 1.2rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__github-corner {
 | 
			
		||||
      top: 0;
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] & {
 | 
			
		||||
        right: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="rtl"] & {
 | 
			
		||||
        left: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      width: 40px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__footer {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      z-index: 100;
 | 
			
		||||
      bottom: 0;
 | 
			
		||||
 | 
			
		||||
      &-right {
 | 
			
		||||
        z-index: 100;
 | 
			
		||||
        display: flex;
 | 
			
		||||
      :root[dir="ltr"] & {
 | 
			
		||||
        right: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="rtl"] & {
 | 
			
		||||
        left: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      width: 190px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .zen-mode-transition {
 | 
			
		||||
@@ -36,15 +105,11 @@
 | 
			
		||||
        transform: translate(-999px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
 | 
			
		||||
        transform: translate(-76px, 0);
 | 
			
		||||
      :root[dir="ltr"] &.App-menu_bottom--transition-left {
 | 
			
		||||
        transform: translate(-92px, 0);
 | 
			
		||||
      }
 | 
			
		||||
      :root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
 | 
			
		||||
        transform: translate(76px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.layer-ui__wrapper__footer-left--transition-bottom {
 | 
			
		||||
        transform: translate(0, 92px);
 | 
			
		||||
      :root[dir="rtl"] &.App-menu_bottom--transition-left {
 | 
			
		||||
        transform: translate(92px, 0);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -72,27 +137,5 @@
 | 
			
		||||
        transition-delay: 0.8s;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .layer-ui__wrapper__footer-center {
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
      & > * {
 | 
			
		||||
        pointer-events: all;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    .layer-ui__wrapper__footer-left,
 | 
			
		||||
    .layer-ui__wrapper__footer-right,
 | 
			
		||||
    .disable-zen-mode--visible {
 | 
			
		||||
      pointer-events: all;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .layer-ui__wrapper__footer-left {
 | 
			
		||||
      margin-bottom: 0.2em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .layer-ui__wrapper__footer-right {
 | 
			
		||||
      margin-top: auto;
 | 
			
		||||
      margin-bottom: auto;
 | 
			
		||||
      margin-inline-end: 1em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,53 +1,61 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import React, { useCallback } from "react";
 | 
			
		||||
import React, {
 | 
			
		||||
  RefObject,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { CLASSES } from "../constants";
 | 
			
		||||
import { exportCanvas } from "../data";
 | 
			
		||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
import { isTextElement, showSelectedShapeActions } from "../element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { Language, t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
 | 
			
		||||
import { ExportType } from "../scene/types";
 | 
			
		||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  AppProps,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import CollabButton from "./CollabButton";
 | 
			
		||||
import { ErrorDialog } from "./ErrorDialog";
 | 
			
		||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 | 
			
		||||
import { ExportCB, ExportDialog } from "./ExportDialog";
 | 
			
		||||
import { FixedSideContainer } from "./FixedSideContainer";
 | 
			
		||||
import { GitHubCorner } from "./GitHubCorner";
 | 
			
		||||
import { HintViewer } from "./HintViewer";
 | 
			
		||||
import { exportFile, load, shield, trash } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import "./LayerUI.scss";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import { LoadingMessage } from "./LoadingMessage";
 | 
			
		||||
import { LockButton } from "./LockButton";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { MobileMenu } from "./MobileMenu";
 | 
			
		||||
import { PasteChartDialog } from "./PasteChartDialog";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
import { HelpDialog } from "./HelpDialog";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import { JSONExportDialog } from "./JSONExportDialog";
 | 
			
		||||
import { LibraryButton } from "./LibraryButton";
 | 
			
		||||
import { isImageFileHandle } from "../data/blob";
 | 
			
		||||
import { LibraryMenu } from "./LibraryMenu";
 | 
			
		||||
 | 
			
		||||
import "./LayerUI.scss";
 | 
			
		||||
import "./Toolbar.scss";
 | 
			
		||||
import { PenModeButton } from "./PenModeButton";
 | 
			
		||||
 | 
			
		||||
interface LayerUIProps {
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  onCollabButtonClick?: () => void;
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  onPenModeToggle: () => void;
 | 
			
		||||
  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
 | 
			
		||||
  zenModeEnabled: boolean;
 | 
			
		||||
  showExitZenModeBtn: boolean;
 | 
			
		||||
@@ -55,116 +63,354 @@ interface LayerUIProps {
 | 
			
		||||
  toggleZenMode: () => void;
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  renderTopRightUI?: (
 | 
			
		||||
    isMobile: boolean,
 | 
			
		||||
  onExportToBackend?: (
 | 
			
		||||
    exportedElements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => JSX.Element | null;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
    canvas: HTMLCanvasElement | null,
 | 
			
		||||
  ) => void;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
  viewModeEnabled: boolean;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  UIOptions: AppProps["UIOptions"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
  ref: RefObject<HTMLElement>,
 | 
			
		||||
  cb: (event: MouseEvent) => void,
 | 
			
		||||
) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const listener = (event: MouseEvent) => {
 | 
			
		||||
      if (!ref.current) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        event.target instanceof Element &&
 | 
			
		||||
        (ref.current.contains(event.target) ||
 | 
			
		||||
          !document.body.contains(event.target))
 | 
			
		||||
      ) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      cb(event);
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener("pointerdown", listener, false);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener("pointerdown", listener);
 | 
			
		||||
    };
 | 
			
		||||
  }, [ref, cb]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LibraryMenuItems = ({
 | 
			
		||||
  library,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  setLibraryItems,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
}: {
 | 
			
		||||
  library: LibraryItems;
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: (index: number) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: (elements: LibraryItem) => void;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  setLibraryItems: (library: LibraryItems) => void;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
 | 
			
		||||
  const CELLS_PER_ROW = isMobile ? 4 : 6;
 | 
			
		||||
  const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
 | 
			
		||||
  const rows = [];
 | 
			
		||||
  let addedPendingElements = false;
 | 
			
		||||
 | 
			
		||||
  const referrer =
 | 
			
		||||
    libraryReturnUrl || window.location.origin + window.location.pathname;
 | 
			
		||||
 | 
			
		||||
  rows.push(
 | 
			
		||||
    <div className="layer-ui__library-header" key="library-header">
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        key="import"
 | 
			
		||||
        type="button"
 | 
			
		||||
        title={t("buttons.load")}
 | 
			
		||||
        aria-label={t("buttons.load")}
 | 
			
		||||
        icon={load}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          importLibraryFromJSON()
 | 
			
		||||
            .then(() => {
 | 
			
		||||
              // Close and then open to get the libraries updated
 | 
			
		||||
              setAppState({ isLibraryOpen: false });
 | 
			
		||||
              setAppState({ isLibraryOpen: true });
 | 
			
		||||
            })
 | 
			
		||||
            .catch(muteFSAbortError)
 | 
			
		||||
            .catch((error) => {
 | 
			
		||||
              setAppState({ errorMessage: error.message });
 | 
			
		||||
            });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {!!library.length && (
 | 
			
		||||
        <>
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="export"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.export")}
 | 
			
		||||
            aria-label={t("buttons.export")}
 | 
			
		||||
            icon={exportFile}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              saveLibraryAsJSON()
 | 
			
		||||
                .catch(muteFSAbortError)
 | 
			
		||||
                .catch((error) => {
 | 
			
		||||
                  setAppState({ errorMessage: error.message });
 | 
			
		||||
                });
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="reset"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.resetLibrary")}
 | 
			
		||||
            aria-label={t("buttons.resetLibrary")}
 | 
			
		||||
            icon={trash}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              if (window.confirm(t("alerts.resetLibrary"))) {
 | 
			
		||||
                Library.resetLibrary();
 | 
			
		||||
                setLibraryItems([]);
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
      <a
 | 
			
		||||
        href={`https://libraries.excalidraw.com?target=${
 | 
			
		||||
          window.name || "_blank"
 | 
			
		||||
        }&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
 | 
			
		||||
        target="_excalidraw_libraries"
 | 
			
		||||
      >
 | 
			
		||||
        {t("labels.libraries")}
 | 
			
		||||
      </a>
 | 
			
		||||
    </div>,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  for (let row = 0; row < numRows; row++) {
 | 
			
		||||
    const y = CELLS_PER_ROW * row;
 | 
			
		||||
    const children = [];
 | 
			
		||||
    for (let x = 0; x < CELLS_PER_ROW; x++) {
 | 
			
		||||
      const shouldAddPendingElements: boolean =
 | 
			
		||||
        pendingElements.length > 0 &&
 | 
			
		||||
        !addedPendingElements &&
 | 
			
		||||
        y + x >= library.length;
 | 
			
		||||
      addedPendingElements = addedPendingElements || shouldAddPendingElements;
 | 
			
		||||
 | 
			
		||||
      children.push(
 | 
			
		||||
        <Stack.Col key={x}>
 | 
			
		||||
          <LibraryUnit
 | 
			
		||||
            elements={library[y + x]}
 | 
			
		||||
            pendingElements={
 | 
			
		||||
              shouldAddPendingElements ? pendingElements : undefined
 | 
			
		||||
            }
 | 
			
		||||
            onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
 | 
			
		||||
            onClick={
 | 
			
		||||
              shouldAddPendingElements
 | 
			
		||||
                ? onAddToLibrary.bind(null, pendingElements)
 | 
			
		||||
                : onInsertShape.bind(null, library[y + x])
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </Stack.Col>,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    rows.push(
 | 
			
		||||
      <Stack.Row align="center" gap={1} key={row}>
 | 
			
		||||
        {children}
 | 
			
		||||
      </Stack.Row>,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack.Col align="start" gap={1} className="layer-ui__library-items">
 | 
			
		||||
      {rows}
 | 
			
		||||
    </Stack.Col>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LibraryMenu = ({
 | 
			
		||||
  onClickOutside,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onClickOutside: (event: MouseEvent) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: () => void;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useOnClickOutside(ref, (event) => {
 | 
			
		||||
    // If click on the library icon, do nothing.
 | 
			
		||||
    if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    onClickOutside(event);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
 | 
			
		||||
 | 
			
		||||
  const [loadingState, setIsLoading] = useState<
 | 
			
		||||
    "preloading" | "loading" | "ready"
 | 
			
		||||
  >("preloading");
 | 
			
		||||
 | 
			
		||||
  const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    Promise.race([
 | 
			
		||||
      new Promise((resolve) => {
 | 
			
		||||
        loadingTimerRef.current = setTimeout(() => {
 | 
			
		||||
          resolve("loading");
 | 
			
		||||
        }, 100);
 | 
			
		||||
      }),
 | 
			
		||||
      Library.loadLibrary().then((items) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setIsLoading("ready");
 | 
			
		||||
      }),
 | 
			
		||||
    ]).then((data) => {
 | 
			
		||||
      if (data === "loading") {
 | 
			
		||||
        setIsLoading("loading");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearTimeout(loadingTimerRef.current!);
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const removeFromLibrary = useCallback(async (indexToRemove) => {
 | 
			
		||||
    const items = await Library.loadLibrary();
 | 
			
		||||
    const nextItems = items.filter((_, index) => index !== indexToRemove);
 | 
			
		||||
    Library.saveLibrary(nextItems);
 | 
			
		||||
    setLibraryItems(nextItems);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const addToLibrary = useCallback(
 | 
			
		||||
    async (elements: LibraryItem) => {
 | 
			
		||||
      const items = await Library.loadLibrary();
 | 
			
		||||
      const nextItems = [...items, elements];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      Library.saveLibrary(nextItems);
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return loadingState === "preloading" ? null : (
 | 
			
		||||
    <Island padding={1} ref={ref} className="layer-ui__library">
 | 
			
		||||
      {loadingState === "loading" ? (
 | 
			
		||||
        <div className="layer-ui__library-message">
 | 
			
		||||
          {t("labels.libraryLoadingMessage")}
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LibraryMenuItems
 | 
			
		||||
          library={libraryItems}
 | 
			
		||||
          onRemoveFromLibrary={removeFromLibrary}
 | 
			
		||||
          onAddToLibrary={addToLibrary}
 | 
			
		||||
          onInsertShape={onInsertShape}
 | 
			
		||||
          pendingElements={pendingElements}
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          setLibraryItems={setLibraryItems}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Island>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LayerUI = ({
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  canvas,
 | 
			
		||||
  elements,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
  onPenModeToggle,
 | 
			
		||||
  onInsertElements,
 | 
			
		||||
  zenModeEnabled,
 | 
			
		||||
  showExitZenModeBtn,
 | 
			
		||||
  showThemeBtn,
 | 
			
		||||
  toggleZenMode,
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
  renderTopRightUI,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
  viewModeEnabled,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  UIOptions,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
}: LayerUIProps) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
  const renderJSONExportDialog = () => {
 | 
			
		||||
  const renderEncryptedIcon = () => (
 | 
			
		||||
    <a
 | 
			
		||||
      className={clsx("encrypted-icon tooltip zen-mode-visibility", {
 | 
			
		||||
        "zen-mode-visibility--hidden": zenModeEnabled,
 | 
			
		||||
      })}
 | 
			
		||||
      href="https://blog.excalidraw.com/end-to-end-encryption/"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      aria-label={t("encrypted.link")}
 | 
			
		||||
    >
 | 
			
		||||
      <Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
 | 
			
		||||
        {shield}
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </a>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderExportDialog = () => {
 | 
			
		||||
    if (!UIOptions.canvasActions.export) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <JSONExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        files={files}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        exportOpts={UIOptions.canvasActions.export}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderImageExportDialog = () => {
 | 
			
		||||
    if (!UIOptions.canvasActions.saveAsImage) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const createExporter =
 | 
			
		||||
      (type: ExportType): ExportCB =>
 | 
			
		||||
      async (exportedElements) => {
 | 
			
		||||
        const fileHandle = await exportCanvas(
 | 
			
		||||
          type,
 | 
			
		||||
          exportedElements,
 | 
			
		||||
          appState,
 | 
			
		||||
          files,
 | 
			
		||||
          {
 | 
			
		||||
            exportBackground: appState.exportBackground,
 | 
			
		||||
            name: appState.name,
 | 
			
		||||
            viewBackgroundColor: appState.viewBackgroundColor,
 | 
			
		||||
          },
 | 
			
		||||
        )
 | 
			
		||||
    const createExporter = (type: ExportType): ExportCB => async (
 | 
			
		||||
      exportedElements,
 | 
			
		||||
      scale,
 | 
			
		||||
    ) => {
 | 
			
		||||
      if (canvas) {
 | 
			
		||||
        await exportCanvas(type, exportedElements, appState, canvas, {
 | 
			
		||||
          exportBackground: appState.exportBackground,
 | 
			
		||||
          name: appState.name,
 | 
			
		||||
          viewBackgroundColor: appState.viewBackgroundColor,
 | 
			
		||||
          scale,
 | 
			
		||||
          shouldAddWatermark: appState.shouldAddWatermark,
 | 
			
		||||
        })
 | 
			
		||||
          .catch(muteFSAbortError)
 | 
			
		||||
          .catch((error) => {
 | 
			
		||||
            console.error(error);
 | 
			
		||||
            setAppState({ errorMessage: error.message });
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          appState.exportEmbedScene &&
 | 
			
		||||
          fileHandle &&
 | 
			
		||||
          isImageFileHandle(fileHandle)
 | 
			
		||||
        ) {
 | 
			
		||||
          setAppState({ fileHandle });
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ImageExportDialog
 | 
			
		||||
      <ExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        files={files}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        onExportToPng={createExporter("png")}
 | 
			
		||||
        onExportToSvg={createExporter("svg")}
 | 
			
		||||
        onExportToClipboard={createExporter("clipboard")}
 | 
			
		||||
        onExportToBackend={
 | 
			
		||||
          onExportToBackend
 | 
			
		||||
            ? (elements) => {
 | 
			
		||||
                onExportToBackend &&
 | 
			
		||||
                  onExportToBackend(elements, appState, canvas);
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const Separator = () => {
 | 
			
		||||
    return <div style={{ width: ".625em" }} />;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderViewModeCanvasActions = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Section
 | 
			
		||||
@@ -178,15 +424,15 @@ const LayerUI = ({
 | 
			
		||||
        <Island padding={2} style={{ zIndex: 1 }}>
 | 
			
		||||
          <Stack.Col gap={4}>
 | 
			
		||||
            <Stack.Row gap={1} justifyContent="space-between">
 | 
			
		||||
              {renderJSONExportDialog()}
 | 
			
		||||
              {renderImageExportDialog()}
 | 
			
		||||
              {actionManager.renderAction("saveScene")}
 | 
			
		||||
              {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
              {renderExportDialog()}
 | 
			
		||||
            </Stack.Row>
 | 
			
		||||
          </Stack.Col>
 | 
			
		||||
        </Island>
 | 
			
		||||
      </Section>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderCanvasActions = () => (
 | 
			
		||||
    <Section
 | 
			
		||||
      heading="canvasActions"
 | 
			
		||||
@@ -199,12 +445,11 @@ const LayerUI = ({
 | 
			
		||||
      <Island padding={2} style={{ zIndex: 1 }}>
 | 
			
		||||
        <Stack.Col gap={4}>
 | 
			
		||||
          <Stack.Row gap={1} justifyContent="space-between">
 | 
			
		||||
            {actionManager.renderAction("clearCanvas")}
 | 
			
		||||
            <Separator />
 | 
			
		||||
            {actionManager.renderAction("loadScene")}
 | 
			
		||||
            {renderJSONExportDialog()}
 | 
			
		||||
            {renderImageExportDialog()}
 | 
			
		||||
            <Separator />
 | 
			
		||||
            {actionManager.renderAction("saveScene")}
 | 
			
		||||
            {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
            {renderExportDialog()}
 | 
			
		||||
            {actionManager.renderAction("clearCanvas")}
 | 
			
		||||
            {onCollabButtonClick && (
 | 
			
		||||
              <CollabButton
 | 
			
		||||
                isCollaborating={isCollaborating}
 | 
			
		||||
@@ -219,9 +464,6 @@ const LayerUI = ({
 | 
			
		||||
            setAppState={setAppState}
 | 
			
		||||
            showThemeBtn={showThemeBtn}
 | 
			
		||||
          />
 | 
			
		||||
          {appState.fileHandle && (
 | 
			
		||||
            <>{actionManager.renderAction("saveToActiveFile")}</>
 | 
			
		||||
          )}
 | 
			
		||||
        </Stack.Col>
 | 
			
		||||
      </Island>
 | 
			
		||||
    </Section>
 | 
			
		||||
@@ -240,8 +482,7 @@ const LayerUI = ({
 | 
			
		||||
        style={{
 | 
			
		||||
          // we want to make sure this doesn't overflow so substracting 200
 | 
			
		||||
          // which is approximately height of zoom footer and top left menu items with some buffer
 | 
			
		||||
          // if active file name is displayed, subtracting 248 to account for its height
 | 
			
		||||
          maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
 | 
			
		||||
          maxHeight: `${appState.height - 200}px`,
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <SelectedShapeActions
 | 
			
		||||
@@ -254,15 +495,12 @@ const LayerUI = ({
 | 
			
		||||
    </Section>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const closeLibrary = useCallback(() => {
 | 
			
		||||
    const isDialogOpen = !!document.querySelector(".Dialog");
 | 
			
		||||
 | 
			
		||||
    // Prevent closing if any dialog is open
 | 
			
		||||
    if (isDialogOpen) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    setAppState({ isLibraryOpen: false });
 | 
			
		||||
  }, [setAppState]);
 | 
			
		||||
  const closeLibrary = useCallback(
 | 
			
		||||
    (event) => {
 | 
			
		||||
      setAppState({ isLibraryOpen: false });
 | 
			
		||||
    },
 | 
			
		||||
    [setAppState],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const deselectItems = useCallback(() => {
 | 
			
		||||
    setAppState({
 | 
			
		||||
@@ -273,18 +511,12 @@ const LayerUI = ({
 | 
			
		||||
 | 
			
		||||
  const libraryMenu = appState.isLibraryOpen ? (
 | 
			
		||||
    <LibraryMenu
 | 
			
		||||
      pendingElements={getSelectedElements(elements, appState, true)}
 | 
			
		||||
      onClose={closeLibrary}
 | 
			
		||||
      pendingElements={getSelectedElements(elements, appState)}
 | 
			
		||||
      onClickOutside={closeLibrary}
 | 
			
		||||
      onInsertShape={onInsertElements}
 | 
			
		||||
      onAddToLibrary={deselectItems}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
      libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
      focusContainer={focusContainer}
 | 
			
		||||
      library={library}
 | 
			
		||||
      theme={appState.theme}
 | 
			
		||||
      files={files}
 | 
			
		||||
      id={id}
 | 
			
		||||
      appState={appState}
 | 
			
		||||
    />
 | 
			
		||||
  ) : null;
 | 
			
		||||
 | 
			
		||||
@@ -310,53 +542,27 @@ const LayerUI = ({
 | 
			
		||||
            <Section heading="shapes">
 | 
			
		||||
              {(heading) => (
 | 
			
		||||
                <Stack.Col gap={4} align="start">
 | 
			
		||||
                  <Stack.Row
 | 
			
		||||
                    gap={1}
 | 
			
		||||
                    className={clsx("App-toolbar-container", {
 | 
			
		||||
                      "zen-mode": zenModeEnabled,
 | 
			
		||||
                    })}
 | 
			
		||||
                  >
 | 
			
		||||
                    <PenModeButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.penMode}
 | 
			
		||||
                      onChange={onPenModeToggle}
 | 
			
		||||
                      title={t("toolBar.penMode")}
 | 
			
		||||
                      penDetected={appState.penDetected}
 | 
			
		||||
                    />
 | 
			
		||||
                    <LockButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
                      onChange={onLockToggle}
 | 
			
		||||
                      title={t("toolBar.lock")}
 | 
			
		||||
                    />
 | 
			
		||||
                  <Stack.Row gap={1}>
 | 
			
		||||
                    <Island
 | 
			
		||||
                      padding={1}
 | 
			
		||||
                      className={clsx("App-toolbar", {
 | 
			
		||||
                        "zen-mode": zenModeEnabled,
 | 
			
		||||
                      })}
 | 
			
		||||
                      className={clsx({ "zen-mode": zenModeEnabled })}
 | 
			
		||||
                    >
 | 
			
		||||
                      <HintViewer
 | 
			
		||||
                        appState={appState}
 | 
			
		||||
                        elements={elements}
 | 
			
		||||
                        isMobile={isMobile}
 | 
			
		||||
                      />
 | 
			
		||||
                      <HintViewer appState={appState} elements={elements} />
 | 
			
		||||
                      {heading}
 | 
			
		||||
                      <Stack.Row gap={1}>
 | 
			
		||||
                        <ShapesSwitcher
 | 
			
		||||
                          canvas={canvas}
 | 
			
		||||
                          elementType={appState.elementType}
 | 
			
		||||
                          setAppState={setAppState}
 | 
			
		||||
                          onImageAction={({ pointerType }) => {
 | 
			
		||||
                            onImageAction({
 | 
			
		||||
                              insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                            });
 | 
			
		||||
                          }}
 | 
			
		||||
                          isLibraryOpen={appState.isLibraryOpen}
 | 
			
		||||
                        />
 | 
			
		||||
                      </Stack.Row>
 | 
			
		||||
                    </Island>
 | 
			
		||||
                    <LibraryButton
 | 
			
		||||
                      appState={appState}
 | 
			
		||||
                      setAppState={setAppState}
 | 
			
		||||
                    <LockIcon
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
                      onChange={onLockToggle}
 | 
			
		||||
                      title={t("toolBar.lock")}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Stack.Row>
 | 
			
		||||
                  {libraryMenu}
 | 
			
		||||
@@ -364,32 +570,24 @@ const LayerUI = ({
 | 
			
		||||
              )}
 | 
			
		||||
            </Section>
 | 
			
		||||
          )}
 | 
			
		||||
          <div
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              "layer-ui__wrapper__top-right zen-mode-transition",
 | 
			
		||||
              {
 | 
			
		||||
                "transition-right": zenModeEnabled,
 | 
			
		||||
              },
 | 
			
		||||
            )}
 | 
			
		||||
          <UserList
 | 
			
		||||
            className={clsx("zen-mode-transition", {
 | 
			
		||||
              "transition-right": zenModeEnabled,
 | 
			
		||||
            })}
 | 
			
		||||
          >
 | 
			
		||||
            <UserList>
 | 
			
		||||
              {appState.collaborators.size > 0 &&
 | 
			
		||||
                Array.from(appState.collaborators)
 | 
			
		||||
                  // Collaborator is either not initialized or is actually the current user.
 | 
			
		||||
                  .filter(([_, client]) => Object.keys(client).length !== 0)
 | 
			
		||||
                  .map(([clientId, client]) => (
 | 
			
		||||
                    <Tooltip
 | 
			
		||||
                      label={client.username || "Unknown user"}
 | 
			
		||||
                      key={clientId}
 | 
			
		||||
                    >
 | 
			
		||||
                      {actionManager.renderAction("goToCollaborator", {
 | 
			
		||||
                        id: clientId,
 | 
			
		||||
                      })}
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  ))}
 | 
			
		||||
            </UserList>
 | 
			
		||||
            {renderTopRightUI?.(isMobile, appState)}
 | 
			
		||||
          </div>
 | 
			
		||||
            {appState.collaborators.size > 0 &&
 | 
			
		||||
              Array.from(appState.collaborators)
 | 
			
		||||
                // Collaborator is either not initialized or is actually the current user.
 | 
			
		||||
                .filter(([_, client]) => Object.keys(client).length !== 0)
 | 
			
		||||
                .map(([clientId, client]) => (
 | 
			
		||||
                  <Tooltip
 | 
			
		||||
                    label={client.username || "Unknown user"}
 | 
			
		||||
                    key={clientId}
 | 
			
		||||
                  >
 | 
			
		||||
                    {actionManager.renderAction("goToCollaborator", clientId)}
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                ))}
 | 
			
		||||
          </UserList>
 | 
			
		||||
        </div>
 | 
			
		||||
      </FixedSideContainer>
 | 
			
		||||
    );
 | 
			
		||||
@@ -397,73 +595,61 @@ const LayerUI = ({
 | 
			
		||||
 | 
			
		||||
  const renderBottomAppMenu = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <footer
 | 
			
		||||
        role="contentinfo"
 | 
			
		||||
        className="layer-ui__wrapper__footer App-menu App-menu_bottom"
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("App-menu App-menu_bottom zen-mode-transition", {
 | 
			
		||||
          "App-menu_bottom--transition-left": zenModeEnabled,
 | 
			
		||||
        })}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-left zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          <Stack.Col gap={2}>
 | 
			
		||||
            <Section heading="canvasActions">
 | 
			
		||||
              <Island padding={1}>
 | 
			
		||||
                <ZoomActions
 | 
			
		||||
                  renderAction={actionManager.renderAction}
 | 
			
		||||
                  zoom={appState.zoom}
 | 
			
		||||
                />
 | 
			
		||||
              </Island>
 | 
			
		||||
              {!viewModeEnabled && (
 | 
			
		||||
                <div
 | 
			
		||||
                  className={clsx("undo-redo-buttons zen-mode-transition", {
 | 
			
		||||
                    "layer-ui__wrapper__footer-left--transition-bottom":
 | 
			
		||||
                      zenModeEnabled,
 | 
			
		||||
                  })}
 | 
			
		||||
                >
 | 
			
		||||
                  {actionManager.renderAction("undo", { size: "small" })}
 | 
			
		||||
                  {actionManager.renderAction("redo", { size: "small" })}
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
            </Section>
 | 
			
		||||
          </Stack.Col>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-center zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "layer-ui__wrapper__footer-left--transition-bottom":
 | 
			
		||||
                zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {renderCustomFooter?.(false, appState)}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            "layer-ui__wrapper__footer-right zen-mode-transition",
 | 
			
		||||
            {
 | 
			
		||||
              "transition-right disable-pointerEvents": zenModeEnabled,
 | 
			
		||||
            },
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {actionManager.renderAction("toggleShortcuts")}
 | 
			
		||||
        </div>
 | 
			
		||||
        <button
 | 
			
		||||
          className={clsx("disable-zen-mode", {
 | 
			
		||||
            "disable-zen-mode--visible": showExitZenModeBtn,
 | 
			
		||||
          })}
 | 
			
		||||
          onClick={toggleZenMode}
 | 
			
		||||
        >
 | 
			
		||||
          {t("buttons.exitZenMode")}
 | 
			
		||||
        </button>
 | 
			
		||||
      </footer>
 | 
			
		||||
        <Stack.Col gap={2}>
 | 
			
		||||
          <Section heading="canvasActions">
 | 
			
		||||
            <Island padding={1}>
 | 
			
		||||
              <ZoomActions
 | 
			
		||||
                renderAction={actionManager.renderAction}
 | 
			
		||||
                zoom={appState.zoom}
 | 
			
		||||
              />
 | 
			
		||||
            </Island>
 | 
			
		||||
            {renderEncryptedIcon()}
 | 
			
		||||
          </Section>
 | 
			
		||||
        </Stack.Col>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderGitHubCorner = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <aside
 | 
			
		||||
        className={clsx(
 | 
			
		||||
          "layer-ui__wrapper__github-corner zen-mode-transition",
 | 
			
		||||
          {
 | 
			
		||||
            "transition-right": zenModeEnabled,
 | 
			
		||||
          },
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        <GitHubCorner theme={appState.theme} />
 | 
			
		||||
      </aside>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
  const renderFooter = () => (
 | 
			
		||||
    <footer role="contentinfo" className="layer-ui__wrapper__footer">
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("zen-mode-transition", {
 | 
			
		||||
          "transition-right disable-pointerEvents": zenModeEnabled,
 | 
			
		||||
        })}
 | 
			
		||||
      >
 | 
			
		||||
        {renderCustomFooter?.(false)}
 | 
			
		||||
        {actionManager.renderAction("toggleShortcuts")}
 | 
			
		||||
      </div>
 | 
			
		||||
      <button
 | 
			
		||||
        className={clsx("disable-zen-mode", {
 | 
			
		||||
          "disable-zen-mode--visible": showExitZenModeBtn,
 | 
			
		||||
        })}
 | 
			
		||||
        onClick={toggleZenMode}
 | 
			
		||||
      >
 | 
			
		||||
        {t("buttons.exitZenMode")}
 | 
			
		||||
      </button>
 | 
			
		||||
    </footer>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const dialogs = (
 | 
			
		||||
    <>
 | 
			
		||||
      {appState.isLoading && <LoadingMessage />}
 | 
			
		||||
@@ -474,11 +660,7 @@ const LayerUI = ({
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.showHelpDialog && (
 | 
			
		||||
        <HelpDialog
 | 
			
		||||
          onClose={() => {
 | 
			
		||||
            setAppState({ showHelpDialog: false });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.pasteDialog.shown && (
 | 
			
		||||
        <PasteChartDialog
 | 
			
		||||
@@ -503,19 +685,15 @@ const LayerUI = ({
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        libraryMenu={libraryMenu}
 | 
			
		||||
        renderJSONExportDialog={renderJSONExportDialog}
 | 
			
		||||
        renderImageExportDialog={renderImageExportDialog}
 | 
			
		||||
        exportButton={renderExportDialog()}
 | 
			
		||||
        setAppState={setAppState}
 | 
			
		||||
        onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
        onLockToggle={onLockToggle}
 | 
			
		||||
        onPenModeToggle={onPenModeToggle}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
        isCollaborating={isCollaborating}
 | 
			
		||||
        renderCustomFooter={renderCustomFooter}
 | 
			
		||||
        viewModeEnabled={viewModeEnabled}
 | 
			
		||||
        showThemeBtn={showThemeBtn}
 | 
			
		||||
        onImageAction={onImageAction}
 | 
			
		||||
        renderTopRightUI={renderTopRightUI}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ) : (
 | 
			
		||||
@@ -530,6 +708,8 @@ const LayerUI = ({
 | 
			
		||||
      {dialogs}
 | 
			
		||||
      {renderFixedSideContainer()}
 | 
			
		||||
      {renderBottomAppMenu()}
 | 
			
		||||
      {renderGitHubCorner()}
 | 
			
		||||
      {renderFooter()}
 | 
			
		||||
      {appState.scrolledOutside && (
 | 
			
		||||
        <button
 | 
			
		||||
          className="scroll-back-to-content"
 | 
			
		||||
@@ -563,7 +743,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
 | 
			
		||||
    prev.renderCustomFooter === next.renderCustomFooter &&
 | 
			
		||||
    prev.langCode === next.langCode &&
 | 
			
		||||
    prev.elements === next.elements &&
 | 
			
		||||
    prev.files === next.files &&
 | 
			
		||||
    keys.every((key) => prevAppState[key] === nextAppState[key])
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,46 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { capitalizeString } from "../utils";
 | 
			
		||||
 | 
			
		||||
const LIBRARY_ICON = (
 | 
			
		||||
  <svg viewBox="0 0 576 512">
 | 
			
		||||
    <path
 | 
			
		||||
      fill="currentColor"
 | 
			
		||||
      d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
 | 
			
		||||
    ></path>
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const LibraryButton: React.FC<{
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  isMobile?: boolean;
 | 
			
		||||
}> = ({ appState, setAppState, isMobile }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon_type_floating ToolIcon__library",
 | 
			
		||||
        `ToolIcon_size_medium`,
 | 
			
		||||
        {
 | 
			
		||||
          "is-mobile": isMobile,
 | 
			
		||||
        },
 | 
			
		||||
      )}
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 0`}
 | 
			
		||||
    >
 | 
			
		||||
      <input
 | 
			
		||||
        className="ToolIcon_type_checkbox"
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        name="editor-library"
 | 
			
		||||
        onChange={(event) => {
 | 
			
		||||
          setAppState({ isLibraryOpen: event.target.checked });
 | 
			
		||||
        }}
 | 
			
		||||
        checked={appState.isLibraryOpen}
 | 
			
		||||
        aria-label={capitalizeString(t("toolBar.library"))}
 | 
			
		||||
        aria-keyshortcuts="0"
 | 
			
		||||
      />
 | 
			
		||||
      <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
 | 
			
		||||
    </label>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,55 +0,0 @@
 | 
			
		||||
@import "open-color/open-color";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .layer-ui__library {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
 | 
			
		||||
    .layer-ui__library-header {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      margin: 2px 0;
 | 
			
		||||
 | 
			
		||||
      button {
 | 
			
		||||
        // 2px from the left to account for focus border of left-most button
 | 
			
		||||
        margin: 0 2px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      a {
 | 
			
		||||
        margin-inline-start: auto;
 | 
			
		||||
        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
 | 
			
		||||
        padding-inline-end: 18px;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__library-message {
 | 
			
		||||
    padding: 10px 20px;
 | 
			
		||||
    max-width: 200px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .publish-library-success {
 | 
			
		||||
    .Dialog__content {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-close.ToolIcon_type_button {
 | 
			
		||||
      background-color: $oc-blue-6;
 | 
			
		||||
      align-self: flex-end;
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: $oc-blue-8;
 | 
			
		||||
      }
 | 
			
		||||
      .ToolIcon__icon {
 | 
			
		||||
        width: auto;
 | 
			
		||||
        font-size: 1rem;
 | 
			
		||||
        color: $oc-white;
 | 
			
		||||
        padding: 0 0.5rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,326 +0,0 @@
 | 
			
		||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import {
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import PublishLibrary from "./PublishLibrary";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
import "./LibraryMenu.scss";
 | 
			
		||||
import LibraryMenuItems from "./LibraryMenuItems";
 | 
			
		||||
import { EVENT } from "../constants";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
  ref: RefObject<HTMLElement>,
 | 
			
		||||
  cb: (event: MouseEvent) => void,
 | 
			
		||||
) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const listener = (event: MouseEvent) => {
 | 
			
		||||
      if (!ref.current) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        event.target instanceof Element &&
 | 
			
		||||
        (ref.current.contains(event.target) ||
 | 
			
		||||
          !document.body.contains(event.target))
 | 
			
		||||
      ) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      cb(event);
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener("pointerdown", listener, false);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener("pointerdown", listener);
 | 
			
		||||
    };
 | 
			
		||||
  }, [ref, cb]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getSelectedItems = (
 | 
			
		||||
  libraryItems: LibraryItems,
 | 
			
		||||
  selectedItems: LibraryItem["id"][],
 | 
			
		||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
 | 
			
		||||
 | 
			
		||||
export const LibraryMenu = ({
 | 
			
		||||
  onClose,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  files,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  appState,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem["elements"];
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
  onAddToLibrary: () => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
 | 
			
		||||
  useOnClickOutside(ref, (event) => {
 | 
			
		||||
    // If click on the library icon, do nothing.
 | 
			
		||||
    if ((event.target as Element).closest(".ToolIcon__library")) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    onClose();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleKeyDown = (event: KeyboardEvent) => {
 | 
			
		||||
      if (event.key === KEYS.ESCAPE) {
 | 
			
		||||
        onClose();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
 | 
			
		||||
    };
 | 
			
		||||
  }, [onClose]);
 | 
			
		||||
 | 
			
		||||
  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
 | 
			
		||||
 | 
			
		||||
  const [loadingState, setIsLoading] = useState<
 | 
			
		||||
    "preloading" | "loading" | "ready"
 | 
			
		||||
  >("preloading");
 | 
			
		||||
  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
 | 
			
		||||
  const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
 | 
			
		||||
    useState(false);
 | 
			
		||||
  const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
 | 
			
		||||
    url: string;
 | 
			
		||||
    authorName: string;
 | 
			
		||||
  }>(null);
 | 
			
		||||
  const loadingTimerRef = useRef<number | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    Promise.race([
 | 
			
		||||
      new Promise((resolve) => {
 | 
			
		||||
        loadingTimerRef.current = window.setTimeout(() => {
 | 
			
		||||
          resolve("loading");
 | 
			
		||||
        }, 100);
 | 
			
		||||
      }),
 | 
			
		||||
      library.loadLibrary().then((items) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setIsLoading("ready");
 | 
			
		||||
      }),
 | 
			
		||||
    ]).then((data) => {
 | 
			
		||||
      if (data === "loading") {
 | 
			
		||||
        setIsLoading("loading");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearTimeout(loadingTimerRef.current!);
 | 
			
		||||
    };
 | 
			
		||||
  }, [library]);
 | 
			
		||||
 | 
			
		||||
  const removeFromLibrary = useCallback(async () => {
 | 
			
		||||
    const items = await library.loadLibrary();
 | 
			
		||||
 | 
			
		||||
    const nextItems = items.filter((item) => !selectedItems.includes(item.id));
 | 
			
		||||
    library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
      setLibraryItems(items);
 | 
			
		||||
      setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
 | 
			
		||||
    });
 | 
			
		||||
    setSelectedItems([]);
 | 
			
		||||
    setLibraryItems(nextItems);
 | 
			
		||||
  }, [library, setAppState, selectedItems, setSelectedItems]);
 | 
			
		||||
 | 
			
		||||
  const resetLibrary = useCallback(() => {
 | 
			
		||||
    library.resetLibrary();
 | 
			
		||||
    setLibraryItems([]);
 | 
			
		||||
    focusContainer();
 | 
			
		||||
  }, [library, focusContainer]);
 | 
			
		||||
 | 
			
		||||
  const addToLibrary = useCallback(
 | 
			
		||||
    async (elements: LibraryItem["elements"]) => {
 | 
			
		||||
      if (elements.some((element) => element.type === "image")) {
 | 
			
		||||
        return setAppState({
 | 
			
		||||
          errorMessage: "Support for adding images to the library coming soon!",
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      const items = await library.loadLibrary();
 | 
			
		||||
      const nextItems: LibraryItems = [
 | 
			
		||||
        {
 | 
			
		||||
          status: "unpublished",
 | 
			
		||||
          elements,
 | 
			
		||||
          id: randomId(),
 | 
			
		||||
          created: Date.now(),
 | 
			
		||||
        },
 | 
			
		||||
        ...items,
 | 
			
		||||
      ];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary, library, setAppState],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderPublishSuccess = useCallback(() => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Dialog
 | 
			
		||||
        onCloseRequest={() => setPublishLibSuccess(null)}
 | 
			
		||||
        title={t("publishSuccessDialog.title")}
 | 
			
		||||
        className="publish-library-success"
 | 
			
		||||
        small={true}
 | 
			
		||||
      >
 | 
			
		||||
        <p>
 | 
			
		||||
          {t("publishSuccessDialog.content", {
 | 
			
		||||
            authorName: publishLibSuccess!.authorName,
 | 
			
		||||
          })}{" "}
 | 
			
		||||
          <a
 | 
			
		||||
            href={publishLibSuccess?.url}
 | 
			
		||||
            target="_blank"
 | 
			
		||||
            rel="noopener noreferrer"
 | 
			
		||||
          >
 | 
			
		||||
            {t("publishSuccessDialog.link")}
 | 
			
		||||
          </a>
 | 
			
		||||
        </p>
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          type="button"
 | 
			
		||||
          title={t("buttons.close")}
 | 
			
		||||
          aria-label={t("buttons.close")}
 | 
			
		||||
          label={t("buttons.close")}
 | 
			
		||||
          onClick={() => setPublishLibSuccess(null)}
 | 
			
		||||
          data-testid="publish-library-success-close"
 | 
			
		||||
          className="publish-library-success-close"
 | 
			
		||||
        />
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    );
 | 
			
		||||
  }, [setPublishLibSuccess, publishLibSuccess]);
 | 
			
		||||
 | 
			
		||||
  const onPublishLibSuccess = useCallback(
 | 
			
		||||
    (data) => {
 | 
			
		||||
      setShowPublishLibraryDialog(false);
 | 
			
		||||
      setPublishLibSuccess({ url: data.url, authorName: data.authorName });
 | 
			
		||||
      const nextLibItems = libraryItems.slice();
 | 
			
		||||
      nextLibItems.forEach((libItem) => {
 | 
			
		||||
        if (selectedItems.includes(libItem.id)) {
 | 
			
		||||
          libItem.status = "published";
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      library.saveLibrary(nextLibItems);
 | 
			
		||||
      setLibraryItems(nextLibItems);
 | 
			
		||||
    },
 | 
			
		||||
    [
 | 
			
		||||
      setShowPublishLibraryDialog,
 | 
			
		||||
      setPublishLibSuccess,
 | 
			
		||||
      libraryItems,
 | 
			
		||||
      selectedItems,
 | 
			
		||||
      library,
 | 
			
		||||
    ],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [lastSelectedItem, setLastSelectedItem] = useState<
 | 
			
		||||
    LibraryItem["id"] | null
 | 
			
		||||
  >(null);
 | 
			
		||||
 | 
			
		||||
  return loadingState === "preloading" ? null : (
 | 
			
		||||
    <Island padding={1} ref={ref} className="layer-ui__library">
 | 
			
		||||
      {showPublishLibraryDialog && (
 | 
			
		||||
        <PublishLibrary
 | 
			
		||||
          onClose={() => setShowPublishLibraryDialog(false)}
 | 
			
		||||
          libraryItems={getSelectedItems(libraryItems, selectedItems)}
 | 
			
		||||
          appState={appState}
 | 
			
		||||
          onSuccess={onPublishLibSuccess}
 | 
			
		||||
          onError={(error) => window.alert(error)}
 | 
			
		||||
          updateItemsInStorage={() => library.saveLibrary(libraryItems)}
 | 
			
		||||
          onRemove={(id: string) =>
 | 
			
		||||
            setSelectedItems(selectedItems.filter((_id) => _id !== id))
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {publishLibSuccess && renderPublishSuccess()}
 | 
			
		||||
 | 
			
		||||
      {loadingState === "loading" ? (
 | 
			
		||||
        <div className="layer-ui__library-message">
 | 
			
		||||
          {t("labels.libraryLoadingMessage")}
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LibraryMenuItems
 | 
			
		||||
          libraryItems={libraryItems}
 | 
			
		||||
          onRemoveFromLibrary={removeFromLibrary}
 | 
			
		||||
          onAddToLibrary={addToLibrary}
 | 
			
		||||
          onInsertShape={onInsertShape}
 | 
			
		||||
          pendingElements={pendingElements}
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
          library={library}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
          files={files}
 | 
			
		||||
          id={id}
 | 
			
		||||
          selectedItems={selectedItems}
 | 
			
		||||
          onToggle={(id, event) => {
 | 
			
		||||
            const shouldSelect = !selectedItems.includes(id);
 | 
			
		||||
 | 
			
		||||
            if (shouldSelect) {
 | 
			
		||||
              if (event.shiftKey && lastSelectedItem) {
 | 
			
		||||
                const rangeStart = libraryItems.findIndex(
 | 
			
		||||
                  (item) => item.id === lastSelectedItem,
 | 
			
		||||
                );
 | 
			
		||||
                const rangeEnd = libraryItems.findIndex(
 | 
			
		||||
                  (item) => item.id === id,
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                if (rangeStart === -1 || rangeEnd === -1) {
 | 
			
		||||
                  setSelectedItems([...selectedItems, id]);
 | 
			
		||||
                  return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const selectedItemsMap = arrayToMap(selectedItems);
 | 
			
		||||
                const nextSelectedIds = libraryItems.reduce(
 | 
			
		||||
                  (acc: LibraryItem["id"][], item, idx) => {
 | 
			
		||||
                    if (
 | 
			
		||||
                      (idx >= rangeStart && idx <= rangeEnd) ||
 | 
			
		||||
                      selectedItemsMap.has(item.id)
 | 
			
		||||
                    ) {
 | 
			
		||||
                      acc.push(item.id);
 | 
			
		||||
                    }
 | 
			
		||||
                    return acc;
 | 
			
		||||
                  },
 | 
			
		||||
                  [],
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                setSelectedItems(nextSelectedIds);
 | 
			
		||||
              } else {
 | 
			
		||||
                setSelectedItems([...selectedItems, id]);
 | 
			
		||||
              }
 | 
			
		||||
              setLastSelectedItem(id);
 | 
			
		||||
            } else {
 | 
			
		||||
              setLastSelectedItem(null);
 | 
			
		||||
              setSelectedItems(selectedItems.filter((_id) => _id !== id));
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          onPublish={() => setShowPublishLibraryDialog(true)}
 | 
			
		||||
          resetLibrary={resetLibrary}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Island>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,102 +0,0 @@
 | 
			
		||||
@import "open-color/open-color";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .library-menu-items-container {
 | 
			
		||||
    .library-actions {
 | 
			
		||||
      display: flex;
 | 
			
		||||
 | 
			
		||||
      button .library-actions-counter {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        right: 2px;
 | 
			
		||||
        bottom: 2px;
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
        width: 1em;
 | 
			
		||||
        height: 1em;
 | 
			
		||||
        padding: 1px;
 | 
			
		||||
        font-size: 0.7rem;
 | 
			
		||||
        background: #fff;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--remove {
 | 
			
		||||
        background-color: $oc-red-7;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-red-8;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-red-9;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
        .library-actions-counter {
 | 
			
		||||
          color: $oc-red-7;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--export {
 | 
			
		||||
        background-color: $oc-lime-5;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-lime-7;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-lime-8;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
        .library-actions-counter {
 | 
			
		||||
          color: $oc-lime-5;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--publish {
 | 
			
		||||
        background-color: $oc-cyan-6;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-cyan-7;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-cyan-9;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
        label {
 | 
			
		||||
          margin-left: -0.2em;
 | 
			
		||||
          margin-right: 1.1em;
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
          font-size: 0.86em;
 | 
			
		||||
        }
 | 
			
		||||
        .library-actions-counter {
 | 
			
		||||
          color: $oc-cyan-6;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &--load {
 | 
			
		||||
        background-color: $oc-blue-6;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $oc-blue-7;
 | 
			
		||||
        }
 | 
			
		||||
        &:active {
 | 
			
		||||
          background-color: $oc-blue-9;
 | 
			
		||||
        }
 | 
			
		||||
        svg {
 | 
			
		||||
          color: $oc-white;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    &__items {
 | 
			
		||||
      max-height: 50vh;
 | 
			
		||||
      overflow: auto;
 | 
			
		||||
      margin-top: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .separator {
 | 
			
		||||
      font-weight: 500;
 | 
			
		||||
      font-size: 0.9rem;
 | 
			
		||||
      margin: 0.6em 0.2em;
 | 
			
		||||
      color: var(--text-primary-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,323 +0,0 @@
 | 
			
		||||
import { chunk } from "lodash";
 | 
			
		||||
import { useCallback, useState } from "react";
 | 
			
		||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import ConfirmDialog from "./ConfirmDialog";
 | 
			
		||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
 | 
			
		||||
import "./LibraryMenuItems.scss";
 | 
			
		||||
import { VERSIONS } from "../constants";
 | 
			
		||||
 | 
			
		||||
const LibraryMenuItems = ({
 | 
			
		||||
  libraryItems,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  library,
 | 
			
		||||
  files,
 | 
			
		||||
  id,
 | 
			
		||||
  selectedItems,
 | 
			
		||||
  onToggle,
 | 
			
		||||
  onPublish,
 | 
			
		||||
  resetLibrary,
 | 
			
		||||
}: {
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
  pendingElements: LibraryItem["elements"];
 | 
			
		||||
  onRemoveFromLibrary: () => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
  onAddToLibrary: (elements: LibraryItem["elements"]) => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  selectedItems: LibraryItem["id"][];
 | 
			
		||||
  onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
 | 
			
		||||
  onPublish: () => void;
 | 
			
		||||
  resetLibrary: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const renderRemoveLibAlert = useCallback(() => {
 | 
			
		||||
    const content = selectedItems.length
 | 
			
		||||
      ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
 | 
			
		||||
      : t("alerts.resetLibrary");
 | 
			
		||||
    const title = selectedItems.length
 | 
			
		||||
      ? t("confirmDialog.removeItemsFromLib")
 | 
			
		||||
      : t("confirmDialog.resetLibrary");
 | 
			
		||||
    return (
 | 
			
		||||
      <ConfirmDialog
 | 
			
		||||
        onConfirm={() => {
 | 
			
		||||
          if (selectedItems.length) {
 | 
			
		||||
            onRemoveFromLibrary();
 | 
			
		||||
          } else {
 | 
			
		||||
            resetLibrary();
 | 
			
		||||
          }
 | 
			
		||||
          setShowRemoveLibAlert(false);
 | 
			
		||||
        }}
 | 
			
		||||
        onCancel={() => {
 | 
			
		||||
          setShowRemoveLibAlert(false);
 | 
			
		||||
        }}
 | 
			
		||||
        title={title}
 | 
			
		||||
      >
 | 
			
		||||
        <p>{content}</p>
 | 
			
		||||
      </ConfirmDialog>
 | 
			
		||||
    );
 | 
			
		||||
  }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
 | 
			
		||||
 | 
			
		||||
  const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
  const renderLibraryActions = () => {
 | 
			
		||||
    const itemsSelected = !!selectedItems.length;
 | 
			
		||||
    const items = itemsSelected
 | 
			
		||||
      ? libraryItems.filter((item) => selectedItems.includes(item.id))
 | 
			
		||||
      : libraryItems;
 | 
			
		||||
    const resetLabel = itemsSelected
 | 
			
		||||
      ? t("buttons.remove")
 | 
			
		||||
      : t("buttons.resetLibrary");
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="library-actions">
 | 
			
		||||
        {(!itemsSelected || !isMobile) && (
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="import"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.load")}
 | 
			
		||||
            aria-label={t("buttons.load")}
 | 
			
		||||
            icon={load}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              importLibraryFromJSON(library)
 | 
			
		||||
                .then(() => {
 | 
			
		||||
                  // Close and then open to get the libraries updated
 | 
			
		||||
                  setAppState({ isLibraryOpen: false });
 | 
			
		||||
                  setAppState({ isLibraryOpen: true });
 | 
			
		||||
                })
 | 
			
		||||
                .catch(muteFSAbortError)
 | 
			
		||||
                .catch((error) => {
 | 
			
		||||
                  setAppState({ errorMessage: error.message });
 | 
			
		||||
                });
 | 
			
		||||
            }}
 | 
			
		||||
            className="library-actions--load"
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {!!items.length && (
 | 
			
		||||
          <>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              key="export"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={t("buttons.export")}
 | 
			
		||||
              aria-label={t("buttons.export")}
 | 
			
		||||
              icon={exportToFileIcon}
 | 
			
		||||
              onClick={async () => {
 | 
			
		||||
                const libraryItems = itemsSelected
 | 
			
		||||
                  ? items
 | 
			
		||||
                  : await library.loadLibrary();
 | 
			
		||||
                saveLibraryAsJSON(libraryItems)
 | 
			
		||||
                  .catch(muteFSAbortError)
 | 
			
		||||
                  .catch((error) => {
 | 
			
		||||
                    setAppState({ errorMessage: error.message });
 | 
			
		||||
                  });
 | 
			
		||||
              }}
 | 
			
		||||
              className="library-actions--export"
 | 
			
		||||
            >
 | 
			
		||||
              {selectedItems.length > 0 && (
 | 
			
		||||
                <span className="library-actions-counter">
 | 
			
		||||
                  {selectedItems.length}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </ToolButton>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              key="reset"
 | 
			
		||||
              type="button"
 | 
			
		||||
              title={resetLabel}
 | 
			
		||||
              aria-label={resetLabel}
 | 
			
		||||
              icon={trash}
 | 
			
		||||
              onClick={() => setShowRemoveLibAlert(true)}
 | 
			
		||||
              className="library-actions--remove"
 | 
			
		||||
            >
 | 
			
		||||
              {selectedItems.length > 0 && (
 | 
			
		||||
                <span className="library-actions-counter">
 | 
			
		||||
                  {selectedItems.length}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </ToolButton>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        {itemsSelected && !isPublished && (
 | 
			
		||||
          <Tooltip label={t("hints.publishLibrary")}>
 | 
			
		||||
            <ToolButton
 | 
			
		||||
              type="button"
 | 
			
		||||
              aria-label={t("buttons.publishLibrary")}
 | 
			
		||||
              label={t("buttons.publishLibrary")}
 | 
			
		||||
              icon={publishIcon}
 | 
			
		||||
              className="library-actions--publish"
 | 
			
		||||
              onClick={onPublish}
 | 
			
		||||
            >
 | 
			
		||||
              {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
 | 
			
		||||
              {selectedItems.length > 0 && (
 | 
			
		||||
                <span className="library-actions-counter">
 | 
			
		||||
                  {selectedItems.length}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </ToolButton>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const CELLS_PER_ROW = isMobile ? 4 : 6;
 | 
			
		||||
 | 
			
		||||
  const referrer =
 | 
			
		||||
    libraryReturnUrl || window.location.origin + window.location.pathname;
 | 
			
		||||
  const isPublished = selectedItems.some(
 | 
			
		||||
    (id) => libraryItems.find((item) => item.id === id)?.status === "published",
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const createLibraryItemCompo = (params: {
 | 
			
		||||
    item:
 | 
			
		||||
      | LibraryItem
 | 
			
		||||
      | /* pending library item */ {
 | 
			
		||||
          id: null;
 | 
			
		||||
          elements: readonly NonDeleted<ExcalidrawElement>[];
 | 
			
		||||
        }
 | 
			
		||||
      | null;
 | 
			
		||||
    onClick?: () => void;
 | 
			
		||||
    key: string;
 | 
			
		||||
  }) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Stack.Col key={params.key}>
 | 
			
		||||
        <LibraryUnit
 | 
			
		||||
          elements={params.item?.elements}
 | 
			
		||||
          files={files}
 | 
			
		||||
          isPending={!params.item?.id && !!params.item?.elements}
 | 
			
		||||
          onClick={params.onClick || (() => {})}
 | 
			
		||||
          id={params.item?.id || null}
 | 
			
		||||
          selected={!!params.item?.id && selectedItems.includes(params.item.id)}
 | 
			
		||||
          onToggle={(id, event) => {
 | 
			
		||||
            onToggle(id, event);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderLibrarySection = (
 | 
			
		||||
    items: (
 | 
			
		||||
      | LibraryItem
 | 
			
		||||
      | /* pending library item */ {
 | 
			
		||||
          id: null;
 | 
			
		||||
          elements: readonly NonDeleted<ExcalidrawElement>[];
 | 
			
		||||
        }
 | 
			
		||||
    )[],
 | 
			
		||||
  ) => {
 | 
			
		||||
    const _items = items.map((item) => {
 | 
			
		||||
      if (item.id) {
 | 
			
		||||
        return createLibraryItemCompo({
 | 
			
		||||
          item,
 | 
			
		||||
          onClick: () => onInsertShape(item.elements),
 | 
			
		||||
          key: item.id,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      return createLibraryItemCompo({
 | 
			
		||||
        key: "__pending__item__",
 | 
			
		||||
        item,
 | 
			
		||||
        onClick: () => onAddToLibrary(pendingElements),
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // ensure we render all empty cells if no items are present
 | 
			
		||||
    let rows = chunk(_items, CELLS_PER_ROW);
 | 
			
		||||
    if (!rows.length) {
 | 
			
		||||
      rows = [[]];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return rows.map((rowItems, index, rows) => {
 | 
			
		||||
      if (index === rows.length - 1) {
 | 
			
		||||
        // pad row with empty cells
 | 
			
		||||
        rowItems = rowItems.concat(
 | 
			
		||||
          new Array(CELLS_PER_ROW - rowItems.length)
 | 
			
		||||
            .fill(null)
 | 
			
		||||
            .map((_, index) => {
 | 
			
		||||
              return createLibraryItemCompo({
 | 
			
		||||
                key: `empty_${index}`,
 | 
			
		||||
                item: null,
 | 
			
		||||
              });
 | 
			
		||||
            }),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      return (
 | 
			
		||||
        <Stack.Row align="center" gap={1} key={index}>
 | 
			
		||||
          {rowItems}
 | 
			
		||||
        </Stack.Row>
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const publishedItems = libraryItems.filter(
 | 
			
		||||
    (item) => item.status === "published",
 | 
			
		||||
  );
 | 
			
		||||
  const unpublishedItems = [
 | 
			
		||||
    // append pending library item
 | 
			
		||||
    ...(pendingElements.length
 | 
			
		||||
      ? [{ id: null, elements: pendingElements }]
 | 
			
		||||
      : []),
 | 
			
		||||
    ...libraryItems.filter((item) => item.status !== "published"),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="library-menu-items-container">
 | 
			
		||||
      {showRemoveLibAlert && renderRemoveLibAlert()}
 | 
			
		||||
      <div className="layer-ui__library-header" key="library-header">
 | 
			
		||||
        {renderLibraryActions()}
 | 
			
		||||
        <a
 | 
			
		||||
          href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
 | 
			
		||||
            window.name || "_blank"
 | 
			
		||||
          }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
 | 
			
		||||
            VERSIONS.excalidrawLibrary
 | 
			
		||||
          }`}
 | 
			
		||||
          target="_excalidraw_libraries"
 | 
			
		||||
        >
 | 
			
		||||
          {t("labels.libraries")}
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Stack.Col
 | 
			
		||||
        className="library-menu-items-container__items"
 | 
			
		||||
        align="start"
 | 
			
		||||
        gap={1}
 | 
			
		||||
      >
 | 
			
		||||
        <>
 | 
			
		||||
          <div className="separator">{t("labels.personalLib")}</div>
 | 
			
		||||
          {renderLibrarySection(unpublishedItems)}
 | 
			
		||||
        </>
 | 
			
		||||
 | 
			
		||||
        <>
 | 
			
		||||
          <div className="separator">{t("labels.excalidrawLib")} </div>
 | 
			
		||||
 | 
			
		||||
          {renderLibrarySection(publishedItems)}
 | 
			
		||||
        </>
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default LibraryMenuItems;
 | 
			
		||||
@@ -1,5 +1,3 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .library-unit {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
@@ -9,26 +7,10 @@
 | 
			
		||||
    position: relative;
 | 
			
		||||
    width: 63px;
 | 
			
		||||
    height: 63px; // match width
 | 
			
		||||
 | 
			
		||||
    &--hover {
 | 
			
		||||
      box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
 | 
			
		||||
      border-color: $oc-blue-5;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--selected {
 | 
			
		||||
      box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
 | 
			
		||||
      border-color: $oc-blue-8;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.theme--dark .library-unit {
 | 
			
		||||
    border-color: rgb(48, 48, 48);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__dragger {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
@@ -40,9 +22,9 @@
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__checkbox-container,
 | 
			
		||||
  .library-unit__checkbox-container:hover,
 | 
			
		||||
  .library-unit__checkbox-container:active {
 | 
			
		||||
  .library-unit__removeFromLibrary,
 | 
			
		||||
  .library-unit__removeFromLibrary:hover,
 | 
			
		||||
  .library-unit__removeFromLibrary:active {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    background: none;
 | 
			
		||||
    border: none;
 | 
			
		||||
@@ -50,35 +32,10 @@
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 0.5rem;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 2rem;
 | 
			
		||||
    bottom: 2rem;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    input {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__checkbox {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 2.3rem;
 | 
			
		||||
    bottom: 2.3rem;
 | 
			
		||||
 | 
			
		||||
    .Checkbox-box {
 | 
			
		||||
      width: 13px;
 | 
			
		||||
      height: 13px;
 | 
			
		||||
      border-radius: 2px;
 | 
			
		||||
      margin: 0.5em 0.5em 0.2em 0.2em;
 | 
			
		||||
      background-color: $oc-blue-1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.Checkbox:hover {
 | 
			
		||||
      .Checkbox-box {
 | 
			
		||||
        background-color: $oc-blue-2;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    right: 5px;
 | 
			
		||||
    top: 5px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__removeFromLibrary > svg {
 | 
			
		||||
@@ -86,37 +43,29 @@
 | 
			
		||||
    width: 16px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__adder {
 | 
			
		||||
  .library-unit__pulse {
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
    animation: library-unit__adder-animation 1s ease-in infinite;
 | 
			
		||||
    animation: library-unit__pulse-animation 1s ease-in infinite;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__adder {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 40%;
 | 
			
		||||
    top: 40%;
 | 
			
		||||
    width: 2rem;
 | 
			
		||||
    height: 2rem;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    width: 20px;
 | 
			
		||||
    height: 20px;
 | 
			
		||||
    margin-left: -10px;
 | 
			
		||||
    margin-top: -10px;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
  }
 | 
			
		||||
  .library-unit:hover .library-unit__adder {
 | 
			
		||||
    fill: $oc-blue-7;
 | 
			
		||||
  }
 | 
			
		||||
  .library-unit:active .library-unit__adder {
 | 
			
		||||
    animation: none;
 | 
			
		||||
    transform: scale(0.8);
 | 
			
		||||
    fill: $oc-black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .library-unit__active {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @keyframes library-unit__adder-animation {
 | 
			
		||||
  @keyframes library-unit__pulse-animation {
 | 
			
		||||
    0% {
 | 
			
		||||
      transform: scale(0.85);
 | 
			
		||||
      transform: scale(0.95);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    50% {
 | 
			
		||||
@@ -124,7 +73,7 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    100% {
 | 
			
		||||
      transform: scale(0.85);
 | 
			
		||||
      transform: scale(0.95);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,103 +1,82 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { close } from "../components/icons";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../is-mobile";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import { BinaryFiles, LibraryItem } from "../types";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
 | 
			
		||||
// fa-plus
 | 
			
		||||
const PLUS_ICON = (
 | 
			
		||||
  <svg viewBox="0 0 1792 1792">
 | 
			
		||||
    <path
 | 
			
		||||
      d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
 | 
			
		||||
      style={{
 | 
			
		||||
        stroke: "#fff",
 | 
			
		||||
        strokeWidth: 140,
 | 
			
		||||
      }}
 | 
			
		||||
      transform="translate(0 64)"
 | 
			
		||||
      fill="currentColor"
 | 
			
		||||
      d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
 | 
			
		||||
    />
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const LibraryUnit = ({
 | 
			
		||||
  id,
 | 
			
		||||
  elements,
 | 
			
		||||
  files,
 | 
			
		||||
  isPending,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onClick,
 | 
			
		||||
  selected,
 | 
			
		||||
  onToggle,
 | 
			
		||||
}: {
 | 
			
		||||
  id: LibraryItem["id"] | /** for pending item */ null;
 | 
			
		||||
  elements?: LibraryItem["elements"];
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  isPending?: boolean;
 | 
			
		||||
  elements?: LibraryItem;
 | 
			
		||||
  pendingElements?: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: () => void;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  selected: boolean;
 | 
			
		||||
  onToggle: (id: string, event: React.MouseEvent) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const node = ref.current;
 | 
			
		||||
    if (!node) {
 | 
			
		||||
    const elementsToRender = elements || pendingElements;
 | 
			
		||||
    if (!elementsToRender) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    (async () => {
 | 
			
		||||
      if (!elements) {
 | 
			
		||||
        return;
 | 
			
		||||
    const svg = exportToSvg(elementsToRender, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
    for (const child of ref.current!.children) {
 | 
			
		||||
      if (child.tagName !== "svg") {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      const svg = await exportToSvg(
 | 
			
		||||
        elements,
 | 
			
		||||
        {
 | 
			
		||||
          exportBackground: false,
 | 
			
		||||
          viewBackgroundColor: oc.white,
 | 
			
		||||
        },
 | 
			
		||||
        files,
 | 
			
		||||
      );
 | 
			
		||||
      node.innerHTML = svg.outerHTML;
 | 
			
		||||
    })();
 | 
			
		||||
      ref.current!.removeChild(child);
 | 
			
		||||
    }
 | 
			
		||||
    ref.current!.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
    const current = ref.current!;
 | 
			
		||||
    return () => {
 | 
			
		||||
      node.innerHTML = "";
 | 
			
		||||
      current.removeChild(svg);
 | 
			
		||||
    };
 | 
			
		||||
  }, [elements, files]);
 | 
			
		||||
  }, [elements, pendingElements]);
 | 
			
		||||
 | 
			
		||||
  const [isHovered, setIsHovered] = useState(false);
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const adder = isPending && (
 | 
			
		||||
 | 
			
		||||
  const adder = (isHovered || isMobile) && pendingElements && (
 | 
			
		||||
    <div className="library-unit__adder">{PLUS_ICON}</div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("library-unit", {
 | 
			
		||||
        "library-unit__active": elements,
 | 
			
		||||
        "library-unit--hover": elements && isHovered,
 | 
			
		||||
        "library-unit--selected": selected,
 | 
			
		||||
        "library-unit__active": elements || pendingElements,
 | 
			
		||||
      })}
 | 
			
		||||
      onMouseEnter={() => setIsHovered(true)}
 | 
			
		||||
      onMouseLeave={() => setIsHovered(false)}
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("library-unit__dragger", {
 | 
			
		||||
          "library-unit__pulse": !!isPending,
 | 
			
		||||
          "library-unit__pulse": !!pendingElements,
 | 
			
		||||
        })}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
        draggable={!!elements}
 | 
			
		||||
        onClick={
 | 
			
		||||
          !!elements || !!isPending
 | 
			
		||||
            ? (event) => {
 | 
			
		||||
                if (id && event.shiftKey) {
 | 
			
		||||
                  onToggle(id, event);
 | 
			
		||||
                } else {
 | 
			
		||||
                  onClick();
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
        onClick={!!elements || !!pendingElements ? onClick : undefined}
 | 
			
		||||
        onDragStart={(event) => {
 | 
			
		||||
          setIsHovered(false);
 | 
			
		||||
          event.dataTransfer.setData(
 | 
			
		||||
@@ -107,12 +86,14 @@ export const LibraryUnit = ({
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {adder}
 | 
			
		||||
      {id && elements && (isHovered || isMobile || selected) && (
 | 
			
		||||
        <CheckboxItem
 | 
			
		||||
          checked={selected}
 | 
			
		||||
          onChange={(checked, event) => onToggle(id, event)}
 | 
			
		||||
          className="library-unit__checkbox"
 | 
			
		||||
        />
 | 
			
		||||
      {elements && (isHovered || isMobile) && (
 | 
			
		||||
        <button
 | 
			
		||||
          className="library-unit__removeFromLibrary"
 | 
			
		||||
          aria-label={t("labels.removeFromLibrary")}
 | 
			
		||||
          onClick={onRemoveFromLibrary}
 | 
			
		||||
        >
 | 
			
		||||
          {close}
 | 
			
		||||
        </button>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const LoadingMessage = () => {
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user