mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Compare commits
	
		
			14 Commits
		
	
	
		
			fix_canvas
			...
			updatescen
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					eb206cc932 | ||
| 
						 | 
					16c287c848 | ||
| 
						 | 
					78024873e5 | ||
| 
						 | 
					4e41bd9dbb | ||
| 
						 | 
					edc23b854f | ||
| 
						 | 
					4843c49556 | ||
| 
						 | 
					d565413082 | ||
| 
						 | 
					dcda7184d0 | ||
| 
						 | 
					8d413670c8 | ||
| 
						 | 
					f774452124 | ||
| 
						 | 
					db4ed1ecb1 | ||
| 
						 | 
					489f45b910 | ||
| 
						 | 
					a17be085b0 | ||
| 
						 | 
					4e07a608d3 | 
							
								
								
									
										26
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/autorelease-excalidraw.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,26 +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 autorelease
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -5,11 +5,9 @@
 | 
			
		||||
.env.test.local
 | 
			
		||||
.envrc
 | 
			
		||||
.eslintcache
 | 
			
		||||
.history
 | 
			
		||||
.idea
 | 
			
		||||
.vercel
 | 
			
		||||
.vscode
 | 
			
		||||
.yarn
 | 
			
		||||
*.log
 | 
			
		||||
*.tgz
 | 
			
		||||
build
 | 
			
		||||
@@ -22,4 +20,3 @@ package-lock.json
 | 
			
		||||
static
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
src/packages/excalidraw/types
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								README.md
									
									
									
									
									
								
							@@ -93,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
 | 
			
		||||
@@ -102,20 +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.
 | 
			
		||||
 | 
			
		||||
#### 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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										40
									
								
								package.json
									
									
									
									
									
								
							@@ -19,35 +19,34 @@
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.10",
 | 
			
		||||
    "@testing-library/react": "11.2.6",
 | 
			
		||||
    "@types/jest": "26.0.22",
 | 
			
		||||
    "@types/react": "17.0.3",
 | 
			
		||||
    "@types/react-dom": "17.0.3",
 | 
			
		||||
    "@sentry/browser": "6.2.2",
 | 
			
		||||
    "@sentry/integrations": "6.2.1",
 | 
			
		||||
    "@testing-library/jest-dom": "5.11.9",
 | 
			
		||||
    "@testing-library/react": "11.2.5",
 | 
			
		||||
    "@types/jest": "26.0.20",
 | 
			
		||||
    "@types/react": "17.0.2",
 | 
			
		||||
    "@types/react-dom": "17.0.1",
 | 
			
		||||
    "@types/socket.io-client": "1.4.36",
 | 
			
		||||
    "browser-fs-access": "0.18.0",
 | 
			
		||||
    "browser-fs-access": "0.14.2",
 | 
			
		||||
    "clsx": "1.1.1",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.0",
 | 
			
		||||
    "firebase": "8.2.10",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.0.1",
 | 
			
		||||
    "lodash.throttle": "4.1.1",
 | 
			
		||||
    "nanoid": "3.1.22",
 | 
			
		||||
    "nanoid": "3.1.21",
 | 
			
		||||
    "open-color": "1.8.0",
 | 
			
		||||
    "pako": "1.0.11",
 | 
			
		||||
    "perfect-freehand": "0.4.7",
 | 
			
		||||
    "png-chunk-text": "1.0.0",
 | 
			
		||||
    "png-chunks-encode": "1.0.0",
 | 
			
		||||
    "png-chunks-extract": "1.0.0",
 | 
			
		||||
    "points-on-curve": "0.2.0",
 | 
			
		||||
    "pwacompat": "2.0.17",
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react": "17.0.1",
 | 
			
		||||
    "react-dom": "17.0.1",
 | 
			
		||||
    "react-scripts": "4.0.3",
 | 
			
		||||
    "roughjs": "4.4.1",
 | 
			
		||||
    "sass": "1.32.10",
 | 
			
		||||
    "roughjs": "4.3.1",
 | 
			
		||||
    "sass": "1.32.8",
 | 
			
		||||
    "socket.io-client": "2.3.1",
 | 
			
		||||
    "typescript": "4.2.4"
 | 
			
		||||
    "typescript": "4.2.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.0",
 | 
			
		||||
@@ -55,9 +54,9 @@
 | 
			
		||||
    "@types/lodash.throttle": "4.1.6",
 | 
			
		||||
    "@types/pako": "1.0.1",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.5",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "eslint-config-prettier": "8.1.0",
 | 
			
		||||
    "eslint-plugin-prettier": "3.3.1",
 | 
			
		||||
    "firebase-tools": "9.9.0",
 | 
			
		||||
    "firebase-tools": "9.6.1",
 | 
			
		||||
    "husky": "4.3.8",
 | 
			
		||||
    "jest-canvas-mock": "2.3.1",
 | 
			
		||||
    "lint-staged": "10.5.4",
 | 
			
		||||
@@ -104,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.
										
									
								
							@@ -51,7 +51,8 @@
 | 
			
		||||
      name="twitter:description"
 | 
			
		||||
      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <!-- OG tags require absolute url for images -->
 | 
			
		||||
    <meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
 | 
			
		||||
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
 | 
			
		||||
 | 
			
		||||
    <!-- Excalidraw version -->
 | 
			
		||||
@@ -87,8 +88,6 @@
 | 
			
		||||
    <link rel="stylesheet" href="fonts.css" type="text/css" />
 | 
			
		||||
    <script>
 | 
			
		||||
      window.EXCALIDRAW_ASSET_PATH = "/";
 | 
			
		||||
      // setting this so that libraries installation reuses this window tab.
 | 
			
		||||
      window.name = "_excalidraw";
 | 
			
		||||
    </script>
 | 
			
		||||
    <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
 | 
			
		||||
    <script
 | 
			
		||||
@@ -107,17 +106,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 +124,6 @@
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        clip: rect(1px, 1px, 1px, 1px);
 | 
			
		||||
        white-space: nowrap; /* added line */
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage {
 | 
			
		||||
@@ -150,24 +146,6 @@
 | 
			
		||||
        color: var(--popup-text-color);
 | 
			
		||||
        font-size: 1.3em;
 | 
			
		||||
      }
 | 
			
		||||
      #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>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -39,37 +39,5 @@
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "screenshots": [
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/screenshots/virtual-whiteboard.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "462x945"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/screenshots/wireframe.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "462x945"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/screenshots/illustration.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "462x945"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/screenshots/shapes.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "462x945"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/screenshots/collaboration.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "462x945"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/screenshots/export.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "462x945"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 28 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 25 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 47 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 25 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 27 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 27 KiB  | 
@@ -1,51 +0,0 @@
 | 
			
		||||
const fs = require("fs");
 | 
			
		||||
const { exec, execSync } = require("child_process");
 | 
			
		||||
 | 
			
		||||
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`);
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// get files changed between prev and head commit
 | 
			
		||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
 | 
			
		||||
  if (error || stderr) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const changedFiles = stdout.trim().split("\n");
 | 
			
		||||
  const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
 | 
			
		||||
 | 
			
		||||
  const excalidrawPackageFiles = changedFiles.filter((file) => {
 | 
			
		||||
    return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!excalidrawPackageFiles.length) {
 | 
			
		||||
    process.exit(0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // update package.json
 | 
			
		||||
  pkg.version = `${pkg.version}-${getShortCommitHash()}`;
 | 
			
		||||
  pkg.name = "@excalidraw/excalidraw-next";
 | 
			
		||||
  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
 | 
			
		||||
 | 
			
		||||
  // update readme
 | 
			
		||||
  const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
 | 
			
		||||
  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
 | 
			
		||||
 | 
			
		||||
  publish();
 | 
			
		||||
});
 | 
			
		||||
@@ -37,9 +37,6 @@ const crowdinMap = {
 | 
			
		||||
  "uk-UA": "en-uk",
 | 
			
		||||
  "zh-CN": "en-zhcn",
 | 
			
		||||
  "zh-TW": "en-zhtw",
 | 
			
		||||
  "lv-LV": "en-lv",
 | 
			
		||||
  "cs-CZ": "en-cs",
 | 
			
		||||
  "kk-KZ": "en-kk",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flags = {
 | 
			
		||||
@@ -77,9 +74,6 @@ const flags = {
 | 
			
		||||
  "uk-UA": "🇺🇦",
 | 
			
		||||
  "zh-CN": "🇨🇳",
 | 
			
		||||
  "zh-TW": "🇹🇼",
 | 
			
		||||
  "lv-LV": "🇱🇻",
 | 
			
		||||
  "cs-CZ": "🇨🇿",
 | 
			
		||||
  "kk-KZ": "🇰🇿",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const languages = {
 | 
			
		||||
@@ -117,9 +111,6 @@ const languages = {
 | 
			
		||||
  "uk-UA": "Українська",
 | 
			
		||||
  "zh-CN": "简体中文",
 | 
			
		||||
  "zh-TW": "繁體中文",
 | 
			
		||||
  "lv-LV": "Latviešu",
 | 
			
		||||
  "cs-CZ": "Česky",
 | 
			
		||||
  "kk-KZ": "Қазақ тілі",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const percentages = fs.readFileSync(
 | 
			
		||||
 
 | 
			
		||||
@@ -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 (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
    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 (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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,20 +2,18 @@ import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { deepCopyElement } from "../element/newElement";
 | 
			
		||||
import { Library } from "../data/library";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    app.library.loadLibrary().then((items) => {
 | 
			
		||||
      app.library.saveLibrary([
 | 
			
		||||
        ...items,
 | 
			
		||||
        selectedElements.map(deepCopyElement),
 | 
			
		||||
      ]);
 | 
			
		||||
    Library.loadLibrary().then((items) => {
 | 
			
		||||
      Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
 | 
			
		||||
    });
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -3,13 +3,12 @@ import { getDefaultAppState } from "../appState";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { DarkModeToggle } from "../components/DarkModeToggle";
 | 
			
		||||
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 "../components/App";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
@@ -22,8 +21,8 @@ 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,12 +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 })
 | 
			
		||||
          }
 | 
			
		||||
          data-testid="canvas-background-picker"
 | 
			
		||||
          onChange={(color) => updateData(color)}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
@@ -59,6 +53,7 @@ export const actionClearCanvas = register({
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        shouldAddWatermark: appState.shouldAddWatermark,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
      },
 | 
			
		||||
@@ -77,7 +72,6 @@ export const actionClearCanvas = register({
 | 
			
		||||
          updateData(null);
 | 
			
		||||
        }
 | 
			
		||||
      }}
 | 
			
		||||
      data-testid="clear-canvas-button"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
@@ -264,27 +258,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 === "light" ? "dark" : "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,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
 | 
			
		||||
          ? selectedElements
 | 
			
		||||
          : getNonDeletedElements(elements),
 | 
			
		||||
        appState,
 | 
			
		||||
        app.canvas,
 | 
			
		||||
        appState,
 | 
			
		||||
      );
 | 
			
		||||
      return {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
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";
 | 
			
		||||
@@ -8,16 +8,9 @@ import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
 | 
			
		||||
import { loadFromJSON, saveAsJSON } from "../data";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { supported as fsSupported } from "browser-fs-access";
 | 
			
		||||
import { CheckboxItem } from "../components/CheckboxItem";
 | 
			
		||||
import { getExportSize } from "../scene/export";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES } from "../constants";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ActiveFile } from "../components/ActiveFile";
 | 
			
		||||
 | 
			
		||||
export const actionChangeProjectName = register({
 | 
			
		||||
  name: "changeProjectName",
 | 
			
		||||
@@ -30,61 +23,11 @@ export const actionChangeProjectName = register({
 | 
			
		||||
      label={t("labels.fileTitle")}
 | 
			
		||||
      value={appState.name || "Unnamed"}
 | 
			
		||||
      onChange={(name: string) => updateData(name)}
 | 
			
		||||
      isNameEditable={
 | 
			
		||||
        typeof appProps.name === "undefined" && !appState.viewModeEnabled
 | 
			
		||||
      }
 | 
			
		||||
      isNameEditable={typeof appProps.name === "undefined"}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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="s"
 | 
			
		||||
              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) => {
 | 
			
		||||
@@ -94,12 +37,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>
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -112,20 +57,46 @@ 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="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",
 | 
			
		||||
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 {
 | 
			
		||||
@@ -154,16 +125,20 @@ export const actionSaveToActiveFile = register({
 | 
			
		||||
  },
 | 
			
		||||
  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)}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionSaveFileToDisk = register({
 | 
			
		||||
  name: "saveFileToDisk",
 | 
			
		||||
export const actionSaveAsScene = register({
 | 
			
		||||
  name: "saveAsScene",
 | 
			
		||||
  perform: async (elements, appState, value) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { fileHandle } = await saveAsJSON(elements, {
 | 
			
		||||
@@ -187,9 +162,10 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
      title={t("buttons.saveAs")}
 | 
			
		||||
      aria-label={t("buttons.saveAs")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      hidden={!fsSupported}
 | 
			
		||||
      hidden={
 | 
			
		||||
        !("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
 | 
			
		||||
      }
 | 
			
		||||
      onClick={() => updateData(null)}
 | 
			
		||||
      data-testid="save-as-button"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
@@ -201,7 +177,7 @@ export const actionLoadScene = register({
 | 
			
		||||
      const {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
      } = await loadFromJSON(appState, elements);
 | 
			
		||||
      } = await loadFromJSON(appState);
 | 
			
		||||
      return {
 | 
			
		||||
        elements: loadedElements,
 | 
			
		||||
        appState: loadedAppState,
 | 
			
		||||
@@ -227,7 +203,6 @@ export const actionLoadScene = register({
 | 
			
		||||
      aria-label={t("buttons.load")}
 | 
			
		||||
      showAriaLabel={useIsMobile()}
 | 
			
		||||
      onClick={updateData}
 | 
			
		||||
      data-testid="load-button"
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ 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,
 | 
			
		||||
@@ -51,19 +51,19 @@ export const actionFinalize = register({
 | 
			
		||||
 | 
			
		||||
    let newElements = elements;
 | 
			
		||||
    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;
 | 
			
		||||
@@ -86,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;
 | 
			
		||||
@@ -118,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",
 | 
			
		||||
@@ -147,14 +145,14 @@ export const actionFinalize = register({
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "freedraw"
 | 
			
		||||
          appState.elementType !== "draw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
              }
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
      commitToHistory: appState.elementType === "draw",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,207 +0,0 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
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 { updateBoundElements } from "../element/binding";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
 | 
			
		||||
const enableActionFlipHorizontal = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  const eligibleElements = getSelectedElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
    appState,
 | 
			
		||||
  );
 | 
			
		||||
  return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const enableActionFlipVertical = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  const eligibleElements = getSelectedElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
    appState,
 | 
			
		||||
  );
 | 
			
		||||
  return eligibleElements.length === 1;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const actionFlipHorizontal = register({
 | 
			
		||||
  name: "flipHorizontal",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "horizontal"),
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.shiftKey && event.code === "KeyH",
 | 
			
		||||
  contextItemLabel: "labels.flipHorizontal",
 | 
			
		||||
  contextItemPredicate: (elements, appState) =>
 | 
			
		||||
    enableActionFlipHorizontal(elements, appState),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionFlipVertical = register({
 | 
			
		||||
  name: "flipVertical",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "vertical"),
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event.shiftKey && event.code === "KeyV",
 | 
			
		||||
  contextItemLabel: "labels.flipVertical",
 | 
			
		||||
  contextItemPredicate: (elements, appState) =>
 | 
			
		||||
    enableActionFlipVertical(elements, appState),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const flipSelectedElements = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  flipDirection: "horizontal" | "vertical",
 | 
			
		||||
) => {
 | 
			
		||||
  const selectedElements = getSelectedElements(
 | 
			
		||||
    getNonDeletedElements(elements),
 | 
			
		||||
    appState,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // remove once we allow for groups of elements to be flipped
 | 
			
		||||
  if (selectedElements.length > 1) {
 | 
			
		||||
    return elements;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const updatedElements = flipElements(
 | 
			
		||||
    selectedElements,
 | 
			
		||||
    appState,
 | 
			
		||||
    flipDirection,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const updatedElementsMap = getElementMap(updatedElements);
 | 
			
		||||
 | 
			
		||||
  return elements.map((element) => updatedElementsMap[element.id] || element);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flipElements = (
 | 
			
		||||
  elements: NonDeleted<ExcalidrawElement>[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  flipDirection: "horizontal" | "vertical",
 | 
			
		||||
): ExcalidrawElement[] => {
 | 
			
		||||
  for (let i = 0; i < elements.length; i++) {
 | 
			
		||||
    flipElement(elements[i], appState);
 | 
			
		||||
    // If vertical flip, rotate an extra 180
 | 
			
		||||
    if (flipDirection === "vertical") {
 | 
			
		||||
      rotateElement(elements[i], Math.PI);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return elements;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const flipElement = (
 | 
			
		||||
  element: NonDeleted<ExcalidrawElement>,
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  const originalX = element.x;
 | 
			
		||||
  const originalY = element.y;
 | 
			
		||||
  const width = element.width;
 | 
			
		||||
  const height = element.height;
 | 
			
		||||
  const originalAngle = normalizeAngle(element.angle);
 | 
			
		||||
 | 
			
		||||
  let finalOffsetX = 0;
 | 
			
		||||
  if (isLinearElement(element) || isFreeDrawElement(element)) {
 | 
			
		||||
    finalOffsetX =
 | 
			
		||||
      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
 | 
			
		||||
      element.width;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Rotate back to zero, if necessary
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    angle: normalizeAngle(0),
 | 
			
		||||
  });
 | 
			
		||||
  // Flip unrotated by pulling TransformHandle to opposite side
 | 
			
		||||
  const transformHandles = getTransformHandles(element, appState.zoom);
 | 
			
		||||
  let usingNWHandle = true;
 | 
			
		||||
  let newNCoordsX = 0;
 | 
			
		||||
  let nHandle = transformHandles.nw;
 | 
			
		||||
  if (!nHandle) {
 | 
			
		||||
    // Use ne handle instead
 | 
			
		||||
    usingNWHandle = false;
 | 
			
		||||
    nHandle = transformHandles.ne;
 | 
			
		||||
    if (!nHandle) {
 | 
			
		||||
      mutateElement(element, {
 | 
			
		||||
        angle: originalAngle,
 | 
			
		||||
      });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isLinearElement(element)) {
 | 
			
		||||
    for (let i = 1; i < element.points.length; i++) {
 | 
			
		||||
      LinearElementEditor.movePoint(element, i, [
 | 
			
		||||
        -element.points[i][0],
 | 
			
		||||
        element.points[i][1],
 | 
			
		||||
      ]);
 | 
			
		||||
    }
 | 
			
		||||
    LinearElementEditor.normalizePoints(element);
 | 
			
		||||
  } else {
 | 
			
		||||
    // calculate new x-coord for transformation
 | 
			
		||||
    newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
 | 
			
		||||
    resizeSingleElement(
 | 
			
		||||
      element,
 | 
			
		||||
      true,
 | 
			
		||||
      element,
 | 
			
		||||
      usingNWHandle ? "nw" : "ne",
 | 
			
		||||
      false,
 | 
			
		||||
      newNCoordsX,
 | 
			
		||||
      nHandle[1],
 | 
			
		||||
    );
 | 
			
		||||
    // fix the size to account for handle sizes
 | 
			
		||||
    mutateElement(element, {
 | 
			
		||||
      width,
 | 
			
		||||
      height,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Rotate by (360 degrees - original angle)
 | 
			
		||||
  let angle = normalizeAngle(2 * Math.PI - originalAngle);
 | 
			
		||||
  if (angle < 0) {
 | 
			
		||||
    // check, probably unnecessary
 | 
			
		||||
    angle = normalizeAngle(angle + 2 * Math.PI);
 | 
			
		||||
  }
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    angle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Move back to original spot to appear "flipped in place"
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    x: originalX + finalOffsetX,
 | 
			
		||||
    y: originalY,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  updateBoundElements(element);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
 | 
			
		||||
  const originalX = element.x;
 | 
			
		||||
  const originalY = element.y;
 | 
			
		||||
  let angle = normalizeAngle(element.angle + rotationAngle);
 | 
			
		||||
  if (angle < 0) {
 | 
			
		||||
    // check, probably unnecessary
 | 
			
		||||
    angle = normalizeAngle(2 * Math.PI + angle);
 | 
			
		||||
  }
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    angle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Move back to original spot
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    x: originalX,
 | 
			
		||||
    y: originalY,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
@@ -3,7 +3,7 @@ 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";
 | 
			
		||||
@@ -59,7 +59,7 @@ const writeData = (
 | 
			
		||||
  return { commitToHistory };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ActionCreator = (history: History) => Action;
 | 
			
		||||
type ActionCreator = (history: SceneHistory) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
 
 | 
			
		||||
@@ -70,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,6 +1,7 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AppState } from "../../src/types";
 | 
			
		||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
			
		||||
import { ButtonSelect } from "../components/ButtonSelect";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker";
 | 
			
		||||
import { IconPicker } from "../components/IconPicker";
 | 
			
		||||
import {
 | 
			
		||||
@@ -13,13 +14,6 @@ import {
 | 
			
		||||
  FillCrossHatchIcon,
 | 
			
		||||
  FillHachureIcon,
 | 
			
		||||
  FillSolidIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  FontFamilyHandDrawnIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  FontSizeLargeIcon,
 | 
			
		||||
  FontSizeMediumIcon,
 | 
			
		||||
  FontSizeSmallIcon,
 | 
			
		||||
  SloppinessArchitectIcon,
 | 
			
		||||
  SloppinessArtistIcon,
 | 
			
		||||
  SloppinessCartoonistIcon,
 | 
			
		||||
@@ -27,15 +21,8 @@ import {
 | 
			
		||||
  StrokeStyleDottedIcon,
 | 
			
		||||
  StrokeStyleSolidIcon,
 | 
			
		||||
  StrokeWidthIcon,
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  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,
 | 
			
		||||
@@ -48,7 +35,7 @@ import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  FontFamilyValues,
 | 
			
		||||
  FontFamily,
 | 
			
		||||
  TextAlign,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getLanguage, t } from "../i18n";
 | 
			
		||||
@@ -103,18 +90,13 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
        elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
          newElementWith(el, {
 | 
			
		||||
            strokeColor: value.currentItemStrokeColor,
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
      }),
 | 
			
		||||
      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 }) => (
 | 
			
		||||
@@ -129,11 +111,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}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -143,18 +121,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 }) => (
 | 
			
		||||
@@ -169,11 +142,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}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
@@ -444,29 +413,13 @@ export const actionChangeFontSize = register({
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      <legend>{t("labels.fontSize")}</legend>
 | 
			
		||||
      <ButtonIconSelect
 | 
			
		||||
      <ButtonSelect
 | 
			
		||||
        group="font-size"
 | 
			
		||||
        options={[
 | 
			
		||||
          {
 | 
			
		||||
            value: 16,
 | 
			
		||||
            text: t("labels.small"),
 | 
			
		||||
            icon: <FontSizeSmallIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 20,
 | 
			
		||||
            text: t("labels.medium"),
 | 
			
		||||
            icon: <FontSizeMediumIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 28,
 | 
			
		||||
            text: t("labels.large"),
 | 
			
		||||
            icon: <FontSizeLargeIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: 36,
 | 
			
		||||
            text: t("labels.veryLarge"),
 | 
			
		||||
            icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          { value: 16, text: t("labels.small") },
 | 
			
		||||
          { value: 20, text: t("labels.medium") },
 | 
			
		||||
          { value: 28, text: t("labels.large") },
 | 
			
		||||
          { value: 36, text: t("labels.veryLarge") },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
@@ -503,32 +456,16 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    const options: {
 | 
			
		||||
      value: FontFamilyValues;
 | 
			
		||||
      text: string;
 | 
			
		||||
      icon: JSX.Element;
 | 
			
		||||
    }[] = [
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Virgil,
 | 
			
		||||
        text: t("labels.handDrawn"),
 | 
			
		||||
        icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Helvetica,
 | 
			
		||||
        text: t("labels.normal"),
 | 
			
		||||
        icon: <FontFamilyNormalIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Cascadia,
 | 
			
		||||
        text: t("labels.code"),
 | 
			
		||||
        icon: <FontFamilyCodeIcon theme={appState.theme} />,
 | 
			
		||||
      },
 | 
			
		||||
    const options: { value: FontFamily; text: string }[] = [
 | 
			
		||||
      { value: 1, text: t("labels.handDrawn") },
 | 
			
		||||
      { value: 2, text: t("labels.normal") },
 | 
			
		||||
      { value: 3, text: t("labels.code") },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.fontFamily")}</legend>
 | 
			
		||||
        <ButtonIconSelect<FontFamilyValues | false>
 | 
			
		||||
        <ButtonSelect<FontFamily | false>
 | 
			
		||||
          group="font-family"
 | 
			
		||||
          options={options}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
@@ -569,24 +506,12 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => (
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      <legend>{t("labels.textAlign")}</legend>
 | 
			
		||||
      <ButtonIconSelect<TextAlign | false>
 | 
			
		||||
      <ButtonSelect<TextAlign | false>
 | 
			
		||||
        group="text-align"
 | 
			
		||||
        options={[
 | 
			
		||||
          {
 | 
			
		||||
            value: "left",
 | 
			
		||||
            text: t("labels.left"),
 | 
			
		||||
            icon: <TextAlignLeftIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "center",
 | 
			
		||||
            text: t("labels.center"),
 | 
			
		||||
            icon: <TextAlignCenterIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "right",
 | 
			
		||||
            text: t("labels.right"),
 | 
			
		||||
            icon: <TextAlignRightIcon theme={appState.theme} />,
 | 
			
		||||
          },
 | 
			
		||||
          { value: "left", text: t("labels.left") },
 | 
			
		||||
          { value: "center", text: t("labels.center") },
 | 
			
		||||
          { value: "right", text: t("labels.right") },
 | 
			
		||||
        ]}
 | 
			
		||||
        value={getFormValue(
 | 
			
		||||
          elements,
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,6 @@ export {
 | 
			
		||||
  actionZoomOut,
 | 
			
		||||
  actionResetZoom,
 | 
			
		||||
  actionZoomToFit,
 | 
			
		||||
  actionToggleTheme,
 | 
			
		||||
} from "./actionCanvas";
 | 
			
		||||
 | 
			
		||||
export { actionFinalize } from "./actionFinalize";
 | 
			
		||||
@@ -34,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
 | 
			
		||||
export {
 | 
			
		||||
  actionChangeProjectName,
 | 
			
		||||
  actionChangeExportBackground,
 | 
			
		||||
  actionSaveToActiveFile,
 | 
			
		||||
  actionSaveFileToDisk,
 | 
			
		||||
  actionSaveScene,
 | 
			
		||||
  actionSaveAsScene,
 | 
			
		||||
  actionLoadScene,
 | 
			
		||||
} from "./actionExport";
 | 
			
		||||
 | 
			
		||||
@@ -67,8 +66,6 @@ export {
 | 
			
		||||
  distributeVertically,
 | 
			
		||||
} from "./actionDistribute";
 | 
			
		||||
 | 
			
		||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  actionCopy,
 | 
			
		||||
  actionCut,
 | 
			
		||||
 
 | 
			
		||||
@@ -7,18 +7,12 @@ import {
 | 
			
		||||
  ActionResult,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppProps, AppState } from "../types";
 | 
			
		||||
import { AppState, ExcalidrawProps } from "../types";
 | 
			
		||||
import { MODES } from "../constants";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
 | 
			
		||||
// This is the <App> component, but for now we don't care about anything but its
 | 
			
		||||
// `canvas` state.
 | 
			
		||||
type App = {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  props: AppProps;
 | 
			
		||||
  library: Library;
 | 
			
		||||
};
 | 
			
		||||
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
 | 
			
		||||
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
@@ -57,15 +51,11 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
    actions.forEach((action) => this.registerAction(action));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
 | 
			
		||||
    const canvasActions = this.app.props.UIOptions.canvasActions;
 | 
			
		||||
  handleKeyDown(event: KeyboardEvent) {
 | 
			
		||||
    const data = Object.values(this.actions)
 | 
			
		||||
      .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
 | 
			
		||||
      .filter(
 | 
			
		||||
        (action) =>
 | 
			
		||||
          (action.name in canvasActions
 | 
			
		||||
            ? canvasActions[action.name as keyof typeof canvasActions]
 | 
			
		||||
            : true) &&
 | 
			
		||||
          action.keyTest &&
 | 
			
		||||
          action.keyTest(
 | 
			
		||||
            event,
 | 
			
		||||
@@ -112,15 +102,7 @@ export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  // 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 (
 | 
			
		||||
      this.actions[name] &&
 | 
			
		||||
      "PanelComponent" in this.actions[name] &&
 | 
			
		||||
      (name in canvasActions
 | 
			
		||||
        ? canvasActions[name as keyof typeof canvasActions]
 | 
			
		||||
        : true)
 | 
			
		||||
    ) {
 | 
			
		||||
    if (this.actions[name] && "PanelComponent" in this.actions[name]) {
 | 
			
		||||
      const action = this.actions[name];
 | 
			
		||||
      const PanelComponent = action.PanelComponent!;
 | 
			
		||||
      const updateData = (formState?: any) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -23,9 +23,7 @@ export type ShortcutName =
 | 
			
		||||
  | "zenMode"
 | 
			
		||||
  | "stats"
 | 
			
		||||
  | "addToLibrary"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical";
 | 
			
		||||
  | "viewMode";
 | 
			
		||||
 | 
			
		||||
const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  cut: [getShortcutKey("CtrlOrCmd+X")],
 | 
			
		||||
@@ -57,10 +55,8 @@ 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")],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,22 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState, ExcalidrawProps } from "../types";
 | 
			
		||||
import Library from "../data/library";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
export type ActionResult =
 | 
			
		||||
  | {
 | 
			
		||||
      elements?: readonly ExcalidrawElement[] | null;
 | 
			
		||||
      appState?: MarkOptional<
 | 
			
		||||
        AppState,
 | 
			
		||||
        "offsetTop" | "offsetLeft" | "width" | "height"
 | 
			
		||||
      > | null;
 | 
			
		||||
      appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
 | 
			
		||||
      commitToHistory: boolean;
 | 
			
		||||
      syncHistory?: boolean;
 | 
			
		||||
    }
 | 
			
		||||
  | false;
 | 
			
		||||
 | 
			
		||||
type AppAPI = {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  focusContainer(): void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ActionFn = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  formData: any,
 | 
			
		||||
  app: AppAPI,
 | 
			
		||||
  app: { canvas: HTMLCanvasElement | null },
 | 
			
		||||
) => ActionResult | Promise<ActionResult>;
 | 
			
		||||
 | 
			
		||||
export type UpdaterFn = (res: ActionResult) => void;
 | 
			
		||||
@@ -52,7 +42,6 @@ export type ActionName =
 | 
			
		||||
  | "changeBackgroundColor"
 | 
			
		||||
  | "changeFillStyle"
 | 
			
		||||
  | "changeStrokeWidth"
 | 
			
		||||
  | "changeStrokeShape"
 | 
			
		||||
  | "changeSloppiness"
 | 
			
		||||
  | "changeStrokeStyle"
 | 
			
		||||
  | "changeArrowhead"
 | 
			
		||||
@@ -66,9 +55,9 @@ export type ActionName =
 | 
			
		||||
  | "changeProjectName"
 | 
			
		||||
  | "changeExportBackground"
 | 
			
		||||
  | "changeExportEmbedScene"
 | 
			
		||||
  | "changeExportScale"
 | 
			
		||||
  | "saveToActiveFile"
 | 
			
		||||
  | "saveFileToDisk"
 | 
			
		||||
  | "changeShouldAddWatermark"
 | 
			
		||||
  | "saveScene"
 | 
			
		||||
  | "saveAsScene"
 | 
			
		||||
  | "loadScene"
 | 
			
		||||
  | "duplicateSelection"
 | 
			
		||||
  | "deleteSelectedElements"
 | 
			
		||||
@@ -96,11 +85,8 @@ export type ActionName =
 | 
			
		||||
  | "alignHorizontallyCentered"
 | 
			
		||||
  | "distributeHorizontally"
 | 
			
		||||
  | "distributeVertically"
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "viewMode"
 | 
			
		||||
  | "exportWithDarkMode"
 | 
			
		||||
  | "toggleTheme";
 | 
			
		||||
  | "exportWithDarkMode";
 | 
			
		||||
 | 
			
		||||
export interface Action {
 | 
			
		||||
  name: ActionName;
 | 
			
		||||
@@ -114,7 +100,7 @@ export interface Action {
 | 
			
		||||
  perform: ActionFn;
 | 
			
		||||
  keyPriority?: number;
 | 
			
		||||
  keyTest?: (
 | 
			
		||||
    event: React.KeyboardEvent | KeyboardEvent,
 | 
			
		||||
    event: KeyboardEvent,
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
@@ -129,7 +115,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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,19 +3,14 @@ import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
  EXPORT_SCALES,
 | 
			
		||||
} 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"
 | 
			
		||||
  "offsetTop" | "offsetLeft"
 | 
			
		||||
> => {
 | 
			
		||||
  return {
 | 
			
		||||
    theme: "light",
 | 
			
		||||
@@ -44,11 +39,11 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    elementType: "selection",
 | 
			
		||||
    errorMessage: null,
 | 
			
		||||
    exportBackground: true,
 | 
			
		||||
    exportScale: defaultExportScale,
 | 
			
		||||
    exportEmbedScene: false,
 | 
			
		||||
    exportWithDarkMode: false,
 | 
			
		||||
    fileHandle: null,
 | 
			
		||||
    gridSize: null,
 | 
			
		||||
    height: window.innerHeight,
 | 
			
		||||
    isBindingEnabled: true,
 | 
			
		||||
    isLibraryOpen: false,
 | 
			
		||||
    isLoading: false,
 | 
			
		||||
@@ -58,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,
 | 
			
		||||
@@ -68,6 +62,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    selectedElementIds: {},
 | 
			
		||||
    selectedGroupIds: {},
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    shouldAddWatermark: false,
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showHelpDialog: false,
 | 
			
		||||
    showStats: false,
 | 
			
		||||
@@ -75,6 +70,7 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    suggestedBindings: [],
 | 
			
		||||
    toastMessage: null,
 | 
			
		||||
    viewBackgroundColor: oc.white,
 | 
			
		||||
    width: window.innerWidth,
 | 
			
		||||
    zenModeEnabled: false,
 | 
			
		||||
    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
 | 
			
		||||
    viewModeEnabled: false,
 | 
			
		||||
@@ -123,7 +119,6 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  errorMessage: { browser: false, export: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false },
 | 
			
		||||
  exportScale: { browser: true, export: false },
 | 
			
		||||
  exportWithDarkMode: { browser: true, export: false },
 | 
			
		||||
  fileHandle: { browser: false, export: false },
 | 
			
		||||
  gridSize: { browser: true, export: true },
 | 
			
		||||
@@ -139,7 +134,6 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  offsetLeft: { browser: false, export: false },
 | 
			
		||||
  offsetTop: { browser: false, export: false },
 | 
			
		||||
  openMenu: { browser: true, export: false },
 | 
			
		||||
  openPopup: { browser: false, export: false },
 | 
			
		||||
  pasteDialog: { browser: false, export: false },
 | 
			
		||||
  previousSelectedElementIds: { browser: true, export: false },
 | 
			
		||||
  resizingElement: { browser: false, export: false },
 | 
			
		||||
@@ -149,6 +143,7 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  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 },
 | 
			
		||||
 
 | 
			
		||||
@@ -6,20 +6,16 @@ import { getSelectedElements } from "./scene";
 | 
			
		||||
import { AppState } from "./types";
 | 
			
		||||
import { SVG_EXPORT_TAG } from "./scene/export";
 | 
			
		||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import { EXPORT_DATA_TYPES } from "./constants";
 | 
			
		||||
import { canvasToBlob } from "./data/blob";
 | 
			
		||||
 | 
			
		||||
const TYPE_ELEMENTS = "excalidraw/elements";
 | 
			
		||||
 | 
			
		||||
type ElementsClipboard = {
 | 
			
		||||
  type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
 | 
			
		||||
  type: typeof TYPE_ELEMENTS;
 | 
			
		||||
  created: number;
 | 
			
		||||
  elements: ExcalidrawElement[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface ClipboardData {
 | 
			
		||||
  spreadsheet?: Spreadsheet;
 | 
			
		||||
  elements?: readonly ExcalidrawElement[];
 | 
			
		||||
  text?: string;
 | 
			
		||||
  errorMessage?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let CLIPBOARD = "";
 | 
			
		||||
let PREFER_APP_CLIPBOARD = false;
 | 
			
		||||
 | 
			
		||||
@@ -35,16 +31,8 @@ export const probablySupportsClipboardBlob =
 | 
			
		||||
  "ClipboardItem" in window &&
 | 
			
		||||
  "toBlob" in HTMLCanvasElement.prototype;
 | 
			
		||||
 | 
			
		||||
const clipboardContainsElements = (
 | 
			
		||||
  contents: any,
 | 
			
		||||
): contents is { elements: ExcalidrawElement[] } => {
 | 
			
		||||
  if (
 | 
			
		||||
    [
 | 
			
		||||
      EXPORT_DATA_TYPES.excalidraw,
 | 
			
		||||
      EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
			
		||||
    ].includes(contents?.type) &&
 | 
			
		||||
    Array.isArray(contents.elements)
 | 
			
		||||
  ) {
 | 
			
		||||
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
 | 
			
		||||
  if (contents?.type === TYPE_ELEMENTS) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  return false;
 | 
			
		||||
@@ -55,7 +43,8 @@ export const copyToClipboard = async (
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  const contents: ElementsClipboard = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidrawClipboard,
 | 
			
		||||
    type: TYPE_ELEMENTS,
 | 
			
		||||
    created: Date.now(),
 | 
			
		||||
    elements: getSelectedElements(elements, appState),
 | 
			
		||||
  };
 | 
			
		||||
  const json = JSON.stringify(contents);
 | 
			
		||||
@@ -116,7 +105,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
 | 
			
		||||
@@ -137,9 +131,15 @@ export const parseClipboard = async (
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const systemClipboardData = JSON.parse(systemClipboard);
 | 
			
		||||
    if (clipboardContainsElements(systemClipboardData)) {
 | 
			
		||||
    // system clipboard elements are newer than in-app clipboard
 | 
			
		||||
    if (
 | 
			
		||||
      isElementsClipboard(systemClipboardData) &&
 | 
			
		||||
      (!appClipboardData?.created ||
 | 
			
		||||
        appClipboardData.created < systemClipboardData.created)
 | 
			
		||||
    ) {
 | 
			
		||||
      return { elements: systemClipboardData.elements };
 | 
			
		||||
    }
 | 
			
		||||
    // in-app clipboard is newer than system clipboard
 | 
			
		||||
    return appClipboardData;
 | 
			
		||||
  } catch {
 | 
			
		||||
    // system clipboard doesn't contain excalidraw elements → return plaintext
 | 
			
		||||
@@ -151,7 +151,8 @@ 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({ "image/png": blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,13 @@ import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
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";
 | 
			
		||||
@@ -54,17 +53,10 @@ export const SelectedShapeActions = ({
 | 
			
		||||
      {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")}
 | 
			
		||||
        </>
 | 
			
		||||
@@ -151,14 +143,23 @@ 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,
 | 
			
		||||
  isLibraryOpen,
 | 
			
		||||
}: {
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  elementType: ExcalidrawElement["type"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  isLibraryOpen: boolean;
 | 
			
		||||
}) => (
 | 
			
		||||
  <>
 | 
			
		||||
    {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
@@ -192,6 +193,19 @@ export const ShapesSwitcher = ({
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    })}
 | 
			
		||||
    <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 });
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  </>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,29 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
export const ButtonIconCycle = <T extends any>({
 | 
			
		||||
@@ -13,11 +14,11 @@ export const ButtonIconCycle = <T extends any>({
 | 
			
		||||
}) => {
 | 
			
		||||
  const current = options.find((op) => op.value === value);
 | 
			
		||||
 | 
			
		||||
  const cycle = () => {
 | 
			
		||||
  function cycle() {
 | 
			
		||||
    const index = options.indexOf(current!);
 | 
			
		||||
    const next = (index + 1) % options.length;
 | 
			
		||||
    onChange(options[next].value);
 | 
			
		||||
  };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <label key={group} className={clsx({ active: current!.value !== null })}>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,53 +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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,20 +0,0 @@
 | 
			
		||||
import OpenColor from "open-color";
 | 
			
		||||
 | 
			
		||||
import "./Card.scss";
 | 
			
		||||
 | 
			
		||||
export const Card: React.FC<{
 | 
			
		||||
  color: keyof OpenColor;
 | 
			
		||||
}> = ({ children, color }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className="Card"
 | 
			
		||||
      style={{
 | 
			
		||||
        ["--card-color" as any]: OpenColor[color][7],
 | 
			
		||||
        ["--card-color-darker" as any]: OpenColor[color][8],
 | 
			
		||||
        ["--card-color-darkest" as any]: 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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .Tooltip-icon {
 | 
			
		||||
      width: 1em;
 | 
			
		||||
      height: 1em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,27 +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) => void;
 | 
			
		||||
}> = ({ children, checked, onChange }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("Checkbox", { "is-checked": checked })}
 | 
			
		||||
      onClick={(event) => {
 | 
			
		||||
        onChange(!checked);
 | 
			
		||||
        ((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>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -2,7 +2,7 @@ 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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -115,7 +115,6 @@ const Picker = ({
 | 
			
		||||
      onClose();
 | 
			
		||||
    }
 | 
			
		||||
    event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -238,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 (
 | 
			
		||||
 
 | 
			
		||||
@@ -76,7 +76,7 @@
 | 
			
		||||
    z-index: 1;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .context-menu-option {
 | 
			
		||||
      display: block;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -32,63 +32,67 @@ const ContextMenu = ({
 | 
			
		||||
  actionManager,
 | 
			
		||||
  appState,
 | 
			
		||||
}: ContextMenuProps) => {
 | 
			
		||||
  const isDarkTheme = !!document
 | 
			
		||||
    .querySelector(".excalidraw")
 | 
			
		||||
    ?.classList.contains("theme--dark");
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover
 | 
			
		||||
      onCloseRequest={onCloseRequest}
 | 
			
		||||
      top={top}
 | 
			
		||||
      left={left}
 | 
			
		||||
      fitInViewport={true}
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("excalidraw", {
 | 
			
		||||
        "theme--dark theme--dark-background-none": isDarkTheme,
 | 
			
		||||
      })}
 | 
			
		||||
    >
 | 
			
		||||
      <ul
 | 
			
		||||
        className="context-menu"
 | 
			
		||||
        onContextMenu={(event) => event.preventDefault()}
 | 
			
		||||
      <Popover
 | 
			
		||||
        onCloseRequest={onCloseRequest}
 | 
			
		||||
        top={top}
 | 
			
		||||
        left={left}
 | 
			
		||||
        fitInViewport={true}
 | 
			
		||||
      >
 | 
			
		||||
        {options.map((option, idx) => {
 | 
			
		||||
          if (option === "separator") {
 | 
			
		||||
            return <hr key={idx} className="context-menu-option-separator" />;
 | 
			
		||||
          }
 | 
			
		||||
        <ul
 | 
			
		||||
          className="context-menu"
 | 
			
		||||
          onContextMenu={(event) => event.preventDefault()}
 | 
			
		||||
        >
 | 
			
		||||
          {options.map((option, idx) => {
 | 
			
		||||
            if (option === "separator") {
 | 
			
		||||
              return <hr key={idx} className="context-menu-option-separator" />;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
          const actionName = option.name;
 | 
			
		||||
          const label = option.contextItemLabel
 | 
			
		||||
            ? t(option.contextItemLabel)
 | 
			
		||||
            : "";
 | 
			
		||||
          return (
 | 
			
		||||
            <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
			
		||||
              <button
 | 
			
		||||
                className={clsx("context-menu-option", {
 | 
			
		||||
                  dangerous: actionName === "deleteSelectedElements",
 | 
			
		||||
                  checkmark: option.checked?.(appState),
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => actionManager.executeAction(option)}
 | 
			
		||||
              >
 | 
			
		||||
                <div className="context-menu-option__label">{label}</div>
 | 
			
		||||
                <kbd className="context-menu-option__shortcut">
 | 
			
		||||
                  {actionName
 | 
			
		||||
                    ? getShortcutFromShortcutName(actionName as ShortcutName)
 | 
			
		||||
                    : ""}
 | 
			
		||||
                </kbd>
 | 
			
		||||
              </button>
 | 
			
		||||
            </li>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </ul>
 | 
			
		||||
    </Popover>
 | 
			
		||||
            const actionName = option.name;
 | 
			
		||||
            const label = option.contextItemLabel
 | 
			
		||||
              ? t(option.contextItemLabel)
 | 
			
		||||
              : "";
 | 
			
		||||
            return (
 | 
			
		||||
              <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
 | 
			
		||||
                <button
 | 
			
		||||
                  className={clsx("context-menu-option", {
 | 
			
		||||
                    dangerous: actionName === "deleteSelectedElements",
 | 
			
		||||
                    checkmark: option.checked?.(appState),
 | 
			
		||||
                  })}
 | 
			
		||||
                  onClick={() => actionManager.executeAction(option)}
 | 
			
		||||
                >
 | 
			
		||||
                  <div className="context-menu-option__label">{label}</div>
 | 
			
		||||
                  <kbd className="context-menu-option__shortcut">
 | 
			
		||||
                    {actionName
 | 
			
		||||
                      ? getShortcutFromShortcutName(actionName as ShortcutName)
 | 
			
		||||
                      : ""}
 | 
			
		||||
                  </kbd>
 | 
			
		||||
                </button>
 | 
			
		||||
              </li>
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
        </ul>
 | 
			
		||||
      </Popover>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
 | 
			
		||||
 | 
			
		||||
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
 | 
			
		||||
  let contextMenuNode = contextMenuNodeByContainer.get(container);
 | 
			
		||||
let contextMenuNode: HTMLDivElement;
 | 
			
		||||
const getContextMenuNode = (): HTMLDivElement => {
 | 
			
		||||
  if (contextMenuNode) {
 | 
			
		||||
    return contextMenuNode;
 | 
			
		||||
  }
 | 
			
		||||
  contextMenuNode = document.createElement("div");
 | 
			
		||||
  container
 | 
			
		||||
    .querySelector(".excalidraw-contextMenuContainer")!
 | 
			
		||||
    .appendChild(contextMenuNode);
 | 
			
		||||
  contextMenuNodeByContainer.set(container, contextMenuNode);
 | 
			
		||||
  return contextMenuNode;
 | 
			
		||||
  const div = document.createElement("div");
 | 
			
		||||
  document.body.appendChild(div);
 | 
			
		||||
  return (contextMenuNode = div);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ContextMenuParams = {
 | 
			
		||||
@@ -97,16 +101,10 @@ type ContextMenuParams = {
 | 
			
		||||
  left: ContextMenuProps["left"];
 | 
			
		||||
  actionManager: ContextMenuProps["actionManager"];
 | 
			
		||||
  appState: Readonly<AppState>;
 | 
			
		||||
  container: HTMLElement;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleClose = (container: HTMLElement) => {
 | 
			
		||||
  const contextMenuNode = contextMenuNodeByContainer.get(container);
 | 
			
		||||
  if (contextMenuNode) {
 | 
			
		||||
    unmountComponentAtNode(contextMenuNode);
 | 
			
		||||
    contextMenuNode.remove();
 | 
			
		||||
    contextMenuNodeByContainer.delete(container);
 | 
			
		||||
  }
 | 
			
		||||
const handleClose = () => {
 | 
			
		||||
  unmountComponentAtNode(getContextMenuNode());
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
@@ -123,11 +121,11 @@ export default {
 | 
			
		||||
          top={params.top}
 | 
			
		||||
          left={params.left}
 | 
			
		||||
          options={options}
 | 
			
		||||
          onCloseRequest={() => handleClose(params.container)}
 | 
			
		||||
          onCloseRequest={handleClose}
 | 
			
		||||
          actionManager={params.actionManager}
 | 
			
		||||
          appState={params.appState}
 | 
			
		||||
        />,
 | 
			
		||||
        getContextMenuNode(params.container),
 | 
			
		||||
        getContextMenuNode(),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
 | 
			
		||||
export type Appearence = "light" | "dark";
 | 
			
		||||
 | 
			
		||||
@@ -13,19 +12,31 @@ export const DarkModeToggle = (props: {
 | 
			
		||||
  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 === "light" ? ICONS.MOON : ICONS.SUN}
 | 
			
		||||
      title={title}
 | 
			
		||||
      aria-label={title}
 | 
			
		||||
      onClick={() => props.onChange(props.value === "dark" ? "light" : "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,14 +1,13 @@
 | 
			
		||||
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 const Dialog = (props: {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
@@ -17,11 +16,8 @@ export const Dialog = (props: {
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  title: React.ReactNode;
 | 
			
		||||
  autofocus?: boolean;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
 | 
			
		||||
  const [lastActiveElement] = useState(document.activeElement);
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!islandNode) {
 | 
			
		||||
@@ -69,25 +65,19 @@ export const Dialog = (props: {
 | 
			
		||||
    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}
 | 
			
		||||
      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 (
 | 
			
		||||
    <>
 | 
			
		||||
@@ -32,7 +28,14 @@ export const ErrorDialog = ({
 | 
			
		||||
          onCloseRequest={handleClose}
 | 
			
		||||
          title={t("errorDialog.title")}
 | 
			
		||||
        >
 | 
			
		||||
          <div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
 | 
			
		||||
          <div>
 | 
			
		||||
            {message.split("\n").map((line) => (
 | 
			
		||||
              <>
 | 
			
		||||
                {line}
 | 
			
		||||
                <br />
 | 
			
		||||
              </>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,24 @@
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
  .ExportDialog__name {
 | 
			
		||||
    grid-column: project-name;
 | 
			
		||||
    margin: auto;
 | 
			
		||||
 | 
			
		||||
    .TextInput {
 | 
			
		||||
      height: calc(1rem - 3px);
 | 
			
		||||
 | 
			
		||||
      &--readonly {
 | 
			
		||||
        background: none;
 | 
			
		||||
        border: none;
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background: none;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .ExportDialog {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
@@ -57,63 +74,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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,19 +6,18 @@ import { canvasToBlob } from "../data/blob";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "./App";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { exportToCanvas } from "../scene/export";
 | 
			
		||||
import { exportToCanvas, getExportSize } from "../scene/export";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { Dialog } from "./Dialog";
 | 
			
		||||
import { clipboard, exportImage } from "./icons";
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import { clipboard, exportFile, link } from "./icons";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import "./ExportDialog.scss";
 | 
			
		||||
import { supported as fsSupported } from "browser-fs-access";
 | 
			
		||||
import OpenColor from "open-color";
 | 
			
		||||
import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
 | 
			
		||||
 | 
			
		||||
const scales = [1, 2, 3];
 | 
			
		||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
 | 
			
		||||
 | 
			
		||||
const supportsContextFilters =
 | 
			
		||||
  "filter" in document.createElement("canvas").getContext("2d")!;
 | 
			
		||||
@@ -53,37 +52,15 @@ export type ExportCB = (
 | 
			
		||||
  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 = ({
 | 
			
		||||
const ExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  exportPadding = 10,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
@@ -92,12 +69,18 @@ const ImageExportModal = ({
 | 
			
		||||
  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 } = appState;
 | 
			
		||||
  const {
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
    shouldAddWatermark,
 | 
			
		||||
  } = appState;
 | 
			
		||||
 | 
			
		||||
  const exportedElements = exportSelected
 | 
			
		||||
    ? getSelectedElements(elements, appState)
 | 
			
		||||
@@ -117,6 +100,8 @@ const ImageExportModal = ({
 | 
			
		||||
        exportBackground,
 | 
			
		||||
        viewBackgroundColor,
 | 
			
		||||
        exportPadding,
 | 
			
		||||
        scale,
 | 
			
		||||
        shouldAddWatermark,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // if converting to blob fails, there's some problem that will
 | 
			
		||||
@@ -139,6 +124,8 @@ const ImageExportModal = ({
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    exportPadding,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
    scale,
 | 
			
		||||
    shouldAddWatermark,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -146,84 +133,106 @@ const ImageExportModal = ({
 | 
			
		||||
      <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")}
 | 
			
		||||
      <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>
 | 
			
		||||
      </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",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {!fsSupported && 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>
 | 
			
		||||
        {actionManager.renderAction("changeExportBackground")}
 | 
			
		||||
        {someElementIsSelected && (
 | 
			
		||||
          <div>
 | 
			
		||||
            <label>
 | 
			
		||||
              <input
 | 
			
		||||
                type="checkbox"
 | 
			
		||||
                checked={exportSelected}
 | 
			
		||||
                onChange={(event) =>
 | 
			
		||||
                  setExportSelected(event.currentTarget.checked)
 | 
			
		||||
                }
 | 
			
		||||
              />{" "}
 | 
			
		||||
              {t("labels.onlySelected")}
 | 
			
		||||
            </label>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </Stack.Row>
 | 
			
		||||
        {actionManager.renderAction("changeExportEmbedScene")}
 | 
			
		||||
        {actionManager.renderAction("changeShouldAddWatermark")}
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ImageExportDialog = ({
 | 
			
		||||
export const ExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
  exportPadding = 10,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  onExportToPng,
 | 
			
		||||
  onExportToSvg,
 | 
			
		||||
  onExportToClipboard,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
@@ -232,11 +241,14 @@ export const ImageExportDialog = ({
 | 
			
		||||
  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 (
 | 
			
		||||
@@ -245,16 +257,17 @@ export const ImageExportDialog = ({
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setModalIsShown(true);
 | 
			
		||||
        }}
 | 
			
		||||
        data-testid="image-export-button"
 | 
			
		||||
        icon={exportImage}
 | 
			
		||||
        data-testid="export-button"
 | 
			
		||||
        icon={exportFile}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={t("buttons.exportImage")}
 | 
			
		||||
        aria-label={t("buttons.export")}
 | 
			
		||||
        showAriaLabel={useIsMobile()}
 | 
			
		||||
        title={t("buttons.exportImage")}
 | 
			
		||||
        title={t("buttons.export")}
 | 
			
		||||
        ref={triggerButton}
 | 
			
		||||
      />
 | 
			
		||||
      {modalIsShown && (
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
 | 
			
		||||
          <ImageExportModal
 | 
			
		||||
        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
 | 
			
		||||
          <ExportModal
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            appState={appState}
 | 
			
		||||
            exportPadding={exportPadding}
 | 
			
		||||
@@ -262,6 +275,7 @@ export const ImageExportDialog = ({
 | 
			
		||||
            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;
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
 
 | 
			
		||||
@@ -3,19 +3,13 @@ import React from "react";
 | 
			
		||||
 | 
			
		||||
// https://github.com/tholman/github-corners
 | 
			
		||||
export const GitHubCorner = React.memo(
 | 
			
		||||
  ({ theme, dir }: { theme: "light" | "dark"; 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"
 | 
			
		||||
@@ -25,18 +19,18 @@ export const GitHubCorner = React.memo(
 | 
			
		||||
      >
 | 
			
		||||
        <path
 | 
			
		||||
          d="M0 0l115 115h15l12 27 108 108V0z"
 | 
			
		||||
          fill={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 === "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 === "light" ? oc.white : "var(--default-bg-color)"}
 | 
			
		||||
          fill={theme === "light" ? oc.white : oc.black}
 | 
			
		||||
        />
 | 
			
		||||
      </a>
 | 
			
		||||
    </svg>
 | 
			
		||||
@@ -153,17 +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")}
 | 
			
		||||
                  label={t("toolBar.draw")}
 | 
			
		||||
                  shortcuts={["Shift+P", "7"]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.editSelectedShape")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
                    getShortcutKey("Enter"),
 | 
			
		||||
                    t("helpDialog.doubleClick"),
 | 
			
		||||
                  ]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("helpDialog.textNewLine")}
 | 
			
		||||
                  shortcuts={[
 | 
			
		||||
@@ -238,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>
 | 
			
		||||
@@ -364,22 +349,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
 | 
			
		||||
                  label={t("labels.ungroup")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.flipHorizontal")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Shift+H")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.flipVertical")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("Shift+V")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.showStroke")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("S")]}
 | 
			
		||||
                />
 | 
			
		||||
                <Shortcut
 | 
			
		||||
                  label={t("labels.showBackground")}
 | 
			
		||||
                  shortcuts={[getShortcutKey("G")]}
 | 
			
		||||
                />
 | 
			
		||||
              </ShortcutIsland>
 | 
			
		||||
            </Column>
 | 
			
		||||
          </Columns>
 | 
			
		||||
 
 | 
			
		||||
@@ -9,13 +9,7 @@ type HelpIconProps = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const HelpIcon = (props: HelpIconProps) => (
 | 
			
		||||
  <button
 | 
			
		||||
    className="help-icon"
 | 
			
		||||
    onClick={props.onClick}
 | 
			
		||||
    type="button"
 | 
			
		||||
    title={`${props.title} — ?`}
 | 
			
		||||
    aria-label={props.title}
 | 
			
		||||
  >
 | 
			
		||||
    {questionCircle}
 | 
			
		||||
  </button>
 | 
			
		||||
  <label title={`${props.title} — ?`} className="help-icon">
 | 
			
		||||
    <div onClick={props.onClick}>{questionCircle}</div>
 | 
			
		||||
  </label>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ import { getSelectedElements } from "../scene";
 | 
			
		||||
 | 
			
		||||
import "./HintViewer.scss";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
 | 
			
		||||
interface Hint {
 | 
			
		||||
@@ -23,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    return t("hints.linearElementMulti");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (elementType === "freedraw") {
 | 
			
		||||
  if (elementType === "draw") {
 | 
			
		||||
    return t("hints.freeDraw");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -57,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
 | 
			
		||||
    return t("hints.lineEditor_info");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
 | 
			
		||||
    return t("hints.text_selected");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (appState.editingElement && isTextElement(appState.editingElement)) {
 | 
			
		||||
    return t("hints.text_editing");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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 (
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
  .Island {
 | 
			
		||||
    --padding: 0;
 | 
			
		||||
    background-color: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: saturate(100%) blur(10px);
 | 
			
		||||
    box-shadow: var(--shadow-island);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    padding: calc(var(--padding) * var(--space-factor));
 | 
			
		||||
 
 | 
			
		||||
@@ -1,127 +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 } 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 { supported as fsSupported } from "browser-fs-access";
 | 
			
		||||
 | 
			
		||||
export type ExportCB = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  scale?: number,
 | 
			
		||||
) => void;
 | 
			
		||||
 | 
			
		||||
const JSONExportModal = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  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")}
 | 
			
		||||
              {!fsSupported && 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, canvas)}
 | 
			
		||||
            />
 | 
			
		||||
          </Card>
 | 
			
		||||
        )}
 | 
			
		||||
        {exportOpts.renderCustomUI &&
 | 
			
		||||
          exportOpts.renderCustomUI(elements, appState, canvas)}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const JSONExportDialog = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  appState,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  exportOpts,
 | 
			
		||||
  canvas,
 | 
			
		||||
}: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  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}
 | 
			
		||||
            actionManager={actionManager}
 | 
			
		||||
            onCloseRequest={handleClose}
 | 
			
		||||
            exportOpts={exportOpts}
 | 
			
		||||
            canvas={canvas}
 | 
			
		||||
          />
 | 
			
		||||
        </Dialog>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -40,17 +40,50 @@
 | 
			
		||||
  .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 {
 | 
			
		||||
@@ -72,16 +105,12 @@
 | 
			
		||||
        transform: translate(-999px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
 | 
			
		||||
      :root[dir="ltr"] &.App-menu_bottom--transition-left {
 | 
			
		||||
        transform: translate(-92px, 0);
 | 
			
		||||
      }
 | 
			
		||||
      :root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
 | 
			
		||||
      :root[dir="rtl"] &.App-menu_bottom--transition-left {
 | 
			
		||||
        transform: translate(92px, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.layer-ui__wrapper__footer-left--transition-bottom {
 | 
			
		||||
        transform: translate(0, 92px);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .disable-zen-mode {
 | 
			
		||||
@@ -108,17 +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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,33 +10,29 @@ 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,
 | 
			
		||||
  LibraryItem,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { 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, trash } from "./icons";
 | 
			
		||||
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";
 | 
			
		||||
@@ -45,9 +41,6 @@ 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";
 | 
			
		||||
 | 
			
		||||
interface LayerUIProps {
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
@@ -64,14 +57,14 @@ interface LayerUIProps {
 | 
			
		||||
  toggleZenMode: () => void;
 | 
			
		||||
  langCode: Language["code"];
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  onExportToBackend?: (
 | 
			
		||||
    exportedElements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    canvas: HTMLCanvasElement | null,
 | 
			
		||||
  ) => void;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
  viewModeEnabled: boolean;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  UIOptions: AppProps["UIOptions"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useOnClickOutside = (
 | 
			
		||||
@@ -103,44 +96,35 @@ const useOnClickOutside = (
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LibraryMenuItems = ({
 | 
			
		||||
  libraryItems,
 | 
			
		||||
  library,
 | 
			
		||||
  onRemoveFromLibrary,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  setLibraryItems,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
}: {
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
  library: LibraryItems;
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onRemoveFromLibrary: (index: number) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: (elements: LibraryItem) => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  setLibraryItems: (library: LibraryItems) => void;
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
 | 
			
		||||
  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;
 | 
			
		||||
  const referrer = libraryReturnUrl || window.location.origin;
 | 
			
		||||
 | 
			
		||||
  rows.push(
 | 
			
		||||
    <div className="layer-ui__library-header" key="library-header">
 | 
			
		||||
    <div className="layer-ui__library-header">
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        key="import"
 | 
			
		||||
        type="button"
 | 
			
		||||
@@ -148,11 +132,11 @@ const LibraryMenuItems = ({
 | 
			
		||||
        aria-label={t("buttons.load")}
 | 
			
		||||
        icon={load}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          importLibraryFromJSON(library)
 | 
			
		||||
          importLibraryFromJSON()
 | 
			
		||||
            .then(() => {
 | 
			
		||||
              // Close and then open to get the libraries updated
 | 
			
		||||
              // Maybe we should close and open the menu so that the items get updated.
 | 
			
		||||
              // But for now we just close the menu.
 | 
			
		||||
              setAppState({ isLibraryOpen: false });
 | 
			
		||||
              setAppState({ isLibraryOpen: true });
 | 
			
		||||
            })
 | 
			
		||||
            .catch(muteFSAbortError)
 | 
			
		||||
            .catch((error) => {
 | 
			
		||||
@@ -160,42 +144,36 @@ const LibraryMenuItems = ({
 | 
			
		||||
            });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {!!libraryItems.length && (
 | 
			
		||||
        <>
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            key="export"
 | 
			
		||||
            type="button"
 | 
			
		||||
            title={t("buttons.export")}
 | 
			
		||||
            aria-label={t("buttons.export")}
 | 
			
		||||
            icon={exportFile}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              saveLibraryAsJSON(library)
 | 
			
		||||
                .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([]);
 | 
			
		||||
                focusContainer();
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
      <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=${id}&theme=${theme}`}
 | 
			
		||||
        href={`https://libraries.excalidraw.com?referrer=${referrer}`}
 | 
			
		||||
        target="_excalidraw_libraries"
 | 
			
		||||
      >
 | 
			
		||||
        {t("labels.libraries")}
 | 
			
		||||
@@ -210,13 +188,13 @@ const LibraryMenuItems = ({
 | 
			
		||||
      const shouldAddPendingElements: boolean =
 | 
			
		||||
        pendingElements.length > 0 &&
 | 
			
		||||
        !addedPendingElements &&
 | 
			
		||||
        y + x >= libraryItems.length;
 | 
			
		||||
        y + x >= library.length;
 | 
			
		||||
      addedPendingElements = addedPendingElements || shouldAddPendingElements;
 | 
			
		||||
 | 
			
		||||
      children.push(
 | 
			
		||||
        <Stack.Col key={x}>
 | 
			
		||||
          <LibraryUnit
 | 
			
		||||
            elements={libraryItems[y + x]}
 | 
			
		||||
            elements={library[y + x]}
 | 
			
		||||
            pendingElements={
 | 
			
		||||
              shouldAddPendingElements ? pendingElements : undefined
 | 
			
		||||
            }
 | 
			
		||||
@@ -224,7 +202,7 @@ const LibraryMenuItems = ({
 | 
			
		||||
            onClick={
 | 
			
		||||
              shouldAddPendingElements
 | 
			
		||||
                ? onAddToLibrary.bind(null, pendingElements)
 | 
			
		||||
                : onInsertShape.bind(null, libraryItems[y + x])
 | 
			
		||||
                : onInsertShape.bind(null, library[y + x])
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </Stack.Col>,
 | 
			
		||||
@@ -249,23 +227,15 @@ const LibraryMenu = ({
 | 
			
		||||
  onInsertShape,
 | 
			
		||||
  pendingElements,
 | 
			
		||||
  onAddToLibrary,
 | 
			
		||||
  theme,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem;
 | 
			
		||||
  onClickOutside: (event: MouseEvent) => void;
 | 
			
		||||
  onInsertShape: (elements: LibraryItem) => void;
 | 
			
		||||
  onAddToLibrary: () => void;
 | 
			
		||||
  theme: AppState["theme"];
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  focusContainer: () => void;
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  useOnClickOutside(ref, (event) => {
 | 
			
		||||
@@ -291,7 +261,7 @@ const LibraryMenu = ({
 | 
			
		||||
          resolve("loading");
 | 
			
		||||
        }, 100);
 | 
			
		||||
      }),
 | 
			
		||||
      library.loadLibrary().then((items) => {
 | 
			
		||||
      Library.loadLibrary().then((items) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setIsLoading("ready");
 | 
			
		||||
      }),
 | 
			
		||||
@@ -303,33 +273,24 @@ const LibraryMenu = ({
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearTimeout(loadingTimerRef.current!);
 | 
			
		||||
    };
 | 
			
		||||
  }, [library]);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const removeFromLibrary = useCallback(
 | 
			
		||||
    async (indexToRemove) => {
 | 
			
		||||
      const items = await library.loadLibrary();
 | 
			
		||||
      const nextItems = items.filter((_, index) => index !== indexToRemove);
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [library, setAppState],
 | 
			
		||||
  );
 | 
			
		||||
  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 items = await Library.loadLibrary();
 | 
			
		||||
      const nextItems = [...items, elements];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      library.saveLibrary(nextItems).catch((error) => {
 | 
			
		||||
        setLibraryItems(items);
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      Library.saveLibrary(nextItems);
 | 
			
		||||
      setLibraryItems(nextItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary, library, setAppState],
 | 
			
		||||
    [onAddToLibrary],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return loadingState === "preloading" ? null : (
 | 
			
		||||
@@ -340,7 +301,7 @@ const LibraryMenu = ({
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LibraryMenuItems
 | 
			
		||||
          libraryItems={libraryItems}
 | 
			
		||||
          library={libraryItems}
 | 
			
		||||
          onRemoveFromLibrary={removeFromLibrary}
 | 
			
		||||
          onAddToLibrary={addToLibrary}
 | 
			
		||||
          onInsertShape={onInsertShape}
 | 
			
		||||
@@ -348,10 +309,6 @@ const LibraryMenu = ({
 | 
			
		||||
          setAppState={setAppState}
 | 
			
		||||
          setLibraryItems={setLibraryItems}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
          focusContainer={focusContainer}
 | 
			
		||||
          library={library}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
          id={id}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Island>
 | 
			
		||||
@@ -372,69 +329,69 @@ const LayerUI = ({
 | 
			
		||||
  showThemeBtn,
 | 
			
		||||
  toggleZenMode,
 | 
			
		||||
  isCollaborating,
 | 
			
		||||
  renderTopRightUI,
 | 
			
		||||
  onExportToBackend,
 | 
			
		||||
  renderCustomFooter,
 | 
			
		||||
  viewModeEnabled,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  UIOptions,
 | 
			
		||||
  focusContainer,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
}: LayerUIProps) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
  const renderJSONExportDialog = () => {
 | 
			
		||||
    if (!UIOptions.canvasActions.export) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <JSONExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        exportOpts={UIOptions.canvasActions.export}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderImageExportDialog = () => {
 | 
			
		||||
    if (!UIOptions.canvasActions.saveAsImage) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  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"
 | 
			
		||||
    >
 | 
			
		||||
      <Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
 | 
			
		||||
        {shield}
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </a>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderExportDialog = () => {
 | 
			
		||||
    const createExporter = (type: ExportType): ExportCB => async (
 | 
			
		||||
      exportedElements,
 | 
			
		||||
      scale,
 | 
			
		||||
    ) => {
 | 
			
		||||
      await exportCanvas(type, exportedElements, appState, {
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        name: appState.name,
 | 
			
		||||
        viewBackgroundColor: appState.viewBackgroundColor,
 | 
			
		||||
      })
 | 
			
		||||
        .catch(muteFSAbortError)
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error(error);
 | 
			
		||||
          setAppState({ errorMessage: error.message });
 | 
			
		||||
        });
 | 
			
		||||
      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 });
 | 
			
		||||
          });
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ImageExportDialog
 | 
			
		||||
      <ExportDialog
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        appState={appState}
 | 
			
		||||
        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
 | 
			
		||||
@@ -448,8 +405,9 @@ 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>
 | 
			
		||||
@@ -468,12 +426,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}
 | 
			
		||||
@@ -488,9 +445,6 @@ const LayerUI = ({
 | 
			
		||||
            setAppState={setAppState}
 | 
			
		||||
            showThemeBtn={showThemeBtn}
 | 
			
		||||
          />
 | 
			
		||||
          {appState.fileHandle && (
 | 
			
		||||
            <>{actionManager.renderAction("saveToActiveFile")}</>
 | 
			
		||||
          )}
 | 
			
		||||
        </Stack.Col>
 | 
			
		||||
      </Island>
 | 
			
		||||
    </Section>
 | 
			
		||||
@@ -509,8 +463,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
 | 
			
		||||
@@ -545,10 +498,6 @@ const LayerUI = ({
 | 
			
		||||
      onAddToLibrary={deselectItems}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
      libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
      focusContainer={focusContainer}
 | 
			
		||||
      library={library}
 | 
			
		||||
      theme={appState.theme}
 | 
			
		||||
      id={id}
 | 
			
		||||
    />
 | 
			
		||||
  ) : null;
 | 
			
		||||
 | 
			
		||||
@@ -575,12 +524,6 @@ const LayerUI = ({
 | 
			
		||||
              {(heading) => (
 | 
			
		||||
                <Stack.Col gap={4} align="start">
 | 
			
		||||
                  <Stack.Row gap={1}>
 | 
			
		||||
                    <LockButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
                      onChange={onLockToggle}
 | 
			
		||||
                      title={t("toolBar.lock")}
 | 
			
		||||
                    />
 | 
			
		||||
                    <Island
 | 
			
		||||
                      padding={1}
 | 
			
		||||
                      className={clsx({ "zen-mode": zenModeEnabled })}
 | 
			
		||||
@@ -592,12 +535,15 @@ const LayerUI = ({
 | 
			
		||||
                          canvas={canvas}
 | 
			
		||||
                          elementType={appState.elementType}
 | 
			
		||||
                          setAppState={setAppState}
 | 
			
		||||
                          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}
 | 
			
		||||
@@ -605,30 +551,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", 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>
 | 
			
		||||
    );
 | 
			
		||||
@@ -636,61 +576,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>
 | 
			
		||||
            </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 />}
 | 
			
		||||
@@ -701,11 +641,7 @@ const LayerUI = ({
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.showHelpDialog && (
 | 
			
		||||
        <HelpDialog
 | 
			
		||||
          onClose={() => {
 | 
			
		||||
            setAppState({ showHelpDialog: false });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
 | 
			
		||||
      )}
 | 
			
		||||
      {appState.pasteDialog.shown && (
 | 
			
		||||
        <PasteChartDialog
 | 
			
		||||
@@ -730,8 +666,7 @@ const LayerUI = ({
 | 
			
		||||
        elements={elements}
 | 
			
		||||
        actionManager={actionManager}
 | 
			
		||||
        libraryMenu={libraryMenu}
 | 
			
		||||
        renderJSONExportDialog={renderJSONExportDialog}
 | 
			
		||||
        renderImageExportDialog={renderImageExportDialog}
 | 
			
		||||
        exportButton={renderExportDialog()}
 | 
			
		||||
        setAppState={setAppState}
 | 
			
		||||
        onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
        onLockToggle={onLockToggle}
 | 
			
		||||
@@ -754,6 +689,8 @@ const LayerUI = ({
 | 
			
		||||
      {dialogs}
 | 
			
		||||
      {renderFixedSideContainer()}
 | 
			
		||||
      {renderBottomAppMenu()}
 | 
			
		||||
      {renderGitHubCorner()}
 | 
			
		||||
      {renderFooter()}
 | 
			
		||||
      {appState.scrolledOutside && (
 | 
			
		||||
        <button
 | 
			
		||||
          className="scroll-back-to-content"
 | 
			
		||||
 
 | 
			
		||||
@@ -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"];
 | 
			
		||||
}> = ({ appState, setAppState }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
 | 
			
		||||
        `ToolIcon_size_m`,
 | 
			
		||||
        {
 | 
			
		||||
          "zen-mode-visibility--hidden": appState.zenModeEnabled,
 | 
			
		||||
        },
 | 
			
		||||
      )}
 | 
			
		||||
      title={`${capitalizeString(t("toolBar.library"))} — 9`}
 | 
			
		||||
      style={{ marginInlineStart: "var(--space-factor)" }}
 | 
			
		||||
    >
 | 
			
		||||
      <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="9"
 | 
			
		||||
      />
 | 
			
		||||
      <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
 | 
			
		||||
    </label>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { close } from "../components/icons";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { exportToSvg } from "../scene/export";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
@@ -36,27 +36,22 @@ export const LibraryUnit = ({
 | 
			
		||||
    if (!elementsToRender) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    let svg: SVGSVGElement;
 | 
			
		||||
    const svg = exportToSvg(elementsToRender, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
    for (const child of ref.current!.children) {
 | 
			
		||||
      if (child.tagName !== "svg") {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      ref.current!.removeChild(child);
 | 
			
		||||
    }
 | 
			
		||||
    ref.current!.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
    const current = ref.current!;
 | 
			
		||||
 | 
			
		||||
    (async () => {
 | 
			
		||||
      svg = await exportToSvg(elementsToRender, {
 | 
			
		||||
        exportBackground: false,
 | 
			
		||||
        viewBackgroundColor: oc.white,
 | 
			
		||||
      });
 | 
			
		||||
      for (const child of ref.current!.children) {
 | 
			
		||||
        if (child.tagName !== "svg") {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
        current!.removeChild(child);
 | 
			
		||||
      }
 | 
			
		||||
      current!.appendChild(svg);
 | 
			
		||||
    })();
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (svg) {
 | 
			
		||||
        current.removeChild(svg);
 | 
			
		||||
      }
 | 
			
		||||
      current.removeChild(svg);
 | 
			
		||||
    };
 | 
			
		||||
  }, [elements, pendingElements]);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,10 @@ type LockIconSize = "s" | "m";
 | 
			
		||||
type LockIconProps = {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  checked: boolean;
 | 
			
		||||
  onChange?(): void;
 | 
			
		||||
  size?: LockIconSize;
 | 
			
		||||
  zenModeEnabled?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -39,12 +41,12 @@ const ICONS = {
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const LockButton = (props: LockIconProps) => {
 | 
			
		||||
export const LockIcon = (props: LockIconProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <label
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
 | 
			
		||||
        `ToolIcon_size_${DEFAULT_SIZE}`,
 | 
			
		||||
        `ToolIcon_size_${props.size || DEFAULT_SIZE}`,
 | 
			
		||||
        {
 | 
			
		||||
          "zen-mode-visibility--hidden": props.zenModeEnabled,
 | 
			
		||||
        },
 | 
			
		||||
@@ -55,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
 | 
			
		||||
        className="ToolIcon_type_checkbox"
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        name={props.name}
 | 
			
		||||
        id={props.id}
 | 
			
		||||
        onChange={props.onChange}
 | 
			
		||||
        checked={props.checked}
 | 
			
		||||
        aria-label={props.title}
 | 
			
		||||
@@ -13,16 +13,14 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 | 
			
		||||
import { Section } from "./Section";
 | 
			
		||||
import CollabButton from "./CollabButton";
 | 
			
		||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 | 
			
		||||
import { LockButton } from "./LockButton";
 | 
			
		||||
import { LockIcon } from "./LockIcon";
 | 
			
		||||
import { UserList } from "./UserList";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import { LibraryButton } from "./LibraryButton";
 | 
			
		||||
 | 
			
		||||
type MobileMenuProps = {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  actionManager: ActionManager;
 | 
			
		||||
  renderJSONExportDialog: () => React.ReactNode;
 | 
			
		||||
  renderImageExportDialog: () => React.ReactNode;
 | 
			
		||||
  exportButton: React.ReactNode;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  libraryMenu: JSX.Element | null;
 | 
			
		||||
@@ -30,7 +28,7 @@ type MobileMenuProps = {
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
 | 
			
		||||
  renderCustomFooter?: (isMobile: boolean) => JSX.Element;
 | 
			
		||||
  viewModeEnabled: boolean;
 | 
			
		||||
  showThemeBtn: boolean;
 | 
			
		||||
};
 | 
			
		||||
@@ -40,8 +38,7 @@ export const MobileMenu = ({
 | 
			
		||||
  elements,
 | 
			
		||||
  libraryMenu,
 | 
			
		||||
  actionManager,
 | 
			
		||||
  renderJSONExportDialog,
 | 
			
		||||
  renderImageExportDialog,
 | 
			
		||||
  exportButton,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
@@ -65,15 +62,15 @@ export const MobileMenu = ({
 | 
			
		||||
                      canvas={canvas}
 | 
			
		||||
                      elementType={appState.elementType}
 | 
			
		||||
                      setAppState={setAppState}
 | 
			
		||||
                      isLibraryOpen={appState.isLibraryOpen}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Stack.Row>
 | 
			
		||||
                </Island>
 | 
			
		||||
                <LockButton
 | 
			
		||||
                <LockIcon
 | 
			
		||||
                  checked={appState.elementLocked}
 | 
			
		||||
                  onChange={onLockToggle}
 | 
			
		||||
                  title={t("toolBar.lock")}
 | 
			
		||||
                />
 | 
			
		||||
                <LibraryButton appState={appState} setAppState={setAppState} />
 | 
			
		||||
              </Stack.Row>
 | 
			
		||||
              {libraryMenu}
 | 
			
		||||
            </Stack.Col>
 | 
			
		||||
@@ -110,17 +107,19 @@ export const MobileMenu = ({
 | 
			
		||||
    if (viewModeEnabled) {
 | 
			
		||||
      return (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderJSONExportDialog()}
 | 
			
		||||
          {renderImageExportDialog()}
 | 
			
		||||
          {actionManager.renderAction("saveScene")}
 | 
			
		||||
          {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
          {exportButton}
 | 
			
		||||
        </>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {actionManager.renderAction("clearCanvas")}
 | 
			
		||||
        {actionManager.renderAction("loadScene")}
 | 
			
		||||
        {renderJSONExportDialog()}
 | 
			
		||||
        {renderImageExportDialog()}
 | 
			
		||||
        {actionManager.renderAction("saveScene")}
 | 
			
		||||
        {actionManager.renderAction("saveAsScene")}
 | 
			
		||||
        {exportButton}
 | 
			
		||||
        {actionManager.renderAction("clearCanvas")}
 | 
			
		||||
        {onCollabButtonClick && (
 | 
			
		||||
          <CollabButton
 | 
			
		||||
            isCollaborating={isCollaborating}
 | 
			
		||||
@@ -156,7 +155,7 @@ export const MobileMenu = ({
 | 
			
		||||
              <div className="panelColumn">
 | 
			
		||||
                <Stack.Col gap={4}>
 | 
			
		||||
                  {renderCanvasActions()}
 | 
			
		||||
                  {renderCustomFooter?.(true, appState)}
 | 
			
		||||
                  {renderCustomFooter?.(true)}
 | 
			
		||||
                  {appState.collaborators.size > 0 && (
 | 
			
		||||
                    <fieldset>
 | 
			
		||||
                      <legend>{t("labels.collaborators")}</legend>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,8 @@
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    z-index: 1;
 | 
			
		||||
    background-color: transparentize($oc-black, 0.3);
 | 
			
		||||
    background-color: transparentize($oc-black, 0.7);
 | 
			
		||||
    backdrop-filter: blur(2px);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Modal__content {
 | 
			
		||||
@@ -44,17 +45,14 @@
 | 
			
		||||
 | 
			
		||||
    // for modals, reset blurry bg
 | 
			
		||||
    background: var(--island-bg-color);
 | 
			
		||||
    backdrop-filter: none;
 | 
			
		||||
 | 
			
		||||
    border: 1px solid var(--dialog-border-color);
 | 
			
		||||
    box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
 | 
			
		||||
    border-radius: 6px;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      max-width: 100%;
 | 
			
		||||
      border: 0;
 | 
			
		||||
      border-radius: 0;
 | 
			
		||||
@@ -84,7 +82,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    .Modal {
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,9 @@
 | 
			
		||||
import "./Modal.scss";
 | 
			
		||||
 | 
			
		||||
import React, { useState, useLayoutEffect, useRef } from "react";
 | 
			
		||||
import React, { useState, useLayoutEffect } from "react";
 | 
			
		||||
import { createPortal } from "react-dom";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { useExcalidrawContainer, useIsMobile } from "./App";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const Modal = (props: {
 | 
			
		||||
  className?: string;
 | 
			
		||||
@@ -13,10 +11,8 @@ export const Modal = (props: {
 | 
			
		||||
  maxWidth?: number;
 | 
			
		||||
  onCloseRequest(): void;
 | 
			
		||||
  labelledBy: string;
 | 
			
		||||
  theme?: AppState["theme"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const { theme = "light" } = props;
 | 
			
		||||
  const modalRoot = useBodyRoot(theme);
 | 
			
		||||
  const modalRoot = useBodyRoot();
 | 
			
		||||
 | 
			
		||||
  if (!modalRoot) {
 | 
			
		||||
    return null;
 | 
			
		||||
@@ -25,7 +21,6 @@ export const Modal = (props: {
 | 
			
		||||
  const handleKeydown = (event: React.KeyboardEvent) => {
 | 
			
		||||
    if (event.key === KEYS.ESCAPE) {
 | 
			
		||||
      event.nativeEvent.stopImmediatePropagation();
 | 
			
		||||
      event.stopPropagation();
 | 
			
		||||
      props.onCloseRequest();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
@@ -42,7 +37,6 @@ export const Modal = (props: {
 | 
			
		||||
      <div
 | 
			
		||||
        className="Modal__content"
 | 
			
		||||
        style={{ "--max-width": `${props.maxWidth}px` }}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -51,29 +45,16 @@ export const Modal = (props: {
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const useBodyRoot = (theme: AppState["theme"]) => {
 | 
			
		||||
const useBodyRoot = () => {
 | 
			
		||||
  const [div, setDiv] = useState<HTMLDivElement | null>(null);
 | 
			
		||||
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const isMobileRef = useRef(isMobile);
 | 
			
		||||
  isMobileRef.current = isMobile;
 | 
			
		||||
 | 
			
		||||
  const { container: excalidrawContainer } = useExcalidrawContainer();
 | 
			
		||||
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    if (div) {
 | 
			
		||||
      div.classList.toggle("excalidraw--mobile", isMobile);
 | 
			
		||||
    }
 | 
			
		||||
  }, [div, isMobile]);
 | 
			
		||||
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    const isDarkTheme =
 | 
			
		||||
      !!excalidrawContainer?.classList.contains("theme--dark") ||
 | 
			
		||||
      theme === "dark";
 | 
			
		||||
    const isDarkTheme = !!document
 | 
			
		||||
      .querySelector(".excalidraw")
 | 
			
		||||
      ?.classList.contains("theme--dark");
 | 
			
		||||
    const div = document.createElement("div");
 | 
			
		||||
 | 
			
		||||
    div.classList.add("excalidraw", "excalidraw-modal-container");
 | 
			
		||||
    div.classList.toggle("excalidraw--mobile", isMobileRef.current);
 | 
			
		||||
 | 
			
		||||
    if (isDarkTheme) {
 | 
			
		||||
      div.classList.add("theme--dark");
 | 
			
		||||
@@ -86,7 +67,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.body.removeChild(div);
 | 
			
		||||
    };
 | 
			
		||||
  }, [excalidrawContainer, theme]);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return div;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .PasteChartDialog {
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      .Island {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
@@ -13,7 +13,7 @@
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: space-around;
 | 
			
		||||
      flex-wrap: wrap;
 | 
			
		||||
      @include isMobile {
 | 
			
		||||
      @media #{$is-mobile-query} {
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -34,21 +34,20 @@ const ChartPreviewBtn = (props: {
 | 
			
		||||
      0,
 | 
			
		||||
    );
 | 
			
		||||
    setChartElements(elements);
 | 
			
		||||
    let svg: SVGSVGElement;
 | 
			
		||||
 | 
			
		||||
    const svg = exportToSvg(elements, {
 | 
			
		||||
      exportBackground: false,
 | 
			
		||||
      viewBackgroundColor: oc.white,
 | 
			
		||||
      shouldAddWatermark: false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const previewNode = previewRef.current!;
 | 
			
		||||
 | 
			
		||||
    (async () => {
 | 
			
		||||
      svg = await exportToSvg(elements, {
 | 
			
		||||
        exportBackground: false,
 | 
			
		||||
        viewBackgroundColor: oc.white,
 | 
			
		||||
      });
 | 
			
		||||
    previewNode.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
      previewNode.appendChild(svg);
 | 
			
		||||
 | 
			
		||||
      if (props.selected) {
 | 
			
		||||
        (previewNode.parentNode as HTMLDivElement).focus();
 | 
			
		||||
      }
 | 
			
		||||
    })();
 | 
			
		||||
    if (props.selected) {
 | 
			
		||||
      (previewNode.parentNode as HTMLDivElement).focus();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      previewNode.removeChild(svg);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .popover {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +0,0 @@
 | 
			
		||||
.ProjectName {
 | 
			
		||||
  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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,10 +1,7 @@
 | 
			
		||||
import "./TextInput.scss";
 | 
			
		||||
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { focusNearestParent } from "../utils";
 | 
			
		||||
 | 
			
		||||
import "./ProjectName.scss";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
import React, { Component } from "react";
 | 
			
		||||
import { selectNode, removeSelection } from "../utils";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  value: string;
 | 
			
		||||
@@ -13,19 +10,20 @@ type Props = {
 | 
			
		||||
  isNameEditable: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ProjectName = (props: Props) => {
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
  const [fileName, setFileName] = useState<string>(props.value);
 | 
			
		||||
 | 
			
		||||
  const handleBlur = (event: any) => {
 | 
			
		||||
    focusNearestParent(event.target);
 | 
			
		||||
    const value = event.target.value;
 | 
			
		||||
    if (value !== props.value) {
 | 
			
		||||
      props.onChange(value);
 | 
			
		||||
    }
 | 
			
		||||
export class ProjectName extends Component<Props> {
 | 
			
		||||
  private handleFocus = (event: React.FocusEvent<HTMLElement>) => {
 | 
			
		||||
    selectNode(event.currentTarget);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
 | 
			
		||||
  private handleBlur = (event: React.FocusEvent<HTMLElement>) => {
 | 
			
		||||
    const value = event.currentTarget.innerText.trim();
 | 
			
		||||
    if (value !== this.props.value) {
 | 
			
		||||
      this.props.onChange(value);
 | 
			
		||||
    }
 | 
			
		||||
    removeSelection();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
 | 
			
		||||
    if (event.key === "Enter") {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      if (event.nativeEvent.isComposing || event.keyCode === 229) {
 | 
			
		||||
@@ -34,26 +32,39 @@ export const ProjectName = (props: Props) => {
 | 
			
		||||
      event.currentTarget.blur();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  private makeEditable = (editable: HTMLSpanElement | null) => {
 | 
			
		||||
    if (!editable) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      editable.contentEditable = "plaintext-only";
 | 
			
		||||
    } catch {
 | 
			
		||||
      editable.contentEditable = "true";
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ProjectName">
 | 
			
		||||
      <label className="ProjectName-label" htmlFor="filename">
 | 
			
		||||
        {`${props.label}${props.isNameEditable ? "" : ":"}`}
 | 
			
		||||
      </label>
 | 
			
		||||
      {props.isNameEditable ? (
 | 
			
		||||
        <input
 | 
			
		||||
          className="TextInput"
 | 
			
		||||
          onBlur={handleBlur}
 | 
			
		||||
          onKeyDown={handleKeyDown}
 | 
			
		||||
          id={`${id}-filename`}
 | 
			
		||||
          value={fileName}
 | 
			
		||||
          onChange={(event) => setFileName(event.target.value)}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <span className="TextInput TextInput--readonly" id={`${id}-filename`}>
 | 
			
		||||
          {props.value}
 | 
			
		||||
        </span>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
  public render() {
 | 
			
		||||
    return this.props.isNameEditable ? (
 | 
			
		||||
      <span
 | 
			
		||||
        suppressContentEditableWarning
 | 
			
		||||
        ref={this.makeEditable}
 | 
			
		||||
        data-type="wysiwyg"
 | 
			
		||||
        className="TextInput"
 | 
			
		||||
        role="textbox"
 | 
			
		||||
        aria-label={this.props.label}
 | 
			
		||||
        onBlur={this.handleBlur}
 | 
			
		||||
        onKeyDown={this.handleKeyDown}
 | 
			
		||||
        onFocus={this.handleFocus}
 | 
			
		||||
      >
 | 
			
		||||
        {this.props.value}
 | 
			
		||||
      </span>
 | 
			
		||||
    ) : (
 | 
			
		||||
      <span
 | 
			
		||||
        className="TextInput TextInput--readonly"
 | 
			
		||||
        aria-label={this.props.label}
 | 
			
		||||
      >
 | 
			
		||||
        {this.props.value}
 | 
			
		||||
      </span>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
 | 
			
		||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
 | 
			
		||||
  heading: string;
 | 
			
		||||
@@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
 | 
			
		||||
  const { id } = useExcalidrawContainer();
 | 
			
		||||
  const header = (
 | 
			
		||||
    <h2 className="visually-hidden" id={`${id}-${heading}-title`}>
 | 
			
		||||
    <h2 className="visually-hidden" id={`${heading}-title`}>
 | 
			
		||||
      {t(`headings.${heading}`)}
 | 
			
		||||
    </h2>
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    <section {...props} aria-labelledby={`${id}-${heading}-title`}>
 | 
			
		||||
    <section {...props} aria-labelledby={`${heading}-title`}>
 | 
			
		||||
      {typeof children === "function" ? (
 | 
			
		||||
        children(header)
 | 
			
		||||
      ) : (
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
    top: 64px;
 | 
			
		||||
    right: 12px;
 | 
			
		||||
    font-size: 12px;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    z-index: 999;
 | 
			
		||||
 | 
			
		||||
    h3 {
 | 
			
		||||
      margin: 0 24px 8px 0;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,49 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import { copyTextToSystemClipboard } from "../clipboard";
 | 
			
		||||
import { DEFAULT_VERSION } from "../constants";
 | 
			
		||||
import { getCommonBounds } from "../element/bounds";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import {
 | 
			
		||||
  getElementsStorageSize,
 | 
			
		||||
  getTotalStorageSize,
 | 
			
		||||
} from "../excalidraw-app/data/localStorage";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useIsMobile } from "../components/App";
 | 
			
		||||
import useIsMobile from "../is-mobile";
 | 
			
		||||
import { getTargetElements } from "../scene";
 | 
			
		||||
import { AppState, ExcalidrawProps } from "../types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { debounce, getVersion, nFormatter } from "../utils";
 | 
			
		||||
import { close } from "./icons";
 | 
			
		||||
import { Island } from "./Island";
 | 
			
		||||
import "./Stats.scss";
 | 
			
		||||
 | 
			
		||||
type StorageSizes = { scene: number; total: number };
 | 
			
		||||
 | 
			
		||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
 | 
			
		||||
  cb({
 | 
			
		||||
    scene: getElementsStorageSize(),
 | 
			
		||||
    total: getTotalStorageSize(),
 | 
			
		||||
  });
 | 
			
		||||
}, 500);
 | 
			
		||||
 | 
			
		||||
export const Stats = (props: {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  renderCustomStats: ExcalidrawProps["renderCustomStats"];
 | 
			
		||||
}) => {
 | 
			
		||||
  const isMobile = useIsMobile();
 | 
			
		||||
  const [storageSizes, setStorageSizes] = useState<StorageSizes>({
 | 
			
		||||
    scene: 0,
 | 
			
		||||
    total: 0,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    getStorageSizes((sizes) => {
 | 
			
		||||
      setStorageSizes(sizes);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => () => getStorageSizes.cancel(), []);
 | 
			
		||||
 | 
			
		||||
  const boundingBox = getCommonBounds(props.elements);
 | 
			
		||||
  const selectedElements = getTargetElements(props.elements, props.appState);
 | 
			
		||||
@@ -26,6 +53,17 @@ export const Stats = (props: {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const version = getVersion();
 | 
			
		||||
  let hash;
 | 
			
		||||
  let timestamp;
 | 
			
		||||
 | 
			
		||||
  if (version !== DEFAULT_VERSION) {
 | 
			
		||||
    timestamp = version.slice(0, 16).replace("T", " ");
 | 
			
		||||
    hash = version.slice(21);
 | 
			
		||||
  } else {
 | 
			
		||||
    timestamp = t("stats.versionNotAvailable");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="Stats">
 | 
			
		||||
      <Island padding={2}>
 | 
			
		||||
@@ -50,7 +88,17 @@ export const Stats = (props: {
 | 
			
		||||
              <td>{t("stats.height")}</td>
 | 
			
		||||
              <td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
 | 
			
		||||
            <tr>
 | 
			
		||||
              <th colSpan={2}>{t("stats.storage")}</th>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td>{t("stats.scene")}</td>
 | 
			
		||||
              <td>{nFormatter(storageSizes.scene, 1)}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td>{t("stats.total")}</td>
 | 
			
		||||
              <td>{nFormatter(storageSizes.total, 1)}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            {selectedElements.length === 1 && (
 | 
			
		||||
              <tr>
 | 
			
		||||
                <th colSpan={2}>{t("stats.element")}</th>
 | 
			
		||||
@@ -72,17 +120,31 @@ export const Stats = (props: {
 | 
			
		||||
              <>
 | 
			
		||||
                <tr>
 | 
			
		||||
                  <td>{"x"}</td>
 | 
			
		||||
                  <td>{Math.round(selectedBoundingBox[0])}</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    {Math.round(
 | 
			
		||||
                      selectedElements.length === 1
 | 
			
		||||
                        ? selectedElements[0].x
 | 
			
		||||
                        : selectedBoundingBox[0],
 | 
			
		||||
                    )}
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                <tr>
 | 
			
		||||
                  <td>{"y"}</td>
 | 
			
		||||
                  <td>{Math.round(selectedBoundingBox[1])}</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    {Math.round(
 | 
			
		||||
                      selectedElements.length === 1
 | 
			
		||||
                        ? selectedElements[0].y
 | 
			
		||||
                        : selectedBoundingBox[1],
 | 
			
		||||
                    )}
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                <tr>
 | 
			
		||||
                  <td>{t("stats.width")}</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    {Math.round(
 | 
			
		||||
                      selectedBoundingBox[2] - selectedBoundingBox[0],
 | 
			
		||||
                      selectedElements.length === 1
 | 
			
		||||
                        ? selectedElements[0].width
 | 
			
		||||
                        : selectedBoundingBox[2] - selectedBoundingBox[0],
 | 
			
		||||
                    )}
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
@@ -90,7 +152,9 @@ export const Stats = (props: {
 | 
			
		||||
                  <td>{t("stats.height")}</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    {Math.round(
 | 
			
		||||
                      selectedBoundingBox[3] - selectedBoundingBox[1],
 | 
			
		||||
                      selectedElements.length === 1
 | 
			
		||||
                        ? selectedElements[0].height
 | 
			
		||||
                        : selectedBoundingBox[3] - selectedBoundingBox[1],
 | 
			
		||||
                    )}
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
@@ -106,7 +170,28 @@ export const Stats = (props: {
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            )}
 | 
			
		||||
            {props.renderCustomStats?.(props.elements, props.appState)}
 | 
			
		||||
            <tr>
 | 
			
		||||
              <th colSpan={2}>{t("stats.version")}</th>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td
 | 
			
		||||
                colSpan={2}
 | 
			
		||||
                style={{ textAlign: "center", cursor: "pointer" }}
 | 
			
		||||
                onClick={async () => {
 | 
			
		||||
                  try {
 | 
			
		||||
                    await copyTextToSystemClipboard(getVersion());
 | 
			
		||||
                    props.setAppState({
 | 
			
		||||
                      toastMessage: t("toast.copyToClipboard"),
 | 
			
		||||
                    });
 | 
			
		||||
                  } catch {}
 | 
			
		||||
                }}
 | 
			
		||||
                title={t("stats.versionCopy")}
 | 
			
		||||
              >
 | 
			
		||||
                {timestamp}
 | 
			
		||||
                <br />
 | 
			
		||||
                {hash}
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
      </Island>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ import "./ToolIcon.scss";
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { useExcalidrawContainer } from "./App";
 | 
			
		||||
 | 
			
		||||
type ToolIconSize = "s" | "m";
 | 
			
		||||
 | 
			
		||||
@@ -30,13 +29,9 @@ type ToolButtonProps =
 | 
			
		||||
      children?: React.ReactNode;
 | 
			
		||||
      onClick?(): void;
 | 
			
		||||
    })
 | 
			
		||||
  | (ToolButtonBaseProps & {
 | 
			
		||||
      type: "icon";
 | 
			
		||||
      children?: React.ReactNode;
 | 
			
		||||
      onClick?(): void;
 | 
			
		||||
    })
 | 
			
		||||
  | (ToolButtonBaseProps & {
 | 
			
		||||
      type: "radio";
 | 
			
		||||
 | 
			
		||||
      checked: boolean;
 | 
			
		||||
      onChange?(): void;
 | 
			
		||||
    });
 | 
			
		||||
@@ -44,12 +39,11 @@ type ToolButtonProps =
 | 
			
		||||
const DEFAULT_SIZE: ToolIconSize = "m";
 | 
			
		||||
 | 
			
		||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
  const { id: excalId } = useExcalidrawContainer();
 | 
			
		||||
  const innerRef = React.useRef(null);
 | 
			
		||||
  React.useImperativeHandle(ref, () => innerRef.current);
 | 
			
		||||
  const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
 | 
			
		||||
 | 
			
		||||
  if (props.type === "button" || props.type === "icon") {
 | 
			
		||||
  if (props.type === "button") {
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        className={clsx(
 | 
			
		||||
@@ -62,7 +56,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
          {
 | 
			
		||||
            ToolIcon: !props.hidden,
 | 
			
		||||
            "ToolIcon--selected": props.selected,
 | 
			
		||||
            "ToolIcon--plain": props.type === "icon",
 | 
			
		||||
          },
 | 
			
		||||
        )}
 | 
			
		||||
        data-testid={props["data-testid"]}
 | 
			
		||||
@@ -73,16 +66,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
        onClick={props.onClick}
 | 
			
		||||
        ref={innerRef}
 | 
			
		||||
      >
 | 
			
		||||
        {(props.icon || props.label) && (
 | 
			
		||||
          <div className="ToolIcon__icon" aria-hidden="true">
 | 
			
		||||
            {props.icon || props.label}
 | 
			
		||||
            {props.keyBindingLabel && (
 | 
			
		||||
              <span className="ToolIcon__keybinding">
 | 
			
		||||
                {props.keyBindingLabel}
 | 
			
		||||
              </span>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        <div className="ToolIcon__icon" aria-hidden="true">
 | 
			
		||||
          {props.icon || props.label}
 | 
			
		||||
          {props.keyBindingLabel && (
 | 
			
		||||
            <span className="ToolIcon__keybinding">
 | 
			
		||||
              {props.keyBindingLabel}
 | 
			
		||||
            </span>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
        {props.showAriaLabel && (
 | 
			
		||||
          <div className="ToolIcon__label">{props["aria-label"]}</div>
 | 
			
		||||
        )}
 | 
			
		||||
@@ -100,7 +91,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
        aria-label={props["aria-label"]}
 | 
			
		||||
        aria-keyshortcuts={props["aria-keyshortcuts"]}
 | 
			
		||||
        data-testid={props["data-testid"]}
 | 
			
		||||
        id={`${excalId}-${props.id}`}
 | 
			
		||||
        id={props.id}
 | 
			
		||||
        onChange={props.onChange}
 | 
			
		||||
        checked={props.checked}
 | 
			
		||||
        ref={innerRef}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,26 +8,9 @@
 | 
			
		||||
    position: relative;
 | 
			
		||||
    font-family: Cascadia;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    background-color: var(--button-gray-1);
 | 
			
		||||
    -webkit-tap-highlight-color: transparent;
 | 
			
		||||
    border-radius: var(--space-factor);
 | 
			
		||||
    user-select: none;
 | 
			
		||||
 | 
			
		||||
    background-color: var(--button-gray-1);
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
    }
 | 
			
		||||
    &:active {
 | 
			
		||||
      background-color: var(--button-gray-3);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon--plain {
 | 
			
		||||
    background-color: transparent;
 | 
			
		||||
    .ToolIcon__icon {
 | 
			
		||||
      width: 2rem;
 | 
			
		||||
      height: 2rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon__icon {
 | 
			
		||||
@@ -74,6 +57,14 @@
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: inherit;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: var(--button-gray-1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:active {
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--focus-highlight-color);
 | 
			
		||||
    }
 | 
			
		||||
@@ -86,14 +77,6 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:active {
 | 
			
		||||
      background-color: var(--button-gray-3);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--show {
 | 
			
		||||
      visibility: visible;
 | 
			
		||||
    }
 | 
			
		||||
@@ -111,9 +94,6 @@
 | 
			
		||||
 | 
			
		||||
    &:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
 | 
			
		||||
      background-color: var(--button-gray-2);
 | 
			
		||||
      &:active {
 | 
			
		||||
        background-color: var(--button-gray-3);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus + .ToolIcon__icon {
 | 
			
		||||
@@ -141,21 +121,12 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon__icon {
 | 
			
		||||
      background-color: var(--button-gray-1);
 | 
			
		||||
      &:hover {
 | 
			
		||||
        background-color: var(--button-gray-2);
 | 
			
		||||
      }
 | 
			
		||||
      &:active {
 | 
			
		||||
        background-color: var(--button-gray-3);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      width: 2rem;
 | 
			
		||||
      height: 2em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .ToolIcon.ToolIcon__lock {
 | 
			
		||||
    margin-inline-end: var(--space-factor);
 | 
			
		||||
    &.ToolIcon_type_floating {
 | 
			
		||||
      margin-left: 0.1rem;
 | 
			
		||||
    }
 | 
			
		||||
@@ -186,9 +157,10 @@
 | 
			
		||||
  // move the lock button out of the way on small viewports
 | 
			
		||||
  // it begins to collide with the GitHub icon before we switch to mobile mode
 | 
			
		||||
  @media (max-width: 760px) {
 | 
			
		||||
    .ToolIcon.ToolIcon_type_floating {
 | 
			
		||||
    .ToolIcon.ToolIcon__lock {
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: 60px;
 | 
			
		||||
      right: -8px;
 | 
			
		||||
 | 
			
		||||
      margin-left: 0;
 | 
			
		||||
@@ -213,13 +185,16 @@
 | 
			
		||||
        position: static;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    .ToolIcon.ToolIcon__library {
 | 
			
		||||
      top: 100px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    .ToolIcon.ToolIcon__lock {
 | 
			
		||||
      margin-inline-end: 0;
 | 
			
		||||
      top: 60px;
 | 
			
		||||
  .TooltipIcon {
 | 
			
		||||
    width: 0.9em;
 | 
			
		||||
    height: 0.9em;
 | 
			
		||||
    margin-left: 5px;
 | 
			
		||||
    margin-top: 1px;
 | 
			
		||||
 | 
			
		||||
    @media #{$is-mobile-query} {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,39 +1,58 @@
 | 
			
		||||
@import "../css/variables.module";
 | 
			
		||||
.excalidraw-tooltip {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
 | 
			
		||||
  padding: 8px;
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  word-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
  background: $oc-black;
 | 
			
		||||
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  color: $oc-white;
 | 
			
		||||
 | 
			
		||||
  display: none;
 | 
			
		||||
 | 
			
		||||
  &.excalidraw-tooltip--visible {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  .Tooltip-icon {
 | 
			
		||||
    width: 0.9em;
 | 
			
		||||
    height: 0.9em;
 | 
			
		||||
    margin-left: 5px;
 | 
			
		||||
    margin-top: 1px;
 | 
			
		||||
    display: flex;
 | 
			
		||||
  .Tooltip {
 | 
			
		||||
    position: relative;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      display: none;
 | 
			
		||||
  .Tooltip__label {
 | 
			
		||||
    --arrow-size: 4px;
 | 
			
		||||
    visibility: hidden;
 | 
			
		||||
    background: $oc-black;
 | 
			
		||||
    color: $oc-white;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    border-radius: 6px;
 | 
			
		||||
    padding: 8px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    font-size: 13px;
 | 
			
		||||
    line-height: 1.5;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
    // extra pixel offset for unknown reasons
 | 
			
		||||
    left: calc(50% + var(--arrow-size) / 2 - 1px);
 | 
			
		||||
    transform: translateX(-50%);
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
    &::after {
 | 
			
		||||
      content: "";
 | 
			
		||||
      border: var(--arrow-size) solid transparent;
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      left: calc(50% - var(--arrow-size));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--above {
 | 
			
		||||
      bottom: calc(100% + var(--arrow-size) + 3px);
 | 
			
		||||
 | 
			
		||||
      &::after {
 | 
			
		||||
        border-top-color: $oc-black;
 | 
			
		||||
        top: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--below {
 | 
			
		||||
      top: calc(100% + var(--arrow-size) + 3px);
 | 
			
		||||
 | 
			
		||||
      &::after {
 | 
			
		||||
        border-bottom-color: $oc-black;
 | 
			
		||||
        bottom: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Tooltip:hover .Tooltip__label {
 | 
			
		||||
    visibility: visible;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .Tooltip__label:hover {
 | 
			
		||||
    visibility: visible;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,92 +1,31 @@
 | 
			
		||||
import "./Tooltip.scss";
 | 
			
		||||
 | 
			
		||||
import React, { useEffect } from "react";
 | 
			
		||||
 | 
			
		||||
const getTooltipDiv = () => {
 | 
			
		||||
  const existingDiv = document.querySelector<HTMLDivElement>(
 | 
			
		||||
    ".excalidraw-tooltip",
 | 
			
		||||
  );
 | 
			
		||||
  if (existingDiv) {
 | 
			
		||||
    return existingDiv;
 | 
			
		||||
  }
 | 
			
		||||
  const div = document.createElement("div");
 | 
			
		||||
  document.body.appendChild(div);
 | 
			
		||||
  div.classList.add("excalidraw-tooltip");
 | 
			
		||||
  return div;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateTooltip = (
 | 
			
		||||
  item: HTMLDivElement,
 | 
			
		||||
  tooltip: HTMLDivElement,
 | 
			
		||||
  label: string,
 | 
			
		||||
  long: boolean,
 | 
			
		||||
) => {
 | 
			
		||||
  tooltip.classList.add("excalidraw-tooltip--visible");
 | 
			
		||||
  tooltip.style.minWidth = long ? "50ch" : "10ch";
 | 
			
		||||
  tooltip.style.maxWidth = long ? "50ch" : "15ch";
 | 
			
		||||
 | 
			
		||||
  tooltip.textContent = label;
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    x: itemX,
 | 
			
		||||
    bottom: itemBottom,
 | 
			
		||||
    top: itemTop,
 | 
			
		||||
    width: itemWidth,
 | 
			
		||||
  } = item.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    width: labelWidth,
 | 
			
		||||
    height: labelHeight,
 | 
			
		||||
  } = tooltip.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
  const viewportWidth = window.innerWidth;
 | 
			
		||||
  const viewportHeight = window.innerHeight;
 | 
			
		||||
 | 
			
		||||
  const margin = 5;
 | 
			
		||||
 | 
			
		||||
  const left = itemX + itemWidth / 2 - labelWidth / 2;
 | 
			
		||||
  const offsetLeft =
 | 
			
		||||
    left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
 | 
			
		||||
 | 
			
		||||
  const top = itemBottom + margin;
 | 
			
		||||
  const offsetTop =
 | 
			
		||||
    top + labelHeight >= viewportHeight
 | 
			
		||||
      ? itemBottom - itemTop + labelHeight + margin * 2
 | 
			
		||||
      : 0;
 | 
			
		||||
 | 
			
		||||
  Object.assign(tooltip.style, {
 | 
			
		||||
    top: `${top - offsetTop}px`,
 | 
			
		||||
    left: `${left - offsetLeft}px`,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
type TooltipProps = {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  label: string;
 | 
			
		||||
  position?: "above" | "below";
 | 
			
		||||
  long?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    return () =>
 | 
			
		||||
      getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      onPointerEnter={(event) =>
 | 
			
		||||
        updateTooltip(
 | 
			
		||||
          event.currentTarget as HTMLDivElement,
 | 
			
		||||
          getTooltipDiv(),
 | 
			
		||||
          label,
 | 
			
		||||
          long,
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      onPointerLeave={() =>
 | 
			
		||||
        getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
 | 
			
		||||
export const Tooltip = ({
 | 
			
		||||
  children,
 | 
			
		||||
  label,
 | 
			
		||||
  position = "below",
 | 
			
		||||
  long = false,
 | 
			
		||||
}: TooltipProps) => (
 | 
			
		||||
  <div className="Tooltip">
 | 
			
		||||
    <span
 | 
			
		||||
      className={
 | 
			
		||||
        position === "above"
 | 
			
		||||
          ? "Tooltip__label Tooltip__label--above"
 | 
			
		||||
          : "Tooltip__label Tooltip__label--below"
 | 
			
		||||
      }
 | 
			
		||||
      style={{ width: long ? "50ch" : "10ch" }}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
      {label}
 | 
			
		||||
    </span>
 | 
			
		||||
    {children}
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,7 @@
 | 
			
		||||
  .UserList {
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
    /*github corner*/
 | 
			
		||||
    padding: var(--space-factor) var(--space-factor) var(--space-factor)
 | 
			
		||||
      var(--space-factor);
 | 
			
		||||
    padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    justify-content: flex-end;
 | 
			
		||||
 
 | 
			
		||||
@@ -24,10 +24,7 @@ type Opts = {
 | 
			
		||||
  mirror?: true;
 | 
			
		||||
} & React.SVGProps<SVGSVGElement>;
 | 
			
		||||
 | 
			
		||||
export const createIcon = (
 | 
			
		||||
  d: string | React.ReactNode,
 | 
			
		||||
  opts: number | Opts = 512,
 | 
			
		||||
) => {
 | 
			
		||||
const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => {
 | 
			
		||||
  const { width = 512, height = width, mirror, style } =
 | 
			
		||||
    typeof opts === "number" ? ({ width: opts } as Opts) : opts;
 | 
			
		||||
  return (
 | 
			
		||||
@@ -44,14 +41,6 @@ export const createIcon = (
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const checkIcon = createIcon(
 | 
			
		||||
  <polyline fill="none" stroke="currentColor" points="20 6 9 17 4 12" />,
 | 
			
		||||
  {
 | 
			
		||||
    width: 24,
 | 
			
		||||
    height: 24,
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const link = createIcon(
 | 
			
		||||
  "M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z",
 | 
			
		||||
  { mirror: true },
 | 
			
		||||
@@ -91,19 +80,6 @@ export const exportFile = createIcon(
 | 
			
		||||
  { width: 576, height: 512, mirror: true },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const exportImage = createIcon(
 | 
			
		||||
  <>
 | 
			
		||||
    <path d="M571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-187 44v-64 64z" />
 | 
			
		||||
    <path d="M384 121.941V128H256V0h6.059c6.362 0 12.471 2.53 16.97 7.029l97.941 97.941a24.01 24.01 0 017.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z" />
 | 
			
		||||
  </>,
 | 
			
		||||
  { width: 576, height: 512, mirror: true },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const exportToFileIcon = createIcon(
 | 
			
		||||
  "M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z",
 | 
			
		||||
  { width: 512, height: 512 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const zoomIn = createIcon(
 | 
			
		||||
  "M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z",
 | 
			
		||||
  { width: 448, height: 512 },
 | 
			
		||||
@@ -147,22 +123,6 @@ export const shareIOS = createIcon(
 | 
			
		||||
  { width: 24, height: 24 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const shareWindows = createIcon(
 | 
			
		||||
  <>
 | 
			
		||||
    <path
 | 
			
		||||
      stroke="currentColor"
 | 
			
		||||
      fill="currentColor"
 | 
			
		||||
      d="M40 5.6v6.1l-4.1.7c-8.9 1.4-16.5 6.9-20.6 15C13 32 10.9 43 12.4 43c.4 0 2.4-1.3 4.4-3 5-3.9 12.1-7 18.2-7.7l5-.6v12.8l11.2-11.3L62.5 22 51.2 10.8 40-.5v6.1zm10.2 22.6L44 34.5v-6.8l-6.9.6c-3.9.3-9.8 1.7-13.2 3.1-3.5 1.4-6.5 2.4-6.7 2.2-.9-1 3-7.5 6.4-10.8C28 18.6 34.4 16 40.1 16c3.7 0 3.9-.1 3.9-3.2V9.5l6.2 6.3 6.3 6.2-6.3 6.2z"
 | 
			
		||||
    />
 | 
			
		||||
    <path
 | 
			
		||||
      stroke="currentColor"
 | 
			
		||||
      fill="currentColor"
 | 
			
		||||
      d="M0 36v20h48v-6.2c0-6 0-6.1-2-4.3-1.1 1-2 2.9-2 4.2V52H4V34c0-17.3-.1-18-2-18s-2 .7-2 20z"
 | 
			
		||||
    />
 | 
			
		||||
  </>,
 | 
			
		||||
  { width: 64, height: 64 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Icon imported form Storybook
 | 
			
		||||
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
 | 
			
		||||
export const resetZoom = createIcon(
 | 
			
		||||
@@ -246,12 +206,14 @@ export const SendToBackIcon = React.memo(
 | 
			
		||||
          d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
 | 
			
		||||
          fill={activeElementColor(theme)}
 | 
			
		||||
          stroke={activeElementColor(theme)}
 | 
			
		||||
          strokeLinejoin="round"
 | 
			
		||||
          strokeWidth="2"
 | 
			
		||||
        />
 | 
			
		||||
        <path
 | 
			
		||||
          d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
 | 
			
		||||
          fill={iconFillColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeLinejoin="round"
 | 
			
		||||
          strokeWidth="2"
 | 
			
		||||
        />
 | 
			
		||||
      </>,
 | 
			
		||||
@@ -357,6 +319,7 @@ export const DistributeHorizontallyIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <>
 | 
			
		||||
        <path d="M5 5V19Z" fill="black" />
 | 
			
		||||
        <path
 | 
			
		||||
          d="M19 5V19M5 5V19"
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
@@ -374,6 +337,14 @@ export const DistributeHorizontallyIcon = React.memo(
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
<svg
 | 
			
		||||
  width="24"
 | 
			
		||||
  height="24"
 | 
			
		||||
  viewBox="0 0 24 24"
 | 
			
		||||
  fill="none"
 | 
			
		||||
  xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
></svg>;
 | 
			
		||||
 | 
			
		||||
export const DistributeVerticallyIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
@@ -477,11 +448,6 @@ export const shield = createIcon(
 | 
			
		||||
  { width: 24 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const file = createIcon(
 | 
			
		||||
  "M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z",
 | 
			
		||||
  { width: 384, height: 512 },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
  createIcon(
 | 
			
		||||
    <>
 | 
			
		||||
@@ -497,16 +463,42 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth="2"
 | 
			
		||||
      />
 | 
			
		||||
      <g
 | 
			
		||||
      <rect
 | 
			
		||||
        x="2.5"
 | 
			
		||||
        y="2.5"
 | 
			
		||||
        width="30"
 | 
			
		||||
        height="30"
 | 
			
		||||
        fill={handlerColor(theme)}
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth="6"
 | 
			
		||||
      >
 | 
			
		||||
        <rect x="2.5" y="2.5" width="30" height="30" />
 | 
			
		||||
        <rect x="2.5" y="149.5" width="30" height="30" />
 | 
			
		||||
        <rect x="147.5" y="149.5" width="30" height="30" />
 | 
			
		||||
        <rect x="147.5" y="2.5" width="30" height="30" />
 | 
			
		||||
      </g>
 | 
			
		||||
      />
 | 
			
		||||
      <rect
 | 
			
		||||
        x="2.5"
 | 
			
		||||
        y="149.5"
 | 
			
		||||
        width="30"
 | 
			
		||||
        height="30"
 | 
			
		||||
        fill={handlerColor(theme)}
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth="6"
 | 
			
		||||
      />
 | 
			
		||||
      <rect
 | 
			
		||||
        x="147.5"
 | 
			
		||||
        y="149.5"
 | 
			
		||||
        width="30"
 | 
			
		||||
        height="30"
 | 
			
		||||
        fill={handlerColor(theme)}
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth="6"
 | 
			
		||||
      />
 | 
			
		||||
      <rect
 | 
			
		||||
        x="147.5"
 | 
			
		||||
        y="2.5"
 | 
			
		||||
        width="30"
 | 
			
		||||
        height="30"
 | 
			
		||||
        fill={handlerColor(theme)}
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth="6"
 | 
			
		||||
      />
 | 
			
		||||
    </>,
 | 
			
		||||
    { width: 182, height: 182, mirror: true },
 | 
			
		||||
  ),
 | 
			
		||||
@@ -528,18 +520,60 @@ export const UngroupIcon = React.memo(
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="2"
 | 
			
		||||
        />
 | 
			
		||||
        <g
 | 
			
		||||
        <rect
 | 
			
		||||
          x="2.5"
 | 
			
		||||
          y="2.5"
 | 
			
		||||
          width="30"
 | 
			
		||||
          height="30"
 | 
			
		||||
          fill={handlerColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="6"
 | 
			
		||||
        >
 | 
			
		||||
          <rect x="2.5" y="2.5" width="30" height="30" />
 | 
			
		||||
          <rect x="78.5" y="149.5" width="30" height="30" />
 | 
			
		||||
          <rect x="147.5" y="149.5" width="30" height="30" />
 | 
			
		||||
          <rect x="147.5" y="78.5" width="30" height="30" />
 | 
			
		||||
          <rect x="105.5" y="2.5" width="30" height="30" />
 | 
			
		||||
          <rect x="2.5" y="102.5" width="30" height="30" />
 | 
			
		||||
        </g>
 | 
			
		||||
        />
 | 
			
		||||
        <rect
 | 
			
		||||
          x="78.5"
 | 
			
		||||
          y="149.5"
 | 
			
		||||
          width="30"
 | 
			
		||||
          height="30"
 | 
			
		||||
          fill={handlerColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="6"
 | 
			
		||||
        />
 | 
			
		||||
        <rect
 | 
			
		||||
          x="147.5"
 | 
			
		||||
          y="149.5"
 | 
			
		||||
          width="30"
 | 
			
		||||
          height="30"
 | 
			
		||||
          fill={handlerColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="6"
 | 
			
		||||
        />
 | 
			
		||||
        <rect
 | 
			
		||||
          x="147.5"
 | 
			
		||||
          y="78.5"
 | 
			
		||||
          width="30"
 | 
			
		||||
          height="30"
 | 
			
		||||
          fill={handlerColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="6"
 | 
			
		||||
        />
 | 
			
		||||
        <rect
 | 
			
		||||
          x="105.5"
 | 
			
		||||
          y="2.5"
 | 
			
		||||
          width="30"
 | 
			
		||||
          height="30"
 | 
			
		||||
          fill={handlerColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="6"
 | 
			
		||||
        />
 | 
			
		||||
        <rect
 | 
			
		||||
          x="2.5"
 | 
			
		||||
          y="102.5"
 | 
			
		||||
          width="30"
 | 
			
		||||
          height="30"
 | 
			
		||||
          fill={handlerColor(theme)}
 | 
			
		||||
          stroke={iconFillColor(theme)}
 | 
			
		||||
          strokeWidth="6"
 | 
			
		||||
        />
 | 
			
		||||
      </>,
 | 
			
		||||
      { width: 182, height: 182, mirror: true },
 | 
			
		||||
    ),
 | 
			
		||||
@@ -581,10 +615,9 @@ export const StrokeWidthIcon = React.memo(
 | 
			
		||||
  ({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        d="M6 10H32"
 | 
			
		||||
        d="M6 10H34"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={strokeWidth}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        fill="none"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20 },
 | 
			
		||||
@@ -599,7 +632,6 @@ export const StrokeStyleSolidIcon = React.memo(
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
        fill="none"
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
      />,
 | 
			
		||||
      {
 | 
			
		||||
        width: 40,
 | 
			
		||||
@@ -617,7 +649,6 @@ export const StrokeStyleDashedIcon = React.memo(
 | 
			
		||||
        strokeWidth={2.5}
 | 
			
		||||
        strokeDasharray={"10, 8"}
 | 
			
		||||
        fill="none"
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20 },
 | 
			
		||||
    ),
 | 
			
		||||
@@ -627,12 +658,11 @@ export const StrokeStyleDottedIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        d="M6 10H36"
 | 
			
		||||
        d="M6 10H34"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2.5}
 | 
			
		||||
        strokeDasharray={"2, 4.5"}
 | 
			
		||||
        strokeDasharray={"4, 4"}
 | 
			
		||||
        fill="none"
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20 },
 | 
			
		||||
    ),
 | 
			
		||||
@@ -645,7 +675,6 @@ export const SloppinessArchitectIcon = React.memo(
 | 
			
		||||
        d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        fill="none"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20, mirror: true },
 | 
			
		||||
@@ -659,7 +688,6 @@ export const SloppinessArtistIcon = React.memo(
 | 
			
		||||
        d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        fill="none"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20, mirror: true },
 | 
			
		||||
@@ -673,7 +701,6 @@ export const SloppinessCartoonistIcon = React.memo(
 | 
			
		||||
        d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        fill="none"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20, mirror: true },
 | 
			
		||||
@@ -687,7 +714,6 @@ export const EdgeSharpIcon = React.memo(
 | 
			
		||||
        d="M10 17L10 5L35 5"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        fill="none"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20, mirror: true },
 | 
			
		||||
@@ -701,7 +727,6 @@ export const EdgeRoundIcon = React.memo(
 | 
			
		||||
        d="M10 17V15C10 8 13 5 21 5L33.5 5"
 | 
			
		||||
        stroke={iconFillColor(theme)}
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        fill="none"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 40, height: 20, mirror: true },
 | 
			
		||||
@@ -769,123 +794,3 @@ export const ArrowheadBarIcon = React.memo(
 | 
			
		||||
      { width: 40, height: 20 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontSizeSmallIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 47, height: 77 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontSizeMediumIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 77, height: 75 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontSizeLargeIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 45, height: 75 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontSizeExtraLargeIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        d="M 42.578 35.4 L 66.699 71.387 L 49.414 71.387 L 32.813 44.385 L 16.211 71.387 L 0 71.387 L 23.682 34.57 L 1.514 0 L 18.213 0 L 33.594 25.684 L 48.682 0 L 64.99 0 L 42.578 35.4 Z M 119.775 71.387 L 75.684 71.387 L 75.684 0 L 90.82 0 L 90.82 58.887 L 119.775 58.887 L 119.775 71.387 Z"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 120, height: 75 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontFamilyHandDrawnIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        d="M290.74 93.24l128.02 128.02-277.99 277.99-114.14 12.6C11.35 513.54-1.56 500.62.14 485.34l12.7-114.22 277.9-277.88zm207.2-19.06l-60.11-60.11c-18.75-18.75-49.16-18.75-67.91 0l-56.55 56.55 128.02 128.02 56.55-56.55c18.75-18.76 18.75-49.16 0-67.91z"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 448, height: 512 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontFamilyNormalIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <>
 | 
			
		||||
        <path
 | 
			
		||||
          fill={iconFillColor(theme)}
 | 
			
		||||
          d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
 | 
			
		||||
        />
 | 
			
		||||
        <path
 | 
			
		||||
          fill={iconFillColor(theme)}
 | 
			
		||||
          d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
 | 
			
		||||
        />
 | 
			
		||||
      </>,
 | 
			
		||||
      { width: 70, height: 78 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const FontFamilyCodeIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <>
 | 
			
		||||
        <path
 | 
			
		||||
          fill={iconFillColor(theme)}
 | 
			
		||||
          d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
 | 
			
		||||
        />
 | 
			
		||||
      </>,
 | 
			
		||||
      { width: 640, height: 512 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const TextAlignLeftIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 448, height: 512 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const TextAlignCenterIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 448, height: 512 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const TextAlignRightIcon = React.memo(
 | 
			
		||||
  ({ theme }: { theme: "light" | "dark" }) =>
 | 
			
		||||
    createIcon(
 | 
			
		||||
      <path
 | 
			
		||||
        d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
 | 
			
		||||
        fill={iconFillColor(theme)}
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
      />,
 | 
			
		||||
      { width: 448, height: 512 },
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import { FontFamily } from "./element/types";
 | 
			
		||||
import cssVariables from "./css/variables.module.scss";
 | 
			
		||||
import { AppProps } from "./types";
 | 
			
		||||
import { FontFamilyValues } from "./element/types";
 | 
			
		||||
 | 
			
		||||
export const APP_NAME = "Excalidraw";
 | 
			
		||||
 | 
			
		||||
@@ -14,7 +13,6 @@ export const CURSOR_TYPE = {
 | 
			
		||||
  TEXT: "text",
 | 
			
		||||
  CROSSHAIR: "crosshair",
 | 
			
		||||
  GRABBING: "grabbing",
 | 
			
		||||
  GRAB: "grab",
 | 
			
		||||
  POINTER: "pointer",
 | 
			
		||||
  MOVE: "move",
 | 
			
		||||
  AUTO: "",
 | 
			
		||||
@@ -64,15 +62,15 @@ export const CLASSES = {
 | 
			
		||||
 | 
			
		||||
// 1-based in case we ever do `if(element.fontFamily)`
 | 
			
		||||
export const FONT_FAMILY = {
 | 
			
		||||
  Virgil: 1,
 | 
			
		||||
  Helvetica: 2,
 | 
			
		||||
  Cascadia: 3,
 | 
			
		||||
};
 | 
			
		||||
  1: "Virgil",
 | 
			
		||||
  2: "Helvetica",
 | 
			
		||||
  3: "Cascadia",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_FONT_SIZE = 20;
 | 
			
		||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
 | 
			
		||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
 | 
			
		||||
export const DEFAULT_TEXT_ALIGN = "left";
 | 
			
		||||
export const DEFAULT_VERTICAL_ALIGN = "top";
 | 
			
		||||
export const DEFAULT_VERSION = "{version}";
 | 
			
		||||
@@ -86,17 +84,9 @@ export const MIME_TYPES = {
 | 
			
		||||
  excalidrawlib: "application/vnd.excalidrawlib+json",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const EXPORT_DATA_TYPES = {
 | 
			
		||||
  excalidraw: "excalidraw",
 | 
			
		||||
  excalidrawClipboard: "excalidraw/clipboard",
 | 
			
		||||
  excalidrawLibrary: "excalidrawlib",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export const EXPORT_SOURCE = window.location.origin;
 | 
			
		||||
 | 
			
		||||
export const STORAGE_KEYS = {
 | 
			
		||||
  LOCAL_STORAGE_LIBRARY: "excalidraw-library",
 | 
			
		||||
} as const;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// time in milliseconds
 | 
			
		||||
export const TAP_TWICE_TIMEOUT = 300;
 | 
			
		||||
@@ -105,6 +95,7 @@ export const TITLE_TIMEOUT = 10000;
 | 
			
		||||
export const TOAST_TIMEOUT = 5000;
 | 
			
		||||
export const VERSION_TIMEOUT = 30000;
 | 
			
		||||
export const SCROLL_TIMEOUT = 100;
 | 
			
		||||
 | 
			
		||||
export const ZOOM_STEP = 0.1;
 | 
			
		||||
 | 
			
		||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
 | 
			
		||||
@@ -119,32 +110,3 @@ export const MODES = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const THEME_FILTER = cssVariables.themeFilter;
 | 
			
		||||
 | 
			
		||||
export const URL_QUERY_KEYS = {
 | 
			
		||||
  addLibrary: "addLibrary",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export const URL_HASH_KEYS = {
 | 
			
		||||
  addLibrary: "addLibrary",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
 | 
			
		||||
  canvasActions: {
 | 
			
		||||
    changeViewBackgroundColor: true,
 | 
			
		||||
    clearCanvas: true,
 | 
			
		||||
    export: { saveFileToDisk: true },
 | 
			
		||||
    loadScene: true,
 | 
			
		||||
    saveToActiveFile: true,
 | 
			
		||||
    theme: true,
 | 
			
		||||
    saveAsImage: true,
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
 | 
			
		||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
 | 
			
		||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
 | 
			
		||||
 | 
			
		||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
 | 
			
		||||
 | 
			
		||||
export const EXPORT_SCALES = [1, 2, 3];
 | 
			
		||||
export const DEFAULT_EXPORT_PADDING = 10; // px
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  clip: rect(1px, 1px, 1px, 1px);
 | 
			
		||||
  white-space: nowrap; /* added line */
 | 
			
		||||
  user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.LoadingMessage {
 | 
			
		||||
 
 | 
			
		||||
@@ -16,12 +16,6 @@
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
 | 
			
		||||
  &:focus {
 | 
			
		||||
    outline: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // serves 2 purposes:
 | 
			
		||||
  // 1. prevent selecting text outside the component when double-clicking or
 | 
			
		||||
@@ -51,13 +45,6 @@
 | 
			
		||||
    image-rendering: -moz-crisp-edges; // FF
 | 
			
		||||
 | 
			
		||||
    z-index: var(--zIndex-canvas);
 | 
			
		||||
 | 
			
		||||
    // Remove the main canvas from document flow to avoid resizeObserver
 | 
			
		||||
    // feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__canvas {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.theme--dark {
 | 
			
		||||
@@ -235,8 +222,7 @@
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      svg {
 | 
			
		||||
        width: 36px;
 | 
			
		||||
        height: 14px;
 | 
			
		||||
        padding: 2px;
 | 
			
		||||
        height: 18px;
 | 
			
		||||
        opacity: 0.6;
 | 
			
		||||
      }
 | 
			
		||||
      &.active svg {
 | 
			
		||||
@@ -333,8 +319,8 @@
 | 
			
		||||
  .App-menu_bottom {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    grid-template-columns: min-content auto min-content;
 | 
			
		||||
    grid-gap: 15px;
 | 
			
		||||
    grid-template-columns: 1fr auto 1fr;
 | 
			
		||||
    grid-gap: 4px;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
    cursor: default;
 | 
			
		||||
    pointer-events: none !important;
 | 
			
		||||
@@ -359,6 +345,10 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
 | 
			
		||||
    pointer-events: all;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .App-menu_bottom > *:first-child {
 | 
			
		||||
    justify-self: flex-start;
 | 
			
		||||
  }
 | 
			
		||||
@@ -416,11 +406,13 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.dropdown-select--floating {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      margin: 0.5em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .dropdown-select__language.dropdown-select--floating {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    bottom: 10px;
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
@@ -456,29 +448,22 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .help-icon {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    fill: $oc-gray-6;
 | 
			
		||||
    bottom: 14px;
 | 
			
		||||
    width: 1.5rem;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    margin-top: 5px;
 | 
			
		||||
    background: none;
 | 
			
		||||
    color: var(--icon-fill-color);
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      margin-right: 14px;
 | 
			
		||||
      right: 14px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      margin-left: 14px;
 | 
			
		||||
      left: 14px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include isMobile {
 | 
			
		||||
  @media #{$is-mobile-query} {
 | 
			
		||||
    aside {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
@@ -494,6 +479,20 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .github-corner {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    z-index: 2;
 | 
			
		||||
 | 
			
		||||
    :root[dir="ltr"] & {
 | 
			
		||||
      right: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :root[dir="rtl"] & {
 | 
			
		||||
      left: 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .zen-mode-visibility {
 | 
			
		||||
    visibility: visible;
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
 
 | 
			
		||||
@@ -14,12 +14,11 @@
 | 
			
		||||
  --focus-highlight-color: #{$oc-blue-2};
 | 
			
		||||
  --icon-fill-color: #{$oc-black};
 | 
			
		||||
  --icon-green-fill-color: #{$oc-green-9};
 | 
			
		||||
  --default-bg-color: #{$oc-white};
 | 
			
		||||
  --input-bg-color: #{$oc-white};
 | 
			
		||||
  --input-border-color: #{$oc-gray-3};
 | 
			
		||||
  --input-hover-bg-color: #{$oc-gray-1};
 | 
			
		||||
  --input-label-color: #{$oc-gray-7};
 | 
			
		||||
  --island-bg-color: rgba(255, 255, 255, 0.96);
 | 
			
		||||
  --island-bg-color: rgba(255, 255, 255, 0.9);
 | 
			
		||||
  --keybinding-color: #{$oc-gray-5};
 | 
			
		||||
  --link-color: #{$oc-blue-7};
 | 
			
		||||
  --overlay-bg-color: #{transparentize($oc-white, 0.12)};
 | 
			
		||||
@@ -57,12 +56,11 @@
 | 
			
		||||
    --focus-highlight-color: #{$oc-blue-6};
 | 
			
		||||
    --icon-fill-color: #{$oc-gray-4};
 | 
			
		||||
    --icon-green-fill-color: #{$oc-green-4};
 | 
			
		||||
    --default-bg-color: #121212;
 | 
			
		||||
    --input-bg-color: #121212;
 | 
			
		||||
    --input-border-color: #2e2e2e;
 | 
			
		||||
    --input-hover-bg-color: #181818;
 | 
			
		||||
    --input-label-color: #{$oc-gray-2};
 | 
			
		||||
    --island-bg-color: rgba(30, 30, 30, 0.98);
 | 
			
		||||
    --island-bg-color: #1e1e1e;
 | 
			
		||||
    --keybinding-color: #{$oc-gray-6};
 | 
			
		||||
    --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
 | 
			
		||||
    --popup-bg-color: #2c2c2c;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,10 @@
 | 
			
		||||
@import "open-color/open-color.scss";
 | 
			
		||||
 | 
			
		||||
@mixin isMobile() {
 | 
			
		||||
  @at-root .excalidraw--mobile#{&} {
 | 
			
		||||
    @content;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// keep up to date with is-mobile.tsx
 | 
			
		||||
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
 | 
			
		||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
 | 
			
		||||
 | 
			
		||||
:export {
 | 
			
		||||
  isMobileQuery: unquote($is-mobile-query);
 | 
			
		||||
  themeFilter: unquote($theme-filter);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,13 @@
 | 
			
		||||
import { cleanAppStateForExport } from "../appState";
 | 
			
		||||
import { EXPORT_DATA_TYPES } from "../constants";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { clearElementsForExport } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { calculateScrollCenter } from "../scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isValidExcalidrawData } from "./json";
 | 
			
		||||
import { restore } from "./restore";
 | 
			
		||||
import { ImportedLibraryData } from "./types";
 | 
			
		||||
import { LibraryData } from "./types";
 | 
			
		||||
 | 
			
		||||
const parseFileContents = async (blob: Blob | File) => {
 | 
			
		||||
  let contents: string;
 | 
			
		||||
@@ -84,7 +83,6 @@ export const loadFromBlob = async (
 | 
			
		||||
  blob: Blob,
 | 
			
		||||
  /** @see restore.localAppState */
 | 
			
		||||
  localAppState: AppState | null,
 | 
			
		||||
  localElements: readonly ExcalidrawElement[] | null,
 | 
			
		||||
) => {
 | 
			
		||||
  const contents = await parseFileContents(blob);
 | 
			
		||||
  try {
 | 
			
		||||
@@ -97,7 +95,13 @@ export const loadFromBlob = async (
 | 
			
		||||
        elements: clearElementsForExport(data.elements || []),
 | 
			
		||||
        appState: {
 | 
			
		||||
          theme: localAppState?.theme,
 | 
			
		||||
          fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
 | 
			
		||||
          fileHandle:
 | 
			
		||||
            blob.handle &&
 | 
			
		||||
            ["application/json", MIME_TYPES.excalidraw].includes(
 | 
			
		||||
              getMimeType(blob),
 | 
			
		||||
            )
 | 
			
		||||
              ? blob.handle
 | 
			
		||||
              : null,
 | 
			
		||||
          ...cleanAppStateForExport(data.appState || {}),
 | 
			
		||||
          ...(localAppState
 | 
			
		||||
            ? calculateScrollCenter(data.elements || [], localAppState, null)
 | 
			
		||||
@@ -105,7 +109,6 @@ export const loadFromBlob = async (
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      localAppState,
 | 
			
		||||
      localElements,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
@@ -117,8 +120,8 @@ export const loadFromBlob = async (
 | 
			
		||||
 | 
			
		||||
export const loadLibraryFromBlob = async (blob: Blob) => {
 | 
			
		||||
  const contents = await parseFileContents(blob);
 | 
			
		||||
  const data: ImportedLibraryData = JSON.parse(contents);
 | 
			
		||||
  if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
 | 
			
		||||
  const data: LibraryData = JSON.parse(contents);
 | 
			
		||||
  if (data.type !== "excalidrawlib") {
 | 
			
		||||
    throw new Error(t("alerts.couldNotLoadInvalidFile"));
 | 
			
		||||
  }
 | 
			
		||||
  return data;
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import decodePng from "png-chunks-extract";
 | 
			
		||||
import tEXt from "png-chunk-text";
 | 
			
		||||
import encodePng from "png-chunks-encode";
 | 
			
		||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
 | 
			
		||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
// PNG
 | 
			
		||||
@@ -67,10 +67,7 @@ export const decodePngMetadata = async (blob: Blob) => {
 | 
			
		||||
      const encodedData = JSON.parse(metadata.text);
 | 
			
		||||
      if (!("encoded" in encodedData)) {
 | 
			
		||||
        // legacy, un-encoded scene JSON
 | 
			
		||||
        if (
 | 
			
		||||
          "type" in encodedData &&
 | 
			
		||||
          encodedData.type === EXPORT_DATA_TYPES.excalidraw
 | 
			
		||||
        ) {
 | 
			
		||||
        if ("type" in encodedData && encodedData.type === "excalidraw") {
 | 
			
		||||
          return metadata.text;
 | 
			
		||||
        }
 | 
			
		||||
        throw new Error("FAILED");
 | 
			
		||||
@@ -118,10 +115,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
 | 
			
		||||
      const encodedData = JSON.parse(json);
 | 
			
		||||
      if (!("encoded" in encodedData)) {
 | 
			
		||||
        // legacy, un-encoded scene JSON
 | 
			
		||||
        if (
 | 
			
		||||
          "type" in encodedData &&
 | 
			
		||||
          encodedData.type === EXPORT_DATA_TYPES.excalidraw
 | 
			
		||||
        ) {
 | 
			
		||||
        if ("type" in encodedData && encodedData.type === "excalidraw") {
 | 
			
		||||
          return json;
 | 
			
		||||
        }
 | 
			
		||||
        throw new Error("FAILED");
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,8 @@
 | 
			
		||||
import { fileSave } from "browser-fs-access";
 | 
			
		||||
import {
 | 
			
		||||
  copyBlobToClipboardAsPng,
 | 
			
		||||
  copyCanvasToClipboardAsPng,
 | 
			
		||||
  copyTextToSystemClipboard,
 | 
			
		||||
} from "../clipboard";
 | 
			
		||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { exportToCanvas, exportToSvg } from "../scene/export";
 | 
			
		||||
@@ -19,29 +18,42 @@ export const exportCanvas = async (
 | 
			
		||||
  type: ExportType,
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  canvas: HTMLCanvasElement,
 | 
			
		||||
  {
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    exportPadding = DEFAULT_EXPORT_PADDING,
 | 
			
		||||
    exportPadding = 10,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
    name,
 | 
			
		||||
    scale = 1,
 | 
			
		||||
    shouldAddWatermark,
 | 
			
		||||
  }: {
 | 
			
		||||
    exportBackground: boolean;
 | 
			
		||||
    exportPadding?: number;
 | 
			
		||||
    viewBackgroundColor: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    scale?: number;
 | 
			
		||||
    shouldAddWatermark: boolean;
 | 
			
		||||
  },
 | 
			
		||||
) => {
 | 
			
		||||
  if (elements.length === 0) {
 | 
			
		||||
    throw new Error(t("alerts.cannotExportEmptyCanvas"));
 | 
			
		||||
  }
 | 
			
		||||
  if (type === "svg" || type === "clipboard-svg") {
 | 
			
		||||
    const tempSvg = await exportToSvg(elements, {
 | 
			
		||||
    const tempSvg = exportToSvg(elements, {
 | 
			
		||||
      exportBackground,
 | 
			
		||||
      exportWithDarkMode: appState.exportWithDarkMode,
 | 
			
		||||
      viewBackgroundColor,
 | 
			
		||||
      exportPadding,
 | 
			
		||||
      exportScale: appState.exportScale,
 | 
			
		||||
      exportEmbedScene: appState.exportEmbedScene && type === "svg",
 | 
			
		||||
      scale,
 | 
			
		||||
      shouldAddWatermark,
 | 
			
		||||
      metadata:
 | 
			
		||||
        appState.exportEmbedScene && type === "svg"
 | 
			
		||||
          ? await (
 | 
			
		||||
              await import(/* webpackChunkName: "image" */ "./image")
 | 
			
		||||
            ).encodeSvgMetadata({
 | 
			
		||||
              text: serializeAsJSON(elements, appState),
 | 
			
		||||
            })
 | 
			
		||||
          : undefined,
 | 
			
		||||
    });
 | 
			
		||||
    if (type === "svg") {
 | 
			
		||||
      await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
 | 
			
		||||
@@ -50,7 +62,7 @@ export const exportCanvas = async (
 | 
			
		||||
      });
 | 
			
		||||
      return;
 | 
			
		||||
    } else if (type === "clipboard-svg") {
 | 
			
		||||
      await copyTextToSystemClipboard(tempSvg.outerHTML);
 | 
			
		||||
      copyTextToSystemClipboard(tempSvg.outerHTML);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -59,14 +71,15 @@ export const exportCanvas = async (
 | 
			
		||||
    exportBackground,
 | 
			
		||||
    viewBackgroundColor,
 | 
			
		||||
    exportPadding,
 | 
			
		||||
    scale,
 | 
			
		||||
    shouldAddWatermark,
 | 
			
		||||
  });
 | 
			
		||||
  tempCanvas.style.display = "none";
 | 
			
		||||
  document.body.appendChild(tempCanvas);
 | 
			
		||||
  let blob = await canvasToBlob(tempCanvas);
 | 
			
		||||
  tempCanvas.remove();
 | 
			
		||||
 | 
			
		||||
  if (type === "png") {
 | 
			
		||||
    const fileName = `${name}.png`;
 | 
			
		||||
    let blob = await canvasToBlob(tempCanvas);
 | 
			
		||||
    if (appState.exportEmbedScene) {
 | 
			
		||||
      blob = await (
 | 
			
		||||
        await import(/* webpackChunkName: "image" */ "./image")
 | 
			
		||||
@@ -82,7 +95,7 @@ export const exportCanvas = async (
 | 
			
		||||
    });
 | 
			
		||||
  } else if (type === "clipboard") {
 | 
			
		||||
    try {
 | 
			
		||||
      await copyBlobToClipboardAsPng(blob);
 | 
			
		||||
      await copyCanvasToClipboardAsPng(tempCanvas);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
 | 
			
		||||
        throw error;
 | 
			
		||||
@@ -90,4 +103,9 @@ export const exportCanvas = async (
 | 
			
		||||
      throw new Error(t("alerts.couldNotCopyToClipboard"));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // clean up the DOM
 | 
			
		||||
  if (tempCanvas !== canvas) {
 | 
			
		||||
    tempCanvas.remove();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,28 @@
 | 
			
		||||
import { fileOpen, fileSave } from "browser-fs-access";
 | 
			
		||||
import { cleanAppStateForExport } from "../appState";
 | 
			
		||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
 | 
			
		||||
import { MIME_TYPES } from "../constants";
 | 
			
		||||
import { clearElementsForExport } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { loadFromBlob } from "./blob";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  ExportedDataState,
 | 
			
		||||
  ImportedDataState,
 | 
			
		||||
  ExportedLibraryData,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import Library from "./library";
 | 
			
		||||
import { Library } from "./library";
 | 
			
		||||
import { ImportedDataState } from "./types";
 | 
			
		||||
 | 
			
		||||
export const serializeAsJSON = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: Partial<AppState>,
 | 
			
		||||
): string => {
 | 
			
		||||
  const data: ExportedDataState = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidraw,
 | 
			
		||||
    version: 2,
 | 
			
		||||
    source: EXPORT_SOURCE,
 | 
			
		||||
    elements: clearElementsForExport(elements),
 | 
			
		||||
    appState: cleanAppStateForExport(appState),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return JSON.stringify(data, null, 2);
 | 
			
		||||
};
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
): string =>
 | 
			
		||||
  JSON.stringify(
 | 
			
		||||
    {
 | 
			
		||||
      type: "excalidraw",
 | 
			
		||||
      version: 2,
 | 
			
		||||
      source: window.location.origin,
 | 
			
		||||
      elements: clearElementsForExport(elements),
 | 
			
		||||
      appState: cleanAppStateForExport(appState),
 | 
			
		||||
    },
 | 
			
		||||
    null,
 | 
			
		||||
    2,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const saveAsJSON = async (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
@@ -49,10 +45,7 @@ export const saveAsJSON = async (
 | 
			
		||||
  return { fileHandle };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loadFromJSON = async (
 | 
			
		||||
  localAppState: AppState,
 | 
			
		||||
  localElements: readonly ExcalidrawElement[] | null,
 | 
			
		||||
) => {
 | 
			
		||||
export const loadFromJSON = async (localAppState: AppState) => {
 | 
			
		||||
  const blob = await fileOpen({
 | 
			
		||||
    description: "Excalidraw files",
 | 
			
		||||
    // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
 | 
			
		||||
@@ -67,7 +60,7 @@ export const loadFromJSON = async (
 | 
			
		||||
    ],
 | 
			
		||||
    */
 | 
			
		||||
  });
 | 
			
		||||
  return loadFromBlob(blob, localAppState, localElements);
 | 
			
		||||
  return loadFromBlob(blob, localAppState);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isValidExcalidrawData = (data?: {
 | 
			
		||||
@@ -76,7 +69,7 @@ export const isValidExcalidrawData = (data?: {
 | 
			
		||||
  appState?: any;
 | 
			
		||||
}): data is ImportedDataState => {
 | 
			
		||||
  return (
 | 
			
		||||
    data?.type === EXPORT_DATA_TYPES.excalidraw &&
 | 
			
		||||
    data?.type === "excalidraw" &&
 | 
			
		||||
    (!data.elements ||
 | 
			
		||||
      (Array.isArray(data.elements) &&
 | 
			
		||||
        (!data.appState || typeof data.appState === "object")))
 | 
			
		||||
@@ -87,20 +80,22 @@ export const isValidLibrary = (json: any) => {
 | 
			
		||||
  return (
 | 
			
		||||
    typeof json === "object" &&
 | 
			
		||||
    json &&
 | 
			
		||||
    json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
 | 
			
		||||
    json.type === "excalidrawlib" &&
 | 
			
		||||
    json.version === 1
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const saveLibraryAsJSON = async (library: Library) => {
 | 
			
		||||
  const libraryItems = await library.loadLibrary();
 | 
			
		||||
  const data: ExportedLibraryData = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidrawLibrary,
 | 
			
		||||
    version: 1,
 | 
			
		||||
    source: EXPORT_SOURCE,
 | 
			
		||||
    library: libraryItems,
 | 
			
		||||
  };
 | 
			
		||||
  const serialized = JSON.stringify(data, null, 2);
 | 
			
		||||
export const saveLibraryAsJSON = async () => {
 | 
			
		||||
  const library = await Library.loadLibrary();
 | 
			
		||||
  const serialized = JSON.stringify(
 | 
			
		||||
    {
 | 
			
		||||
      type: "excalidrawlib",
 | 
			
		||||
      version: 1,
 | 
			
		||||
      library,
 | 
			
		||||
    },
 | 
			
		||||
    null,
 | 
			
		||||
    2,
 | 
			
		||||
  );
 | 
			
		||||
  const fileName = "library.excalidrawlib";
 | 
			
		||||
  const blob = new Blob([serialized], {
 | 
			
		||||
    type: MIME_TYPES.excalidrawlib,
 | 
			
		||||
@@ -112,7 +107,7 @@ export const saveLibraryAsJSON = async (library: Library) => {
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const importLibraryFromJSON = async (library: Library) => {
 | 
			
		||||
export const importLibraryFromJSON = async () => {
 | 
			
		||||
  const blob = await fileOpen({
 | 
			
		||||
    description: "Excalidraw library files",
 | 
			
		||||
    // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
 | 
			
		||||
@@ -121,5 +116,5 @@ export const importLibraryFromJSON = async (library: Library) => {
 | 
			
		||||
    extensions: [".json", ".excalidrawlib"],
 | 
			
		||||
    */
 | 
			
		||||
  });
 | 
			
		||||
  await library.importLibrary(blob);
 | 
			
		||||
  Library.importLibrary(blob);
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user