mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Compare commits
	
		
			80 Commits
		
	
	
		
			dwelle/bum
			...
			mrazator/g
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					9c91cf93dd | ||
| 
						 | 
					dd1370381d | ||
| 
						 | 
					72d6ee48fc | ||
| 
						 | 
					232242d2e9 | ||
| 
						 | 
					f19ce30dfe | ||
| 
						 | 
					3cf14c73a3 | ||
| 
						 | 
					8d530cf102 | ||
| 
						 | 
					b87925d253 | ||
| 
						 | 
					a6684b09f2 | ||
| 
						 | 
					b427617f66 | ||
| 
						 | 
					2907fbe34b | ||
| 
						 | 
					c67815f7b0 | ||
| 
						 | 
					c641860cb1 | ||
| 
						 | 
					84d89b9a8a | ||
| 
						 | 
					e63dd025c9 | ||
| 
						 | 
					15e019706d | ||
| 
						 | 
					a133a70e87 | ||
| 
						 | 
					80ea7ca23f | ||
| 
						 | 
					e844580b14 | ||
| 
						 | 
					5a0771ad9c | ||
| 
						 | 
					adcdbe2907 | ||
| 
						 | 
					230d0edc44 | ||
| 
						 | 
					d0a380758e | ||
| 
						 | 
					7b36de0476 | ||
| 
						 | 
					2427e622b0 | ||
| 
						 | 
					62228e0bbb | ||
| 
						 | 
					4c5408263c | ||
| 
						 | 
					bd7b778f41 | ||
| 
						 | 
					43b2476dfe | ||
| 
						 | 
					df8875a497 | ||
| 
						 | 
					6fbc44fd1f | ||
| 
						 | 
					d25a7d365b | ||
| 
						 | 
					e52c2cd0b6 | ||
| 
						 | 
					96eeec5119 | ||
| 
						 | 
					f5221d521b | ||
| 
						 | 
					db2c235cd4 | ||
| 
						 | 
					148b895f46 | ||
| 
						 | 
					d9258a736b | ||
| 
						 | 
					2e1f08c796 | ||
| 
						 | 
					1d5b41dabb | ||
| 
						 | 
					66a2f24296 | ||
| 
						 | 
					04668d8263 | ||
| 
						 | 
					abbeed3d5f | ||
| 
						 | 
					ba8c09d529 | ||
| 
						 | 
					744b3e5d09 | ||
| 
						 | 
					6ba9bd60e8 | ||
| 
						 | 
					a1ffa064df | ||
| 
						 | 
					4dc4590f24 | ||
| 
						 | 
					d2f67e619f | ||
| 
						 | 
					22b39277f5 | ||
| 
						 | 
					63dee03ef0 | ||
| 
						 | 
					08b13f971d | ||
| 
						 | 
					69f4cc70cb | ||
| 
						 | 
					860308eb27 | ||
| 
						 | 
					4eb9463f26 | ||
| 
						 | 
					6ed6131169 | ||
| 
						 | 
					1ed98f9c93 | ||
| 
						 | 
					a71bb63d1f | ||
| 
						 | 
					661d6a4a75 | ||
| 
						 | 
					defd34923a | ||
| 
						 | 
					c540bd68aa | ||
| 
						 | 
					eddbe55f50 | ||
| 
						 | 
					2f9526da24 | ||
| 
						 | 
					1b6e3fe05b | ||
| 
						 | 
					afe52c89a7 | ||
| 
						 | 
					be4e127f6c | ||
| 
						 | 
					ff0b4394b1 | ||
| 
						 | 
					7d8b7fc14d | ||
| 
						 | 
					971b4d4ae6 | ||
| 
						 | 
					cc4c51996c | ||
| 
						 | 
					79257a1923 | ||
| 
						 | 
					dc66261c19 | ||
| 
						 | 
					273ba803d9 | ||
| 
						 | 
					301e83805d | ||
| 
						 | 
					ed5ce8d3de | ||
| 
						 | 
					1ed53b153c | ||
| 
						 | 
					c1926f33bb | ||
| 
						 | 
					6539029d2a | ||
| 
						 | 
					d1f37cc64f | ||
| 
						 | 
					f0d25e34c3 | 
@@ -4,8 +4,16 @@
 | 
			
		||||
!.eslintrc.json
 | 
			
		||||
!.npmrc
 | 
			
		||||
!.prettierrc
 | 
			
		||||
!excalidraw-app/
 | 
			
		||||
!package.json
 | 
			
		||||
!public/
 | 
			
		||||
!packages/
 | 
			
		||||
!scripts/
 | 
			
		||||
!tsconfig.json
 | 
			
		||||
!yarn.lock
 | 
			
		||||
 | 
			
		||||
# keep (sub)sub directories at the end to exclude from explicit included
 | 
			
		||||
# e.g. ./packages/excalidraw/{dist,node_modules}
 | 
			
		||||
**/build
 | 
			
		||||
**/dist
 | 
			
		||||
**/node_modules
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
 | 
			
		||||
# whether to disable live reload / HMR. Usuaully what you want to do when
 | 
			
		||||
# debugging Service Workers.
 | 
			
		||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
 | 
			
		||||
VITE_APP_DISABLE_TRACKING=true
 | 
			
		||||
VITE_APP_ENABLE_TRACKING=true
 | 
			
		||||
 | 
			
		||||
FAST_REFRESH=false
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
 | 
			
		||||
 | 
			
		||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 | 
			
		||||
 | 
			
		||||
VITE_APP_DISABLE_TRACKING=
 | 
			
		||||
VITE_APP_ENABLE_TRACKING=false
 | 
			
		||||
 
 | 
			
		||||
@@ -6,3 +6,5 @@ firebase/
 | 
			
		||||
dist/
 | 
			
		||||
public/workbox
 | 
			
		||||
packages/excalidraw/types
 | 
			
		||||
examples/**/public
 | 
			
		||||
dev-dist
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
  "extends": ["@excalidraw/eslint-config", "react-app"],
 | 
			
		||||
  "rules": {
 | 
			
		||||
    "import/no-anonymous-default-export": "off",
 | 
			
		||||
    "no-restricted-globals": "off"
 | 
			
		||||
    "no-restricted-globals": "off",
 | 
			
		||||
    "@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,14 +1,16 @@
 | 
			
		||||
name: Tests
 | 
			
		||||
 | 
			
		||||
on: pull_request
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches: master
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  test:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
      - name: Setup Node.js 18.x
 | 
			
		||||
        uses: actions/setup-node@v2
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 18.x
 | 
			
		||||
      - name: Install and test
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -2,16 +2,18 @@ FROM node:18 AS build
 | 
			
		||||
 | 
			
		||||
WORKDIR /opt/node_app
 | 
			
		||||
 | 
			
		||||
COPY package.json yarn.lock ./
 | 
			
		||||
RUN yarn --ignore-optional --network-timeout 600000
 | 
			
		||||
COPY . .
 | 
			
		||||
 | 
			
		||||
# do not ignore optional dependencies:
 | 
			
		||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
 | 
			
		||||
RUN yarn --network-timeout 600000
 | 
			
		||||
 | 
			
		||||
ARG NODE_ENV=production
 | 
			
		||||
 | 
			
		||||
COPY . .
 | 
			
		||||
RUN yarn build:app:docker
 | 
			
		||||
 | 
			
		||||
FROM nginx:1.21-alpine
 | 
			
		||||
FROM nginx:1.27-alpine
 | 
			
		||||
 | 
			
		||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
 | 
			
		||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
 | 
			
		||||
 | 
			
		||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
 | 
			
		||||
```jsx showLineNumbers
 | 
			
		||||
export default function App() {
 | 
			
		||||
  const [excalidrawAPI, setExcalidrawAPI] = useState(null);
 | 
			
		||||
  return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
 | 
			
		||||
  return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,9 +9,9 @@ All `props` are _optional_.
 | 
			
		||||
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
 | 
			
		||||
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
 | 
			
		||||
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
 | 
			
		||||
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
 | 
			
		||||
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
 | 
			
		||||
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
 | 
			
		||||
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
 | 
			
		||||
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
 | 
			
		||||
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
 | 
			
		||||
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
 | 
			
		||||
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
 | 
			
		||||
@@ -26,7 +26,7 @@ All `props` are _optional_.
 | 
			
		||||
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
 | 
			
		||||
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
 | 
			
		||||
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
 | 
			
		||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
 | 
			
		||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
 | 
			
		||||
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
 | 
			
		||||
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
 | 
			
		||||
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
 | 
			
		||||
 
 | 
			
		||||
@@ -90,7 +90,7 @@ function App() {
 | 
			
		||||
        <img src={canvasUrl} alt="" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div style={{ height: "400px" }}>
 | 
			
		||||
        <Excalidraw ref={(api) => setExcalidrawAPI(api)}
 | 
			
		||||
        <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
 | 
			
		||||
/>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
 
 | 
			
		||||
@@ -18,13 +18,13 @@
 | 
			
		||||
    "@docusaurus/core": "2.2.0",
 | 
			
		||||
    "@docusaurus/preset-classic": "2.2.0",
 | 
			
		||||
    "@docusaurus/theme-live-codeblock": "2.2.0",
 | 
			
		||||
    "@excalidraw/excalidraw": "0.17.0",
 | 
			
		||||
    "@excalidraw/excalidraw": "0.17.6",
 | 
			
		||||
    "@mdx-js/react": "^1.6.22",
 | 
			
		||||
    "clsx": "^1.2.1",
 | 
			
		||||
    "docusaurus-plugin-sass": "0.2.3",
 | 
			
		||||
    "prism-react-renderer": "^1.3.5",
 | 
			
		||||
    "react": "^17.0.2",
 | 
			
		||||
    "react-dom": "^17.0.2",
 | 
			
		||||
    "react": "18.2.0",
 | 
			
		||||
    "react-dom": "18.2.0",
 | 
			
		||||
    "sass": "1.57.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ pre a {
 | 
			
		||||
  padding: 5px;
 | 
			
		||||
  background: #70b1ec;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  border: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1718,10 +1718,10 @@
 | 
			
		||||
    url-loader "^4.1.1"
 | 
			
		||||
    webpack "^5.73.0"
 | 
			
		||||
 | 
			
		||||
"@excalidraw/excalidraw@0.17.0":
 | 
			
		||||
  version "0.17.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
 | 
			
		||||
  integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
 | 
			
		||||
"@excalidraw/excalidraw@0.17.6":
 | 
			
		||||
  version "0.17.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
 | 
			
		||||
  integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
 | 
			
		||||
 | 
			
		||||
"@hapi/hoek@^9.0.0":
 | 
			
		||||
  version "9.3.0"
 | 
			
		||||
 
 | 
			
		||||
@@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
 | 
			
		||||
 | 
			
		||||
import { nanoid } from "nanoid";
 | 
			
		||||
 | 
			
		||||
import type { ResolvablePromise } from "../utils";
 | 
			
		||||
import {
 | 
			
		||||
  resolvablePromise,
 | 
			
		||||
  ResolvablePromise,
 | 
			
		||||
  distance2d,
 | 
			
		||||
  fileOpen,
 | 
			
		||||
  withBatchedUpdates,
 | 
			
		||||
@@ -872,7 +872,7 @@ export default function App({
 | 
			
		||||
                files: excalidrawAPI.getFiles(),
 | 
			
		||||
              });
 | 
			
		||||
              const ctx = canvas.getContext("2d")!;
 | 
			
		||||
              ctx.font = "30px Virgil";
 | 
			
		||||
              ctx.font = "30px Excalifont";
 | 
			
		||||
              ctx.strokeText("My custom text", 50, 60);
 | 
			
		||||
              setCanvasUrl(canvas.toDataURL());
 | 
			
		||||
            }}
 | 
			
		||||
@@ -893,7 +893,7 @@ export default function App({
 | 
			
		||||
                files: excalidrawAPI.getFiles(),
 | 
			
		||||
              });
 | 
			
		||||
              const ctx = canvas.getContext("2d")!;
 | 
			
		||||
              ctx.font = "30px Virgil";
 | 
			
		||||
              ctx.font = "30px Excalifont";
 | 
			
		||||
              ctx.strokeText("My custom text", 50, 60);
 | 
			
		||||
              setCanvasUrl(canvas.toDataURL());
 | 
			
		||||
            }}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
 | 
			
		||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
 | 
			
		||||
import CustomFooter from "./CustomFooter";
 | 
			
		||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
 | 
			
		||||
];
 | 
			
		||||
export default {
 | 
			
		||||
  elements,
 | 
			
		||||
  appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
 | 
			
		||||
  appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
 | 
			
		||||
  scrollToContent: true,
 | 
			
		||||
  libraryItems: [
 | 
			
		||||
    [
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { unstable_batchedUpdates } from "react-dom";
 | 
			
		||||
import { fileOpen as _fileOpen } from "browser-fs-access";
 | 
			
		||||
import type { MIME_TYPES } from "@excalidraw/excalidraw";
 | 
			
		||||
import { MIME_TYPES } from "@excalidraw/excalidraw";
 | 
			
		||||
import { AbortError } from "../../packages/excalidraw/errors";
 | 
			
		||||
 | 
			
		||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								examples/excalidraw/with-nextjs/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								examples/excalidraw/with-nextjs/.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -34,3 +34,6 @@ yarn-error.log*
 | 
			
		||||
# typescript
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
next-env.d.ts
 | 
			
		||||
 | 
			
		||||
# copied assets
 | 
			
		||||
public/*.woff2
 | 
			
		||||
@@ -3,7 +3,8 @@
 | 
			
		||||
  "version": "0.1.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
 | 
			
		||||
    "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
 | 
			
		||||
    "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
 | 
			
		||||
    "dev": "yarn build:workspace && next dev -p 3005",
 | 
			
		||||
    "build": "yarn build:workspace && next build",
 | 
			
		||||
    "start": "next start -p 3006",
 | 
			
		||||
@@ -12,13 +13,13 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@excalidraw/excalidraw": "*",
 | 
			
		||||
    "next": "14.1",
 | 
			
		||||
    "react": "^18",
 | 
			
		||||
    "react-dom": "^18"
 | 
			
		||||
    "react": "18.2.0",
 | 
			
		||||
    "react-dom": "18.2.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/node": "^20",
 | 
			
		||||
    "@types/react": "^18",
 | 
			
		||||
    "@types/react-dom": "^18",
 | 
			
		||||
    "@types/react": "18.2.0",
 | 
			
		||||
    "@types/react-dom": "18.2.0",
 | 
			
		||||
    "path2d-polyfill": "2.0.1",
 | 
			
		||||
    "typescript": "^5"
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import dynamic from "next/dynamic";
 | 
			
		||||
import Script from "next/script";
 | 
			
		||||
import "../common.scss";
 | 
			
		||||
 | 
			
		||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
 | 
			
		||||
@@ -15,7 +16,9 @@ export default function Page() {
 | 
			
		||||
    <>
 | 
			
		||||
      <a href="/excalidraw-in-pages">Switch to Pages router</a>
 | 
			
		||||
      <h1 className="page-title">App Router</h1>
 | 
			
		||||
 | 
			
		||||
      <Script id="load-env-variables" strategy="beforeInteractive">
 | 
			
		||||
        {`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
 | 
			
		||||
      </Script>
 | 
			
		||||
      {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
 | 
			
		||||
      <ExcalidrawWithClientOnly />
 | 
			
		||||
    </>
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ a {
 | 
			
		||||
  color: #1c7ed6;
 | 
			
		||||
  font-size: 20px;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  font-weight: 550;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.page-title {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								examples/excalidraw/with-script-in-browser/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								examples/excalidraw/with-script-in-browser/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
# copied assets
 | 
			
		||||
public/*.woff2
 | 
			
		||||
@@ -11,6 +11,7 @@
 | 
			
		||||
    <title>React App</title>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.name = "codesandbox";
 | 
			
		||||
      window.EXCALIDRAW_ASSET_PATH = window.origin;
 | 
			
		||||
    </script>
 | 
			
		||||
    <link rel="stylesheet" href="/dist/browser/dev/index.css" />
 | 
			
		||||
  </head>
 | 
			
		||||
 
 | 
			
		||||
@@ -12,8 +12,10 @@
 | 
			
		||||
    "typescript": "^5"
 | 
			
		||||
  },
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
 | 
			
		||||
    "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
 | 
			
		||||
    "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
 | 
			
		||||
    "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
 | 
			
		||||
    "start": "yarn build:workspace && vite",
 | 
			
		||||
    "build": "yarn build:workspace && vite build",
 | 
			
		||||
    "build:preview": "yarn build && vite preview --port 5002"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import polyfill from "../packages/excalidraw/polyfill";
 | 
			
		||||
import LanguageDetector from "i18next-browser-languagedetector";
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from "react";
 | 
			
		||||
import { trackEvent } from "../packages/excalidraw/analytics";
 | 
			
		||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
 | 
			
		||||
@@ -13,7 +12,7 @@ import {
 | 
			
		||||
  VERSION_TIMEOUT,
 | 
			
		||||
} from "../packages/excalidraw/constants";
 | 
			
		||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  FileId,
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
@@ -22,27 +21,26 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
 | 
			
		||||
import { t } from "../packages/excalidraw/i18n";
 | 
			
		||||
import {
 | 
			
		||||
  Excalidraw,
 | 
			
		||||
  defaultLang,
 | 
			
		||||
  LiveCollaborationTrigger,
 | 
			
		||||
  TTDDialog,
 | 
			
		||||
  TTDDialogTrigger,
 | 
			
		||||
  StoreAction,
 | 
			
		||||
  reconcileElements,
 | 
			
		||||
} from "../packages/excalidraw";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawImperativeAPI,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  ExcalidrawInitialDataState,
 | 
			
		||||
  UIAppState,
 | 
			
		||||
} from "../packages/excalidraw/types";
 | 
			
		||||
import type { ResolvablePromise } from "../packages/excalidraw/utils";
 | 
			
		||||
import {
 | 
			
		||||
  debounce,
 | 
			
		||||
  getVersion,
 | 
			
		||||
  getFrame,
 | 
			
		||||
  isTestEnv,
 | 
			
		||||
  preventUnload,
 | 
			
		||||
  ResolvablePromise,
 | 
			
		||||
  resolvablePromise,
 | 
			
		||||
  isRunningInIframe,
 | 
			
		||||
} from "../packages/excalidraw/utils";
 | 
			
		||||
@@ -52,8 +50,8 @@ import {
 | 
			
		||||
  STORAGE_KEYS,
 | 
			
		||||
  SYNC_BROWSER_TABS_TIMEOUT,
 | 
			
		||||
} from "./app_constants";
 | 
			
		||||
import type { CollabAPI } from "./collab/Collab";
 | 
			
		||||
import Collab, {
 | 
			
		||||
  CollabAPI,
 | 
			
		||||
  collabAPIAtom,
 | 
			
		||||
  isCollaboratingAtom,
 | 
			
		||||
  isOfflineAtom,
 | 
			
		||||
@@ -69,11 +67,8 @@ import {
 | 
			
		||||
  importUsernameFromLocalStorage,
 | 
			
		||||
} from "./data/localStorage";
 | 
			
		||||
import CustomStats from "./CustomStats";
 | 
			
		||||
import {
 | 
			
		||||
  restore,
 | 
			
		||||
  restoreAppState,
 | 
			
		||||
  RestoredDataState,
 | 
			
		||||
} from "../packages/excalidraw/data/restore";
 | 
			
		||||
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
 | 
			
		||||
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
 | 
			
		||||
import {
 | 
			
		||||
  ExportToExcalidrawPlus,
 | 
			
		||||
  exportToExcalidrawPlus,
 | 
			
		||||
@@ -96,12 +91,12 @@ import {
 | 
			
		||||
import { AppMainMenu } from "./components/AppMainMenu";
 | 
			
		||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
 | 
			
		||||
import { AppFooter } from "./components/AppFooter";
 | 
			
		||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
 | 
			
		||||
import { Provider, useAtom, useAtomValue } from "jotai";
 | 
			
		||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
 | 
			
		||||
import { appJotaiStore } from "./app-jotai";
 | 
			
		||||
 | 
			
		||||
import "./index.scss";
 | 
			
		||||
import { ResolutionType } from "../packages/excalidraw/utility-types";
 | 
			
		||||
import type { ResolutionType } from "../packages/excalidraw/utility-types";
 | 
			
		||||
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
 | 
			
		||||
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
 | 
			
		||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
 | 
			
		||||
@@ -124,11 +119,45 @@ import {
 | 
			
		||||
  youtubeIcon,
 | 
			
		||||
} from "../packages/excalidraw/components/icons";
 | 
			
		||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
 | 
			
		||||
import { getPreferredLanguage } from "./app-language/language-detector";
 | 
			
		||||
import { useAppLangCode } from "./app-language/language-state";
 | 
			
		||||
 | 
			
		||||
polyfill();
 | 
			
		||||
 | 
			
		||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  interface BeforeInstallPromptEventChoiceResult {
 | 
			
		||||
    outcome: "accepted" | "dismissed";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  interface BeforeInstallPromptEvent extends Event {
 | 
			
		||||
    prompt(): Promise<void>;
 | 
			
		||||
    userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  interface WindowEventMap {
 | 
			
		||||
    beforeinstallprompt: BeforeInstallPromptEvent;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let pwaEvent: BeforeInstallPromptEvent | null = null;
 | 
			
		||||
 | 
			
		||||
// Adding a listener outside of the component as it may (?) need to be
 | 
			
		||||
// subscribed early to catch the event.
 | 
			
		||||
//
 | 
			
		||||
// Also note that it will fire only if certain heuristics are met (user has
 | 
			
		||||
// used the app for some time, etc.)
 | 
			
		||||
window.addEventListener(
 | 
			
		||||
  "beforeinstallprompt",
 | 
			
		||||
  (event: BeforeInstallPromptEvent) => {
 | 
			
		||||
    // prevent Chrome <= 67 from automatically showing the prompt
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    // cache for later use
 | 
			
		||||
    pwaEvent = event;
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
let isSelfEmbedding = false;
 | 
			
		||||
 | 
			
		||||
if (window.self !== window.top) {
 | 
			
		||||
@@ -143,11 +172,6 @@ if (window.self !== window.top) {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const languageDetector = new LanguageDetector();
 | 
			
		||||
languageDetector.init({
 | 
			
		||||
  languageUtils: {},
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const shareableLinkConfirmDialog = {
 | 
			
		||||
  title: t("overwriteConfirm.modal.shareableLink.title"),
 | 
			
		||||
  description: (
 | 
			
		||||
@@ -293,19 +317,15 @@ const initializeScene = async (opts: {
 | 
			
		||||
  return { scene: null, isExternalScene: false };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
 | 
			
		||||
export const appLangCodeAtom = atom(
 | 
			
		||||
  Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const ExcalidrawWrapper = () => {
 | 
			
		||||
  const [errorMessage, setErrorMessage] = useState("");
 | 
			
		||||
  const [langCode, setLangCode] = useAtom(appLangCodeAtom);
 | 
			
		||||
  const isCollabDisabled = isRunningInIframe();
 | 
			
		||||
 | 
			
		||||
  const [appTheme, setAppTheme] = useAtom(appThemeAtom);
 | 
			
		||||
  const { editorTheme } = useHandleAppTheme();
 | 
			
		||||
 | 
			
		||||
  const [langCode, setLangCode] = useAppLangCode();
 | 
			
		||||
 | 
			
		||||
  // initial state
 | 
			
		||||
  // ---------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
@@ -461,11 +481,7 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
        if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
 | 
			
		||||
          const localDataState = importFromLocalStorage();
 | 
			
		||||
          const username = importUsernameFromLocalStorage();
 | 
			
		||||
          let langCode = languageDetector.detect() || defaultLang.code;
 | 
			
		||||
          if (Array.isArray(langCode)) {
 | 
			
		||||
            langCode = langCode[0];
 | 
			
		||||
          }
 | 
			
		||||
          setLangCode(langCode);
 | 
			
		||||
          setLangCode(getPreferredLanguage());
 | 
			
		||||
          excalidrawAPI.updateScene({
 | 
			
		||||
            ...localDataState,
 | 
			
		||||
            storeAction: StoreAction.UPDATE,
 | 
			
		||||
@@ -566,10 +582,6 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
    };
 | 
			
		||||
  }, [excalidrawAPI]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    languageDetector.cacheUserLanguage(langCode);
 | 
			
		||||
  }, [langCode]);
 | 
			
		||||
 | 
			
		||||
  const onChange = (
 | 
			
		||||
    elements: readonly OrderedExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
@@ -1103,6 +1115,21 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              label: t("labels.installPWA"),
 | 
			
		||||
              category: DEFAULT_CATEGORIES.app,
 | 
			
		||||
              predicate: () => !!pwaEvent,
 | 
			
		||||
              perform: () => {
 | 
			
		||||
                if (pwaEvent) {
 | 
			
		||||
                  pwaEvent.prompt();
 | 
			
		||||
                  pwaEvent.userChoice.then(() => {
 | 
			
		||||
                    // event cannot be reused, but we'll hopefully
 | 
			
		||||
                    // grab new one as the event should be fired again
 | 
			
		||||
                    pwaEvent = null;
 | 
			
		||||
                  });
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
      </Excalidraw>
 | 
			
		||||
 
 | 
			
		||||
@@ -7,8 +7,8 @@ import {
 | 
			
		||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
 | 
			
		||||
import { t } from "../packages/excalidraw/i18n";
 | 
			
		||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
 | 
			
		||||
import { UIAppState } from "../packages/excalidraw/types";
 | 
			
		||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
 | 
			
		||||
import type { UIAppState } from "../packages/excalidraw/types";
 | 
			
		||||
 | 
			
		||||
type StorageSizes = { scene: number; total: number };
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,7 @@
 | 
			
		||||
import { useSetAtom } from "jotai";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { appLangCodeAtom } from "../App";
 | 
			
		||||
import { useI18n } from "../../packages/excalidraw/i18n";
 | 
			
		||||
import { languages } from "../../packages/excalidraw/i18n";
 | 
			
		||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
 | 
			
		||||
import { appLangCodeAtom } from "./language-state";
 | 
			
		||||
 | 
			
		||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
 | 
			
		||||
  const { t, langCode } = useI18n();
 | 
			
		||||
							
								
								
									
										25
									
								
								excalidraw-app/app-language/language-detector.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								excalidraw-app/app-language/language-detector.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import LanguageDetector from "i18next-browser-languagedetector";
 | 
			
		||||
import { defaultLang, languages } from "../../packages/excalidraw";
 | 
			
		||||
 | 
			
		||||
export const languageDetector = new LanguageDetector();
 | 
			
		||||
 | 
			
		||||
languageDetector.init({
 | 
			
		||||
  languageUtils: {},
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const getPreferredLanguage = () => {
 | 
			
		||||
  const detectedLanguages = languageDetector.detect();
 | 
			
		||||
 | 
			
		||||
  const detectedLanguage = Array.isArray(detectedLanguages)
 | 
			
		||||
    ? detectedLanguages[0]
 | 
			
		||||
    : detectedLanguages;
 | 
			
		||||
 | 
			
		||||
  const initialLanguage =
 | 
			
		||||
    (detectedLanguage
 | 
			
		||||
      ? // region code may not be defined if user uses generic preferred language
 | 
			
		||||
        // (e.g. chinese vs instead of chinese-simplified)
 | 
			
		||||
        languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
 | 
			
		||||
      : null) || defaultLang.code;
 | 
			
		||||
 | 
			
		||||
  return initialLanguage;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										15
									
								
								excalidraw-app/app-language/language-state.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								excalidraw-app/app-language/language-state.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import { atom, useAtom } from "jotai";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
 | 
			
		||||
 | 
			
		||||
export const appLangCodeAtom = atom(getPreferredLanguage());
 | 
			
		||||
 | 
			
		||||
export const useAppLangCode = () => {
 | 
			
		||||
  const [langCode, setLangCode] = useAtom(appLangCodeAtom);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    languageDetector.cacheUserLanguage(langCode);
 | 
			
		||||
  }, [langCode]);
 | 
			
		||||
 | 
			
		||||
  return [langCode, setLangCode] as const;
 | 
			
		||||
};
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
import throttle from "lodash.throttle";
 | 
			
		||||
import { PureComponent } from "react";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawImperativeAPI,
 | 
			
		||||
  SocketId,
 | 
			
		||||
} from "../../packages/excalidraw/types";
 | 
			
		||||
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
 | 
			
		||||
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
 | 
			
		||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
 | 
			
		||||
import {
 | 
			
		||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  InitializedExcalidrawImageElement,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
@@ -19,7 +19,7 @@ import {
 | 
			
		||||
  zoomToFitBounds,
 | 
			
		||||
  reconcileElements,
 | 
			
		||||
} from "../../packages/excalidraw";
 | 
			
		||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
 | 
			
		||||
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
 | 
			
		||||
import {
 | 
			
		||||
  assertNever,
 | 
			
		||||
  preventUnload,
 | 
			
		||||
@@ -36,12 +36,14 @@ import {
 | 
			
		||||
  SYNC_FULL_SCENE_INTERVAL_MS,
 | 
			
		||||
  WS_EVENTS,
 | 
			
		||||
} from "../app_constants";
 | 
			
		||||
import type {
 | 
			
		||||
  SocketUpdateDataSource,
 | 
			
		||||
  SyncableExcalidrawElement,
 | 
			
		||||
} from "../data";
 | 
			
		||||
import {
 | 
			
		||||
  generateCollaborationLinkData,
 | 
			
		||||
  getCollaborationLink,
 | 
			
		||||
  getSyncableElements,
 | 
			
		||||
  SocketUpdateDataSource,
 | 
			
		||||
  SyncableExcalidrawElement,
 | 
			
		||||
} from "../data";
 | 
			
		||||
import {
 | 
			
		||||
  isSavedToFirebase,
 | 
			
		||||
@@ -77,7 +79,7 @@ import { resetBrowserStateVersions } from "../data/tabSync";
 | 
			
		||||
import { LocalData } from "../data/LocalData";
 | 
			
		||||
import { atom } from "jotai";
 | 
			
		||||
import { appJotaiStore } from "../app-jotai";
 | 
			
		||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
 | 
			
		||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
 | 
			
		||||
import { collabErrorIndicatorAtom } from "./CollabError";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,15 @@
 | 
			
		||||
import {
 | 
			
		||||
  isSyncableElement,
 | 
			
		||||
import type {
 | 
			
		||||
  SocketUpdateData,
 | 
			
		||||
  SocketUpdateDataSource,
 | 
			
		||||
  SyncableExcalidrawElement,
 | 
			
		||||
} from "../data";
 | 
			
		||||
import { isSyncableElement } from "../data";
 | 
			
		||||
 | 
			
		||||
import { TCollabClass } from "./Collab";
 | 
			
		||||
import type { TCollabClass } from "./Collab";
 | 
			
		||||
 | 
			
		||||
import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  OnUserFollowedPayload,
 | 
			
		||||
  SocketId,
 | 
			
		||||
  UserIdleState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  arrowBarToLeftIcon,
 | 
			
		||||
  loginIcon,
 | 
			
		||||
  ExcalLogo,
 | 
			
		||||
} from "../../packages/excalidraw/components/icons";
 | 
			
		||||
import { Theme } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import type { Theme } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import { MainMenu } from "../../packages/excalidraw/index";
 | 
			
		||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
 | 
			
		||||
import { LanguageList } from "./LanguageList";
 | 
			
		||||
import { LanguageList } from "../app-language/LanguageList";
 | 
			
		||||
 | 
			
		||||
export const AppMainMenu: React.FC<{
 | 
			
		||||
  onCollabDialogOpen: () => any;
 | 
			
		||||
@@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
 | 
			
		||||
      <MainMenu.ItemLink
 | 
			
		||||
        icon={ExcalLogo}
 | 
			
		||||
        href={`${
 | 
			
		||||
          import.meta.env.VITE_APP_PLUS_APP
 | 
			
		||||
          import.meta.env.VITE_APP_PLUS_LP
 | 
			
		||||
        }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
 | 
			
		||||
        className=""
 | 
			
		||||
      >
 | 
			
		||||
@@ -42,7 +42,7 @@ export const AppMainMenu: React.FC<{
 | 
			
		||||
      </MainMenu.ItemLink>
 | 
			
		||||
      <MainMenu.DefaultItems.Socials />
 | 
			
		||||
      <MainMenu.ItemLink
 | 
			
		||||
        icon={arrowBarToLeftIcon}
 | 
			
		||||
        icon={loginIcon}
 | 
			
		||||
        href={`${import.meta.env.VITE_APP_PLUS_APP}${
 | 
			
		||||
          isExcalidrawPlusSignedUser ? "" : "/sign-up"
 | 
			
		||||
        }?utm_source=signin&utm_medium=app&utm_content=hamburger`}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { arrowBarToLeftIcon } from "../../packages/excalidraw/components/icons";
 | 
			
		||||
import { loginIcon } from "../../packages/excalidraw/components/icons";
 | 
			
		||||
import { useI18n } from "../../packages/excalidraw/i18n";
 | 
			
		||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
 | 
			
		||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
 | 
			
		||||
@@ -61,7 +61,7 @@ export const AppWelcomeScreen: React.FC<{
 | 
			
		||||
                import.meta.env.VITE_APP_PLUS_LP
 | 
			
		||||
              }/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
 | 
			
		||||
              shortcut={null}
 | 
			
		||||
              icon={arrowBarToLeftIcon}
 | 
			
		||||
              icon={loginIcon}
 | 
			
		||||
            >
 | 
			
		||||
              Sign up
 | 
			
		||||
            </WelcomeScreen.Center.MenuItemLink>
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
 | 
			
		||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
 | 
			
		||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
 | 
			
		||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  FileId,
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "../../packages/excalidraw/element/types";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { THEME } from "../../packages/excalidraw/constants";
 | 
			
		||||
import { Theme } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import type { Theme } from "../../packages/excalidraw/element/types";
 | 
			
		||||
 | 
			
		||||
// https://github.com/tholman/github-corners
 | 
			
		||||
export const GitHubCorner = React.memo(
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,14 @@ import { StoreAction } from "../../packages/excalidraw";
 | 
			
		||||
import { compressData } from "../../packages/excalidraw/data/encode";
 | 
			
		||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
 | 
			
		||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawImageElement,
 | 
			
		||||
  FileId,
 | 
			
		||||
  InitializedExcalidrawImageElement,
 | 
			
		||||
} from "../../packages/excalidraw/element/types";
 | 
			
		||||
import { t } from "../../packages/excalidraw/i18n";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFileMetadata,
 | 
			
		||||
  ExcalidrawImperativeAPI,
 | 
			
		||||
 
 | 
			
		||||
@@ -20,19 +20,19 @@ import {
 | 
			
		||||
  get,
 | 
			
		||||
} from "idb-keyval";
 | 
			
		||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
 | 
			
		||||
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
 | 
			
		||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
 | 
			
		||||
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
 | 
			
		||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
 | 
			
		||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  FileId,
 | 
			
		||||
} from "../../packages/excalidraw/element/types";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
} from "../../packages/excalidraw/types";
 | 
			
		||||
import { MaybePromise } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import { debounce } from "../../packages/excalidraw/utils";
 | 
			
		||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
 | 
			
		||||
import { FileManager } from "./FileManager";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
import { reconcileElements } from "../../packages/excalidraw";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  FileId,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
} from "../../packages/excalidraw/element/types";
 | 
			
		||||
import { getSceneVersion } from "../../packages/excalidraw/element";
 | 
			
		||||
import Portal from "../collab/Portal";
 | 
			
		||||
import type Portal from "../collab/Portal";
 | 
			
		||||
import { restoreElements } from "../../packages/excalidraw/data/restore";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFileMetadata,
 | 
			
		||||
@@ -20,8 +20,9 @@ import {
 | 
			
		||||
  decryptData,
 | 
			
		||||
} from "../../packages/excalidraw/data/encryption";
 | 
			
		||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
 | 
			
		||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
 | 
			
		||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import type { SyncableExcalidrawElement } from ".";
 | 
			
		||||
import { getSyncableElements } from ".";
 | 
			
		||||
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import type { Socket } from "socket.io-client";
 | 
			
		||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,30 +9,30 @@ import {
 | 
			
		||||
} from "../../packages/excalidraw/data/encryption";
 | 
			
		||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
 | 
			
		||||
import { restore } from "../../packages/excalidraw/data/restore";
 | 
			
		||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
 | 
			
		||||
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
 | 
			
		||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
 | 
			
		||||
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
 | 
			
		||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
 | 
			
		||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  FileId,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
} from "../../packages/excalidraw/element/types";
 | 
			
		||||
import { t } from "../../packages/excalidraw/i18n";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  SocketId,
 | 
			
		||||
  UserIdleState,
 | 
			
		||||
} from "../../packages/excalidraw/types";
 | 
			
		||||
import { MakeBrand } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
 | 
			
		||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
 | 
			
		||||
import type { WS_SUBTYPES } from "../app_constants";
 | 
			
		||||
import {
 | 
			
		||||
  DELETED_ELEMENT_TIMEOUT,
 | 
			
		||||
  FILE_UPLOAD_MAX_BYTES,
 | 
			
		||||
  ROOM_ID_BYTES,
 | 
			
		||||
  WS_SUBTYPES,
 | 
			
		||||
} from "../app_constants";
 | 
			
		||||
import { encodeFilesForUpload } from "./FileManager";
 | 
			
		||||
import { saveFilesToFirebase } from "./firebase";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import { AppState } from "../../packages/excalidraw/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
 | 
			
		||||
import type { AppState } from "../../packages/excalidraw/types";
 | 
			
		||||
import {
 | 
			
		||||
  clearAppStateForLocalStorage,
 | 
			
		||||
  getDefaultAppState,
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@
 | 
			
		||||
      name="description"
 | 
			
		||||
      content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
			
		||||
    />
 | 
			
		||||
    <meta name="image" content="https://excalidraw.com/og-image-2.png" />
 | 
			
		||||
    <meta name="image" content="https://excalidraw.com/og-image-3.png" />
 | 
			
		||||
 | 
			
		||||
    <!-- Open Graph / Facebook -->
 | 
			
		||||
    <meta property="og:site_name" content="Excalidraw" />
 | 
			
		||||
@@ -35,7 +35,7 @@
 | 
			
		||||
      property="og:description"
 | 
			
		||||
      content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
 | 
			
		||||
    />
 | 
			
		||||
    <meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
 | 
			
		||||
    <meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
 | 
			
		||||
 | 
			
		||||
    <!-- Twitter -->
 | 
			
		||||
    <meta property="twitter:card" content="summary_large_image" />
 | 
			
		||||
@@ -51,7 +51,7 @@
 | 
			
		||||
    />
 | 
			
		||||
    <meta
 | 
			
		||||
      property="twitter:image"
 | 
			
		||||
      content="https://excalidraw.com/og-twitter-v2.png"
 | 
			
		||||
      content="https://excalidraw.com/og-image-3.png"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <!-- General tags -->
 | 
			
		||||
@@ -95,6 +95,11 @@
 | 
			
		||||
        color: #fff;
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <!-- Warmup the connection for Google fonts -->
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.googleapis.com" />
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
 | 
			
		||||
 | 
			
		||||
    <!------------------------------------------------------------------------->
 | 
			
		||||
    <% if (typeof PROD != 'undefined' && PROD == true) { %>
 | 
			
		||||
    <script>
 | 
			
		||||
@@ -115,8 +120,55 @@
 | 
			
		||||
        window.location.href = "https://app.excalidraw.com";
 | 
			
		||||
      }
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <!-- Following placeholder is replaced during the build step -->
 | 
			
		||||
    <!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
 | 
			
		||||
 | 
			
		||||
    <% } else { %>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.EXCALIDRAW_ASSET_PATH = window.origin;
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <!-- in DEV we need to preload from the local server and without the hash -->
 | 
			
		||||
    <link
 | 
			
		||||
      rel="preload"
 | 
			
		||||
      href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
 | 
			
		||||
      as="font"
 | 
			
		||||
      type="font/woff2"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
    <link
 | 
			
		||||
      rel="preload"
 | 
			
		||||
      href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
 | 
			
		||||
      as="font"
 | 
			
		||||
      type="font/woff2"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
    <link
 | 
			
		||||
      rel="preload"
 | 
			
		||||
      href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
 | 
			
		||||
      as="font"
 | 
			
		||||
      type="font/woff2"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
    <% } %>
 | 
			
		||||
 | 
			
		||||
    <!-- For Nunito only preload the latin range, which should be good enough for now -->
 | 
			
		||||
    <link
 | 
			
		||||
      rel="preload"
 | 
			
		||||
      href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
 | 
			
		||||
      as="font"
 | 
			
		||||
      type="font/woff2"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <!-- Register Assistant as the UI font, before the scene inits -->
 | 
			
		||||
    <link
 | 
			
		||||
      rel="stylesheet"
 | 
			
		||||
      href="../packages/excalidraw/fonts/assets/fonts.css"
 | 
			
		||||
      type="text/css"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
 | 
			
		||||
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
 | 
			
		||||
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
 | 
			
		||||
@@ -124,22 +176,6 @@
 | 
			
		||||
    <!-- Excalidraw version -->
 | 
			
		||||
    <meta name="version" content="{version}" />
 | 
			
		||||
 | 
			
		||||
    <link
 | 
			
		||||
      rel="preload"
 | 
			
		||||
      href="/Virgil.woff2"
 | 
			
		||||
      as="font"
 | 
			
		||||
      type="font/woff2"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
    <link
 | 
			
		||||
      rel="preload"
 | 
			
		||||
      href="/Cascadia.woff2"
 | 
			
		||||
      as="font"
 | 
			
		||||
      type="font/woff2"
 | 
			
		||||
      crossorigin="anonymous"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
 | 
			
		||||
    <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
 | 
			
		||||
    VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
 | 
			
		||||
    <script>
 | 
			
		||||
@@ -158,7 +194,6 @@
 | 
			
		||||
    </script>
 | 
			
		||||
    <% } %>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.EXCALIDRAW_ASSET_PATH = "/";
 | 
			
		||||
      // setting this so that libraries installation reuses this window tab.
 | 
			
		||||
      window.name = "_excalidraw";
 | 
			
		||||
    </script>
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,7 @@
 | 
			
		||||
    margin-bottom: auto;
 | 
			
		||||
    margin-inline-start: auto;
 | 
			
		||||
    margin-inline-end: 0.6em;
 | 
			
		||||
    z-index: var(--zIndex-layerUI);
 | 
			
		||||
 | 
			
		||||
    svg {
 | 
			
		||||
      width: 1.2rem;
 | 
			
		||||
@@ -40,6 +41,10 @@
 | 
			
		||||
      }
 | 
			
		||||
      &.highlighted {
 | 
			
		||||
        color: var(--color-promo);
 | 
			
		||||
        font-weight: 700;
 | 
			
		||||
        .dropdown-menu-item__icon g {
 | 
			
		||||
          stroke-width: 2;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -26,17 +26,28 @@
 | 
			
		||||
    "node": ">=18.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "vite-plugin-html": "3.2.2"
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "idb-keyval": "6.0.3",
 | 
			
		||||
    "jotai": "1.13.1",
 | 
			
		||||
    "react": "18.2.0",
 | 
			
		||||
    "react-dom": "18.2.0",
 | 
			
		||||
    "vite-plugin-html": "3.2.2",
 | 
			
		||||
    "@excalidraw/random-username": "1.0.0",
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.4",
 | 
			
		||||
    "socket.io-client": "4.7.2"
 | 
			
		||||
  },
 | 
			
		||||
  "prettier": "@excalidraw/prettier-config",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build-node": "node ./scripts/build-node.js",
 | 
			
		||||
    "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
 | 
			
		||||
    "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
 | 
			
		||||
    "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
 | 
			
		||||
    "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
 | 
			
		||||
    "build:version": "node ../scripts/build-version.js",
 | 
			
		||||
    "build": "yarn build:app && yarn build:version",
 | 
			
		||||
    "start": "yarn && vite",
 | 
			
		||||
    "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
 | 
			
		||||
    "start:production": "yarn build && yarn serve",
 | 
			
		||||
    "serve": "npx http-server build -a localhost -p 5001 -o",
 | 
			
		||||
    "build:preview": "yarn build && vite preview --port 5000"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,8 @@ import {
 | 
			
		||||
} from "../../packages/excalidraw/components/icons";
 | 
			
		||||
import { TextField } from "../../packages/excalidraw/components/TextField";
 | 
			
		||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
 | 
			
		||||
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
 | 
			
		||||
import type { CollabAPI } from "../collab/Collab";
 | 
			
		||||
import { activeRoomLinkAtom } from "../collab/Collab";
 | 
			
		||||
import { atom, useAtom, useAtomValue } from "jotai";
 | 
			
		||||
 | 
			
		||||
import "./ShareDialog.scss";
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
 | 
			
		||||
  class="welcome-screen-center"
 | 
			
		||||
>
 | 
			
		||||
  <div
 | 
			
		||||
    class="welcome-screen-center__logo virgil welcome-screen-decor"
 | 
			
		||||
    class="welcome-screen-center__logo excalifont welcome-screen-decor"
 | 
			
		||||
  >
 | 
			
		||||
    <div
 | 
			
		||||
      class="ExcalidrawLogo is-small"
 | 
			
		||||
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div
 | 
			
		||||
    class="welcome-screen-center__heading welcome-screen-decor virgil"
 | 
			
		||||
    class="welcome-screen-center__heading welcome-screen-decor excalifont"
 | 
			
		||||
  >
 | 
			
		||||
    All your data is saved locally in your browser.
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -216,23 +216,22 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
 | 
			
		||||
          stroke-width="2"
 | 
			
		||||
          viewBox="0 0 24 24"
 | 
			
		||||
        >
 | 
			
		||||
          <g>
 | 
			
		||||
          <g
 | 
			
		||||
            stroke-width="1.5"
 | 
			
		||||
          >
 | 
			
		||||
            <path
 | 
			
		||||
              d="M0 0h24v24H0z"
 | 
			
		||||
              fill="none"
 | 
			
		||||
              stroke="none"
 | 
			
		||||
            />
 | 
			
		||||
            <path
 | 
			
		||||
              d="M10 12l10 0"
 | 
			
		||||
              d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
 | 
			
		||||
            />
 | 
			
		||||
            <path
 | 
			
		||||
              d="M10 12l4 4"
 | 
			
		||||
              d="M21 12h-13l3 -3"
 | 
			
		||||
            />
 | 
			
		||||
            <path
 | 
			
		||||
              d="M10 12l4 -4"
 | 
			
		||||
            />
 | 
			
		||||
            <path
 | 
			
		||||
              d="M4 4l0 16"
 | 
			
		||||
              d="M11 15l-3 -3"
 | 
			
		||||
            />
 | 
			
		||||
          </g>
 | 
			
		||||
        </svg>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ import { vi } from "vitest";
 | 
			
		||||
import {
 | 
			
		||||
  act,
 | 
			
		||||
  render,
 | 
			
		||||
  updateSceneData,
 | 
			
		||||
  waitFor,
 | 
			
		||||
} from "../../packages/excalidraw/tests/test-utils";
 | 
			
		||||
import ExcalidrawApp from "../App";
 | 
			
		||||
@@ -88,12 +87,12 @@ describe("collaboration", () => {
 | 
			
		||||
    const rect1 = API.createElement({ ...rect1Props });
 | 
			
		||||
    const rect2 = API.createElement({ ...rect2Props });
 | 
			
		||||
 | 
			
		||||
    updateSceneData({
 | 
			
		||||
    API.updateScene({
 | 
			
		||||
      elements: syncInvalidIndices([rect1, rect2]),
 | 
			
		||||
      storeAction: StoreAction.CAPTURE,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    updateSceneData({
 | 
			
		||||
    API.updateScene({
 | 
			
		||||
      elements: syncInvalidIndices([
 | 
			
		||||
        rect1,
 | 
			
		||||
        newElementWith(h.elements[1], { isDeleted: true }),
 | 
			
		||||
@@ -143,7 +142,7 @@ describe("collaboration", () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // simulate force deleting the element remotely
 | 
			
		||||
    updateSceneData({
 | 
			
		||||
    API.updateScene({
 | 
			
		||||
      elements: syncInvalidIndices([rect1]),
 | 
			
		||||
      storeAction: StoreAction.UPDATE,
 | 
			
		||||
    });
 | 
			
		||||
@@ -178,7 +177,7 @@ describe("collaboration", () => {
 | 
			
		||||
    act(() => h.app.actionManager.executeAction(undoAction));
 | 
			
		||||
 | 
			
		||||
    // simulate local update
 | 
			
		||||
    updateSceneData({
 | 
			
		||||
    API.updateScene({
 | 
			
		||||
      elements: syncInvalidIndices([
 | 
			
		||||
        h.elements[0],
 | 
			
		||||
        newElementWith(h.elements[1], { x: 100 }),
 | 
			
		||||
@@ -216,7 +215,7 @@ describe("collaboration", () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // simulate force deleting the element remotely
 | 
			
		||||
    updateSceneData({
 | 
			
		||||
    API.updateScene({
 | 
			
		||||
      elements: syncInvalidIndices([rect1]),
 | 
			
		||||
      storeAction: StoreAction.UPDATE,
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import { atom, useAtom } from "jotai";
 | 
			
		||||
import { useEffect, useLayoutEffect, useState } from "react";
 | 
			
		||||
import { THEME } from "../packages/excalidraw";
 | 
			
		||||
import { EVENT } from "../packages/excalidraw/constants";
 | 
			
		||||
import { Theme } from "../packages/excalidraw/element/types";
 | 
			
		||||
import type { Theme } from "../packages/excalidraw/element/types";
 | 
			
		||||
import { CODES, KEYS } from "../packages/excalidraw/keys";
 | 
			
		||||
import { STORAGE_KEYS } from "./app_constants";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
 | 
			
		||||
import { VitePWA } from "vite-plugin-pwa";
 | 
			
		||||
import checker from "vite-plugin-checker";
 | 
			
		||||
import { createHtmlPlugin } from "vite-plugin-html";
 | 
			
		||||
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
 | 
			
		||||
 | 
			
		||||
// To load .env.local variables
 | 
			
		||||
const envVars = loadEnv("", `../`);
 | 
			
		||||
@@ -22,6 +23,14 @@ export default defineConfig({
 | 
			
		||||
    outDir: "build",
 | 
			
		||||
    rollupOptions: {
 | 
			
		||||
      output: {
 | 
			
		||||
        assetFileNames(chunkInfo) {
 | 
			
		||||
          if (chunkInfo?.name?.endsWith(".woff2")) {
 | 
			
		||||
            // put on root so we are flexible about the CDN path
 | 
			
		||||
            return "[name]-[hash][extname]";
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return "assets/[name]-[hash][extname]";
 | 
			
		||||
        },
 | 
			
		||||
        // Creating separate chunk for locales except for en and percentages.json so they
 | 
			
		||||
        // can be cached at runtime and not merged with
 | 
			
		||||
        // app precache. en.json and percentages.json are needed for first load
 | 
			
		||||
@@ -41,6 +50,7 @@ export default defineConfig({
 | 
			
		||||
    sourcemap: true,
 | 
			
		||||
  },
 | 
			
		||||
  plugins: [
 | 
			
		||||
    woff2BrowserPlugin(),
 | 
			
		||||
    react(),
 | 
			
		||||
    checker({
 | 
			
		||||
      typescript: true,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										32
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								package.json
									
									
									
									
									
								
							@@ -1,6 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "name": "excalidraw-monorepo",
 | 
			
		||||
  "packageManager": "yarn@1.22.22",
 | 
			
		||||
  "workspaces": [
 | 
			
		||||
    "excalidraw-app",
 | 
			
		||||
    "packages/excalidraw",
 | 
			
		||||
@@ -8,19 +9,8 @@
 | 
			
		||||
    "examples/excalidraw",
 | 
			
		||||
    "examples/excalidraw/*"
 | 
			
		||||
  ],
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@excalidraw/random-username": "1.0.0",
 | 
			
		||||
    "@sentry/browser": "6.2.5",
 | 
			
		||||
    "@sentry/integrations": "6.2.5",
 | 
			
		||||
    "firebase": "8.3.3",
 | 
			
		||||
    "i18next-browser-languagedetector": "6.1.4",
 | 
			
		||||
    "idb-keyval": "6.0.3",
 | 
			
		||||
    "jotai": "1.13.1",
 | 
			
		||||
    "react": "18.2.0",
 | 
			
		||||
    "react-dom": "18.2.0",
 | 
			
		||||
    "socket.io-client": "4.7.2"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@babel/plugin-proposal-private-property-in-object": "7.21.11",
 | 
			
		||||
    "@excalidraw/eslint-config": "1.0.3",
 | 
			
		||||
    "@excalidraw/prettier-config": "1.0.2",
 | 
			
		||||
    "@types/chai": "4.3.0",
 | 
			
		||||
@@ -50,19 +40,19 @@
 | 
			
		||||
    "vite-plugin-ejs": "1.7.0",
 | 
			
		||||
    "vite-plugin-pwa": "0.17.4",
 | 
			
		||||
    "vite-plugin-svgr": "2.4.0",
 | 
			
		||||
    "vitest": "1.5.3",
 | 
			
		||||
    "vitest": "1.6.0",
 | 
			
		||||
    "vitest-canvas-mock": "0.3.2"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": "18.0.0 - 22.x.x"
 | 
			
		||||
    "node": "18.0.0 - 20.x.x"
 | 
			
		||||
  },
 | 
			
		||||
  "homepage": ".",
 | 
			
		||||
  "prettier": "@excalidraw/prettier-config",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build-node": "node ./scripts/build-node.js",
 | 
			
		||||
    "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
 | 
			
		||||
    "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
 | 
			
		||||
    "build:version": "node ./scripts/build-version.js",
 | 
			
		||||
    "build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
 | 
			
		||||
    "build:app": "yarn --cwd ./excalidraw-app build:app",
 | 
			
		||||
    "build:version": "yarn --cwd ./excalidraw-app build:version",
 | 
			
		||||
    "build": "yarn --cwd ./excalidraw-app build",
 | 
			
		||||
    "fix:code": "yarn test:code --fix",
 | 
			
		||||
    "fix:other": "yarn prettier --write",
 | 
			
		||||
@@ -86,6 +76,12 @@
 | 
			
		||||
    "autorelease": "node scripts/autorelease.js",
 | 
			
		||||
    "prerelease:excalidraw": "node scripts/prerelease.js",
 | 
			
		||||
    "build:preview": "yarn build && vite preview --port 5000",
 | 
			
		||||
    "release:excalidraw": "node scripts/release.js"
 | 
			
		||||
    "release:excalidraw": "node scripts/release.js",
 | 
			
		||||
    "rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
 | 
			
		||||
    "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
 | 
			
		||||
    "clean-install": "yarn rm:node_modules && yarn install"
 | 
			
		||||
  },
 | 
			
		||||
  "resolutions": {
 | 
			
		||||
    "@types/react": "18.2.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section.
 | 
			
		||||
 | 
			
		||||
### Features
 | 
			
		||||
 | 
			
		||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
 | 
			
		||||
 | 
			
		||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
 | 
			
		||||
 | 
			
		||||
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
 | 
			
		||||
 | 
			
		||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
 | 
			
		||||
 | 
			
		||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import { alignElements, Alignment } from "../align";
 | 
			
		||||
import type { Alignment } from "../align";
 | 
			
		||||
import { alignElements } from "../align";
 | 
			
		||||
import {
 | 
			
		||||
  AlignBottomIcon,
 | 
			
		||||
  AlignLeftIcon,
 | 
			
		||||
@@ -10,13 +11,13 @@ import {
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { isFrameLikeElement } from "../element/typeChecks";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { AppClassProperties, AppState, UIAppState } from "../types";
 | 
			
		||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
import {
 | 
			
		||||
  BOUND_TEXT_PADDING,
 | 
			
		||||
  ROUNDNESS,
 | 
			
		||||
  VERTICAL_ALIGN,
 | 
			
		||||
  TEXT_ALIGN,
 | 
			
		||||
  VERTICAL_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { isTextElement, newElement } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
@@ -23,14 +23,14 @@ import {
 | 
			
		||||
  isTextBindableContainer,
 | 
			
		||||
  isUsingAdaptiveRadius,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextContainer,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { Mutable } from "../utility-types";
 | 
			
		||||
import type { AppState } from "../types";
 | 
			
		||||
import type { Mutable } from "../utility-types";
 | 
			
		||||
import { arrayToMap, getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { syncMovedIndices } from "../fractionalIndex";
 | 
			
		||||
@@ -142,6 +142,7 @@ export const actionBindText = register({
 | 
			
		||||
      containerId: container.id,
 | 
			
		||||
      verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
      textAlign: TEXT_ALIGN.CENTER,
 | 
			
		||||
      autoResize: true,
 | 
			
		||||
    });
 | 
			
		||||
    mutateElement(container, {
 | 
			
		||||
      boundElements: (container.boundElements || []).concat({
 | 
			
		||||
@@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({
 | 
			
		||||
            verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
            boundElements: null,
 | 
			
		||||
            textAlign: TEXT_ALIGN.CENTER,
 | 
			
		||||
            autoResize: true,
 | 
			
		||||
          },
 | 
			
		||||
          false,
 | 
			
		||||
        );
 | 
			
		||||
 
 | 
			
		||||
@@ -18,13 +18,13 @@ import {
 | 
			
		||||
  ZOOM_STEP,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { getCommonBounds, getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getNormalizedZoom } from "../scene";
 | 
			
		||||
import { centerScrollOn } from "../scene/scroll";
 | 
			
		||||
import { getStateForZoom } from "../scene/zoom";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import type { AppState, NormalizedZoomValue } from "../types";
 | 
			
		||||
import { getShortcutKey, updateActiveTool } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { Tooltip } from "../components/Tooltip";
 | 
			
		||||
@@ -35,7 +35,7 @@ import {
 | 
			
		||||
  isHandToolActive,
 | 
			
		||||
} from "../appState";
 | 
			
		||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 | 
			
		||||
import { SceneBounds } from "../element/bounds";
 | 
			
		||||
import type { SceneBounds } from "../element/bounds";
 | 
			
		||||
import { setCursor } from "../cursor";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
 | 
			
		||||
@@ -104,7 +104,7 @@ export const actionClearCanvas = register({
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        stats: appState.stats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
        activeTool:
 | 
			
		||||
          appState.activeTool.type === "image"
 | 
			
		||||
 
 | 
			
		||||
@@ -4,21 +4,28 @@ import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getElementsInGroup } from "../groups";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { fixBindingsAfterDeletion } from "../element/binding";
 | 
			
		||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  isBoundToContainer,
 | 
			
		||||
  isElbowArrow,
 | 
			
		||||
  isFrameLikeElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { updateActiveTool } from "../utils";
 | 
			
		||||
import { TrashIcon } from "../components/icons";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { mutateElbowArrow } from "../element/routing";
 | 
			
		||||
 | 
			
		||||
const deleteSelectedElements = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
) => {
 | 
			
		||||
  const elementsMap = app.scene.getNonDeletedElementsMap();
 | 
			
		||||
  const framesToBeDeleted = new Set(
 | 
			
		||||
    getSelectedElements(
 | 
			
		||||
      elements.filter((el) => isFrameLikeElement(el)),
 | 
			
		||||
@@ -29,6 +36,26 @@ const deleteSelectedElements = (
 | 
			
		||||
  return {
 | 
			
		||||
    elements: elements.map((el) => {
 | 
			
		||||
      if (appState.selectedElementIds[el.id]) {
 | 
			
		||||
        if (el.boundElements) {
 | 
			
		||||
          el.boundElements.forEach((candidate) => {
 | 
			
		||||
            const bound = app.scene
 | 
			
		||||
              .getNonDeletedElementsMap()
 | 
			
		||||
              .get(candidate.id);
 | 
			
		||||
            if (bound && isElbowArrow(bound)) {
 | 
			
		||||
              mutateElement(bound, {
 | 
			
		||||
                startBinding:
 | 
			
		||||
                  el.id === bound.startBinding?.elementId
 | 
			
		||||
                    ? null
 | 
			
		||||
                    : bound.startBinding,
 | 
			
		||||
                endBinding:
 | 
			
		||||
                  el.id === bound.endBinding?.elementId
 | 
			
		||||
                    ? null
 | 
			
		||||
                    : bound.endBinding,
 | 
			
		||||
              });
 | 
			
		||||
              mutateElbowArrow(bound, elementsMap, bound.points);
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        return newElementWith(el, { isDeleted: true });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@@ -130,7 +157,11 @@ export const actionDeleteSelected = register({
 | 
			
		||||
          : endBindingElement,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      LinearElementEditor.deletePoints(element, selectedPointsIndices);
 | 
			
		||||
      LinearElementEditor.deletePoints(
 | 
			
		||||
        element,
 | 
			
		||||
        selectedPointsIndices,
 | 
			
		||||
        elementsMap,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        elements,
 | 
			
		||||
@@ -149,7 +180,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    let { elements: nextElements, appState: nextAppState } =
 | 
			
		||||
      deleteSelectedElements(elements, appState);
 | 
			
		||||
      deleteSelectedElements(elements, appState, app);
 | 
			
		||||
    fixBindingsAfterDeletion(
 | 
			
		||||
      nextElements,
 | 
			
		||||
      elements.filter(({ id }) => appState.selectedElementIds[id]),
 | 
			
		||||
 
 | 
			
		||||
@@ -3,16 +3,17 @@ import {
 | 
			
		||||
  DistributeVerticallyIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { distributeElements, Distribution } from "../distribute";
 | 
			
		||||
import type { Distribution } from "../distribute";
 | 
			
		||||
import { distributeElements } from "../distribute";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { isFrameLikeElement } from "../element/typeChecks";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import type { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { duplicateElement, getNonDeletedElements } from "../element";
 | 
			
		||||
import { isSomeElementSelected } from "../scene";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
@@ -12,9 +12,9 @@ import {
 | 
			
		||||
  getSelectedGroupForElement,
 | 
			
		||||
  getElementsInGroup,
 | 
			
		||||
} from "../groups";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import type { AppState } from "../types";
 | 
			
		||||
import { fixBindingsAfterDuplication } from "../element/binding";
 | 
			
		||||
import { ActionResult } from "./types";
 | 
			
		||||
import type { ActionResult } from "./types";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  bindTextToShapeAfterDuplication,
 | 
			
		||||
@@ -40,12 +40,11 @@ export const actionDuplicateSelection = register({
 | 
			
		||||
  icon: DuplicateIcon,
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, formData, app) => {
 | 
			
		||||
    const elementsMap = app.scene.getNonDeletedElementsMap();
 | 
			
		||||
    // duplicate selected point(s) if editing a line
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const ret = LinearElementEditor.duplicateSelectedPoints(
 | 
			
		||||
        appState,
 | 
			
		||||
        elementsMap,
 | 
			
		||||
        app.scene.getNonDeletedElementsMap(),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (!ret) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Excalidraw } from "../index";
 | 
			
		||||
import { queryByTestId, fireEvent } from "@testing-library/react";
 | 
			
		||||
import { render } from "../tests/test-utils";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { isFrameLikeElement } from "../element/typeChecks";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { isImageFileHandle } from "../data/blob";
 | 
			
		||||
import { nativeFileSystemSupported } from "../data/filesystem";
 | 
			
		||||
import { Theme } from "../element/types";
 | 
			
		||||
import type { Theme } from "../element/types";
 | 
			
		||||
 | 
			
		||||
import "../components/ToolIcon.scss";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ import {
 | 
			
		||||
  bindOrUnbindLinearElement,
 | 
			
		||||
} from "../element/binding";
 | 
			
		||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import type { AppState } from "../types";
 | 
			
		||||
import { resetCursor } from "../cursor";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
 | 
			
		||||
@@ -38,6 +38,7 @@ export const actionFinalize = register({
 | 
			
		||||
            startBindingElement,
 | 
			
		||||
            endBindingElement,
 | 
			
		||||
            elementsMap,
 | 
			
		||||
            scene,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
        return {
 | 
			
		||||
@@ -72,8 +73,8 @@ export const actionFinalize = register({
 | 
			
		||||
 | 
			
		||||
    const multiPointElement = appState.multiElement
 | 
			
		||||
      ? appState.multiElement
 | 
			
		||||
      : appState.editingElement?.type === "freedraw"
 | 
			
		||||
      ? appState.editingElement
 | 
			
		||||
      : appState.newElement?.type === "freedraw"
 | 
			
		||||
      ? appState.newElement
 | 
			
		||||
      : null;
 | 
			
		||||
 | 
			
		||||
    if (multiPointElement) {
 | 
			
		||||
@@ -131,7 +132,13 @@ export const actionFinalize = register({
 | 
			
		||||
          -1,
 | 
			
		||||
          arrayToMap(elements),
 | 
			
		||||
        );
 | 
			
		||||
        maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
 | 
			
		||||
        maybeBindLinearElement(
 | 
			
		||||
          multiPointElement,
 | 
			
		||||
          appState,
 | 
			
		||||
          { x, y },
 | 
			
		||||
          elementsMap,
 | 
			
		||||
          elements,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -169,7 +176,8 @@ export const actionFinalize = register({
 | 
			
		||||
            ? appState.activeTool
 | 
			
		||||
            : activeTool,
 | 
			
		||||
        activeEmbeddable: null,
 | 
			
		||||
        draggingElement: null,
 | 
			
		||||
        newElement: null,
 | 
			
		||||
        selectionElement: null,
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
        editingElement: null,
 | 
			
		||||
        startBoundElement: null,
 | 
			
		||||
@@ -197,7 +205,7 @@ export const actionFinalize = register({
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
    (event.key === KEYS.ESCAPE &&
 | 
			
		||||
      (appState.editingLinearElement !== null ||
 | 
			
		||||
        (!appState.draggingElement && appState.multiElement === null))) ||
 | 
			
		||||
        (!appState.newElement && appState.multiElement === null))) ||
 | 
			
		||||
    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
 | 
			
		||||
      appState.multiElement !== null),
 | 
			
		||||
  PanelComponent: ({ appState, updateData, data }) => (
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  NonDeleted,
 | 
			
		||||
  NonDeletedSceneElementsMap,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { resizeMultipleElements } from "../element/resizeElements";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import type { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { getCommonBoundingBox } from "../element/bounds";
 | 
			
		||||
@@ -124,7 +124,9 @@ const flipElements = (
 | 
			
		||||
 | 
			
		||||
  bindOrUnbindLinearElements(
 | 
			
		||||
    selectedElements.filter(isLinearElement),
 | 
			
		||||
    app,
 | 
			
		||||
    elementsMap,
 | 
			
		||||
    app.scene.getNonDeletedElements(),
 | 
			
		||||
    app.scene,
 | 
			
		||||
    isBindingEnabled(appState),
 | 
			
		||||
    [],
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { removeAllElementsFromFrame } from "../frame";
 | 
			
		||||
import { getFrameChildren } from "../frame";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { AppClassProperties, AppState, UIAppState } from "../types";
 | 
			
		||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
 | 
			
		||||
import { updateActiveTool } from "../utils";
 | 
			
		||||
import { setCursorForShape } from "../cursor";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 
 | 
			
		||||
@@ -17,12 +17,12 @@ import {
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { randomId } from "../random";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import type { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  getElementsInResizingFrame,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,16 @@
 | 
			
		||||
import { Action, ActionResult } from "./types";
 | 
			
		||||
import type { Action, ActionResult } from "./types";
 | 
			
		||||
import { UndoIcon, RedoIcon } from "../components/icons";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { History, HistoryChangedEvent } from "../history";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import type { History } from "../history";
 | 
			
		||||
import { HistoryChangedEvent } from "../history";
 | 
			
		||||
import type { AppState } from "../types";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { isWindows } from "../constants";
 | 
			
		||||
import { SceneElementsMap } from "../element/types";
 | 
			
		||||
import { Store, StoreAction } from "../store";
 | 
			
		||||
import type { SceneElementsMap } from "../element/types";
 | 
			
		||||
import type { Store } from "../store";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { useEmitter } from "../hooks/useEmitter";
 | 
			
		||||
 | 
			
		||||
const writeData = (
 | 
			
		||||
@@ -19,7 +21,9 @@ const writeData = (
 | 
			
		||||
    !appState.multiElement &&
 | 
			
		||||
    !appState.resizingElement &&
 | 
			
		||||
    !appState.editingElement &&
 | 
			
		||||
    !appState.draggingElement
 | 
			
		||||
    !appState.newElement &&
 | 
			
		||||
    !appState.selectedElementsAreBeingDragged &&
 | 
			
		||||
    !appState.selectionElement
 | 
			
		||||
  ) {
 | 
			
		||||
    const result = updater();
 | 
			
		||||
 | 
			
		||||
@@ -48,7 +52,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
 | 
			
		||||
  icon: UndoIcon,
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  viewMode: false,
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
  perform: (elements, appState, value, app) =>
 | 
			
		||||
    writeData(appState, () =>
 | 
			
		||||
      history.undo(
 | 
			
		||||
        arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
 | 
			
		||||
@@ -63,7 +67,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => {
 | 
			
		||||
    const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
 | 
			
		||||
      history.onHistoryChangedEmitter,
 | 
			
		||||
      new HistoryChangedEvent(),
 | 
			
		||||
      new HistoryChangedEvent(
 | 
			
		||||
        history.isUndoStackEmpty,
 | 
			
		||||
        history.isRedoStackEmpty,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
@@ -74,6 +81,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
 | 
			
		||||
        onClick={updateData}
 | 
			
		||||
        size={data?.size || "medium"}
 | 
			
		||||
        disabled={isUndoStackEmpty}
 | 
			
		||||
        data-testid="button-undo"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
@@ -85,7 +93,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
 | 
			
		||||
  icon: RedoIcon,
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  viewMode: false,
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
  perform: (elements, appState, _, app) =>
 | 
			
		||||
    writeData(appState, () =>
 | 
			
		||||
      history.redo(
 | 
			
		||||
        arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
 | 
			
		||||
@@ -101,7 +109,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
 | 
			
		||||
  PanelComponent: ({ updateData, data }) => {
 | 
			
		||||
    const { isRedoStackEmpty } = useEmitter(
 | 
			
		||||
      history.onHistoryChangedEmitter,
 | 
			
		||||
      new HistoryChangedEvent(),
 | 
			
		||||
      new HistoryChangedEvent(
 | 
			
		||||
        history.isUndoStackEmpty,
 | 
			
		||||
        history.isRedoStackEmpty,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
@@ -112,6 +123,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
 | 
			
		||||
        onClick={updateData}
 | 
			
		||||
        size={data?.size || "medium"}
 | 
			
		||||
        disabled={isRedoStackEmpty}
 | 
			
		||||
        data-testid="button-redo"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,12 @@
 | 
			
		||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { ExcalidrawLinearElement } from "../element/types";
 | 
			
		||||
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import type { ExcalidrawLinearElement } from "../element/types";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { lineEditorIcon } from "../components/icons";
 | 
			
		||||
 | 
			
		||||
export const actionToggleLinearEditor = register({
 | 
			
		||||
  name: "toggleLinearEditor",
 | 
			
		||||
@@ -11,18 +14,24 @@ export const actionToggleLinearEditor = register({
 | 
			
		||||
  label: (elements, appState, app) => {
 | 
			
		||||
    const selectedElement = app.scene.getSelectedElements({
 | 
			
		||||
      selectedElementIds: appState.selectedElementIds,
 | 
			
		||||
      includeBoundTextElement: true,
 | 
			
		||||
    })[0] as ExcalidrawLinearElement;
 | 
			
		||||
    return appState.editingLinearElement?.elementId === selectedElement?.id
 | 
			
		||||
      ? "labels.lineEditor.exit"
 | 
			
		||||
    })[0] as ExcalidrawLinearElement | undefined;
 | 
			
		||||
 | 
			
		||||
    return selectedElement?.type === "arrow"
 | 
			
		||||
      ? "labels.lineEditor.editArrow"
 | 
			
		||||
      : "labels.lineEditor.edit";
 | 
			
		||||
  },
 | 
			
		||||
  keywords: ["line"],
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "element",
 | 
			
		||||
  },
 | 
			
		||||
  predicate: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = app.scene.getSelectedElements(appState);
 | 
			
		||||
    if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
 | 
			
		||||
    if (
 | 
			
		||||
      !appState.editingLinearElement &&
 | 
			
		||||
      selectedElements.length === 1 &&
 | 
			
		||||
      isLinearElement(selectedElements[0]) &&
 | 
			
		||||
      !isElbowArrow(selectedElements[0])
 | 
			
		||||
    ) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
@@ -45,4 +54,24 @@ export const actionToggleLinearEditor = register({
 | 
			
		||||
      storeAction: StoreAction.CAPTURE,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ appState, updateData, app }) => {
 | 
			
		||||
    const selectedElement = app.scene.getSelectedElements({
 | 
			
		||||
      selectedElementIds: appState.selectedElementIds,
 | 
			
		||||
    })[0] as ExcalidrawLinearElement;
 | 
			
		||||
 | 
			
		||||
    const label = t(
 | 
			
		||||
      selectedElement.type === "arrow"
 | 
			
		||||
        ? "labels.lineEditor.editArrow"
 | 
			
		||||
        : "labels.lineEditor.edit",
 | 
			
		||||
    );
 | 
			
		||||
    return (
 | 
			
		||||
      <ToolButton
 | 
			
		||||
        type="button"
 | 
			
		||||
        icon={lineEditorIcon}
 | 
			
		||||
        title={label}
 | 
			
		||||
        aria-label={label}
 | 
			
		||||
        onClick={() => updateData(null)}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { getClientColor } from "../clients";
 | 
			
		||||
import { Avatar } from "../components/Avatar";
 | 
			
		||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
 | 
			
		||||
import type { GoToCollaboratorComponentProps } from "../components/UserList";
 | 
			
		||||
import {
 | 
			
		||||
  eyeIcon,
 | 
			
		||||
  microphoneIcon,
 | 
			
		||||
@@ -8,7 +8,7 @@ import {
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { Collaborator } from "../types";
 | 
			
		||||
import type { Collaborator } from "../types";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Excalidraw } from "../index";
 | 
			
		||||
import { queryByTestId } from "@testing-library/react";
 | 
			
		||||
import { render } from "../tests/test-utils";
 | 
			
		||||
@@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
 | 
			
		||||
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
 | 
			
		||||
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
 | 
			
		||||
 | 
			
		||||
const { h } = window;
 | 
			
		||||
 | 
			
		||||
describe("element locking", () => {
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await render(<Excalidraw />);
 | 
			
		||||
@@ -22,7 +21,7 @@ describe("element locking", () => {
 | 
			
		||||
      // just in case we change it in the future
 | 
			
		||||
      expect(color).not.toBe(COLOR_PALETTE.transparent);
 | 
			
		||||
 | 
			
		||||
      h.setState({
 | 
			
		||||
      API.setAppState({
 | 
			
		||||
        currentItemBackgroundColor: color,
 | 
			
		||||
      });
 | 
			
		||||
      const activeColor = queryByTestId(
 | 
			
		||||
@@ -40,14 +39,14 @@ describe("element locking", () => {
 | 
			
		||||
      // just in case we change it in the future
 | 
			
		||||
      expect(color).not.toBe(COLOR_PALETTE.transparent);
 | 
			
		||||
 | 
			
		||||
      h.setState({
 | 
			
		||||
      API.setAppState({
 | 
			
		||||
        currentItemBackgroundColor: color,
 | 
			
		||||
        currentItemFillStyle: "hachure",
 | 
			
		||||
      });
 | 
			
		||||
      const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
 | 
			
		||||
 | 
			
		||||
      expect(hachureFillButton).toHaveClass("active");
 | 
			
		||||
      h.setState({
 | 
			
		||||
      API.setAppState({
 | 
			
		||||
        currentItemFillStyle: "solid",
 | 
			
		||||
      });
 | 
			
		||||
      const solidFillStyle = queryByTestId(document.body, `fill-solid`);
 | 
			
		||||
@@ -57,7 +56,7 @@ describe("element locking", () => {
 | 
			
		||||
    it("should not show fill style when background transparent", () => {
 | 
			
		||||
      UI.clickTool("rectangle");
 | 
			
		||||
 | 
			
		||||
      h.setState({
 | 
			
		||||
      API.setAppState({
 | 
			
		||||
        currentItemBackgroundColor: COLOR_PALETTE.transparent,
 | 
			
		||||
        currentItemFillStyle: "hachure",
 | 
			
		||||
      });
 | 
			
		||||
@@ -69,7 +68,7 @@ describe("element locking", () => {
 | 
			
		||||
    it("should show horizontal text align for text tool", () => {
 | 
			
		||||
      UI.clickTool("text");
 | 
			
		||||
 | 
			
		||||
      h.setState({
 | 
			
		||||
      API.setAppState({
 | 
			
		||||
        currentItemTextAlign: "right",
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
@@ -85,7 +84,7 @@ describe("element locking", () => {
 | 
			
		||||
        backgroundColor: "red",
 | 
			
		||||
        fillStyle: "cross-hatch",
 | 
			
		||||
      });
 | 
			
		||||
      h.elements = [rect];
 | 
			
		||||
      API.setElements([rect]);
 | 
			
		||||
      API.setSelectedElements([rect]);
 | 
			
		||||
 | 
			
		||||
      const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
 | 
			
		||||
@@ -98,7 +97,7 @@ describe("element locking", () => {
 | 
			
		||||
        backgroundColor: COLOR_PALETTE.transparent,
 | 
			
		||||
        fillStyle: "cross-hatch",
 | 
			
		||||
      });
 | 
			
		||||
      h.elements = [rect];
 | 
			
		||||
      API.setElements([rect]);
 | 
			
		||||
      API.setSelectedElements([rect]);
 | 
			
		||||
 | 
			
		||||
      const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
 | 
			
		||||
@@ -114,7 +113,7 @@ describe("element locking", () => {
 | 
			
		||||
        type: "rectangle",
 | 
			
		||||
        strokeWidth: STROKE_WIDTH.thin,
 | 
			
		||||
      });
 | 
			
		||||
      h.elements = [rect1, rect2];
 | 
			
		||||
      API.setElements([rect1, rect2]);
 | 
			
		||||
      API.setSelectedElements([rect1, rect2]);
 | 
			
		||||
 | 
			
		||||
      const thinStrokeWidthButton = queryByTestId(
 | 
			
		||||
@@ -133,7 +132,7 @@ describe("element locking", () => {
 | 
			
		||||
        type: "rectangle",
 | 
			
		||||
        strokeWidth: STROKE_WIDTH.bold,
 | 
			
		||||
      });
 | 
			
		||||
      h.elements = [rect1, rect2];
 | 
			
		||||
      API.setElements([rect1, rect2]);
 | 
			
		||||
      API.setSelectedElements([rect1, rect2]);
 | 
			
		||||
 | 
			
		||||
      expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
 | 
			
		||||
@@ -155,13 +154,15 @@ describe("element locking", () => {
 | 
			
		||||
      });
 | 
			
		||||
      const text = API.createElement({
 | 
			
		||||
        type: "text",
 | 
			
		||||
        fontFamily: FONT_FAMILY.Cascadia,
 | 
			
		||||
        fontFamily: FONT_FAMILY["Comic Shanns"],
 | 
			
		||||
      });
 | 
			
		||||
      h.elements = [rect, text];
 | 
			
		||||
      API.setElements([rect, text]);
 | 
			
		||||
      API.setSelectedElements([rect, text]);
 | 
			
		||||
 | 
			
		||||
      expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
 | 
			
		||||
      expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
 | 
			
		||||
      expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
 | 
			
		||||
        "active",
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,6 @@
 | 
			
		||||
import { AppClassProperties, AppState, Primitive } from "../types";
 | 
			
		||||
import { useEffect, useMemo, useRef, useState } from "react";
 | 
			
		||||
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
 | 
			
		||||
import type { StoreActionType } from "../store";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
 | 
			
		||||
  DEFAULT_ELEMENT_BACKGROUND_PICKS,
 | 
			
		||||
@@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
 | 
			
		||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
 | 
			
		||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
 | 
			
		||||
import { IconPicker } from "../components/IconPicker";
 | 
			
		||||
import { FontPicker } from "../components/FontPicker/FontPicker";
 | 
			
		||||
// TODO barnabasmolnar/editor-redesign
 | 
			
		||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
 | 
			
		||||
// ArrowHead icons
 | 
			
		||||
@@ -38,9 +41,6 @@ import {
 | 
			
		||||
  FontSizeExtraLargeIcon,
 | 
			
		||||
  EdgeSharpIcon,
 | 
			
		||||
  EdgeRoundIcon,
 | 
			
		||||
  FreedrawIcon,
 | 
			
		||||
  FontFamilyNormalIcon,
 | 
			
		||||
  FontFamilyCodeIcon,
 | 
			
		||||
  TextAlignLeftIcon,
 | 
			
		||||
  TextAlignCenterIcon,
 | 
			
		||||
  TextAlignRightIcon,
 | 
			
		||||
@@ -50,8 +50,12 @@ import {
 | 
			
		||||
  ArrowheadDiamondIcon,
 | 
			
		||||
  ArrowheadDiamondOutlineIcon,
 | 
			
		||||
  fontSizeIcon,
 | 
			
		||||
  sharpArrowIcon,
 | 
			
		||||
  roundArrowIcon,
 | 
			
		||||
  elbowArrowIcon,
 | 
			
		||||
} from "../components/icons";
 | 
			
		||||
import {
 | 
			
		||||
  ARROW_TYPE,
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  FONT_FAMILY,
 | 
			
		||||
@@ -65,17 +69,17 @@ import {
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element";
 | 
			
		||||
import { mutateElement, newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { getBoundTextElement } from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  getDefaultLineHeight,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  isArrowElement,
 | 
			
		||||
  isBoundToContainer,
 | 
			
		||||
  isElbowArrow,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isUsingAdaptiveRadius,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  Arrowhead,
 | 
			
		||||
  ExcalidrawBindableElement,
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
@@ -94,9 +98,23 @@ import {
 | 
			
		||||
  isSomeElementSelected,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { arrayToMap, getShortcutKey } from "../utils";
 | 
			
		||||
import {
 | 
			
		||||
  arrayToMap,
 | 
			
		||||
  getFontFamilyString,
 | 
			
		||||
  getShortcutKey,
 | 
			
		||||
  tupleToCoors,
 | 
			
		||||
} from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { Fonts, getLineHeight } from "../fonts";
 | 
			
		||||
import {
 | 
			
		||||
  bindLinearElement,
 | 
			
		||||
  bindPointToSnapToElementOutline,
 | 
			
		||||
  calculateFixedPointForElbowArrowBinding,
 | 
			
		||||
  getHoveredElementForBinding,
 | 
			
		||||
} from "../element/binding";
 | 
			
		||||
import { mutateElbowArrow } from "../element/routing";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
 | 
			
		||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 | 
			
		||||
 | 
			
		||||
@@ -167,7 +185,7 @@ const offsetElementAfterFontResize = (
 | 
			
		||||
  prevElement: ExcalidrawTextElement,
 | 
			
		||||
  nextElement: ExcalidrawTextElement,
 | 
			
		||||
) => {
 | 
			
		||||
  if (isBoundToContainer(nextElement)) {
 | 
			
		||||
  if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
 | 
			
		||||
    return nextElement;
 | 
			
		||||
  }
 | 
			
		||||
  return mutateElement(
 | 
			
		||||
@@ -729,104 +747,388 @@ export const actionIncreaseFontSize = register({
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type ChangeFontFamilyData = Partial<
 | 
			
		||||
  Pick<
 | 
			
		||||
    AppState,
 | 
			
		||||
    "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
 | 
			
		||||
  >
 | 
			
		||||
> & {
 | 
			
		||||
  /** cache of selected & editing elements populated on opened popup */
 | 
			
		||||
  cachedElements?: Map<string, ExcalidrawElement>;
 | 
			
		||||
  /** flag to reset all elements to their cached versions  */
 | 
			
		||||
  resetAll?: true;
 | 
			
		||||
  /** flag to reset all containers to their cached versions */
 | 
			
		||||
  resetContainers?: true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontFamily = register({
 | 
			
		||||
  name: "changeFontFamily",
 | 
			
		||||
  label: "labels.fontFamily",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
    const { cachedElements, resetAll, resetContainers, ...nextAppState } =
 | 
			
		||||
      value as ChangeFontFamilyData;
 | 
			
		||||
 | 
			
		||||
    if (resetAll) {
 | 
			
		||||
      const nextElements = changeProperty(
 | 
			
		||||
        elements,
 | 
			
		||||
        appState,
 | 
			
		||||
        (oldElement) => {
 | 
			
		||||
          if (isTextElement(oldElement)) {
 | 
			
		||||
            const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
              oldElement,
 | 
			
		||||
              {
 | 
			
		||||
                fontFamily: value,
 | 
			
		||||
                lineHeight: getDefaultLineHeight(value),
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              app.scene.getContainerElement(oldElement),
 | 
			
		||||
              app.scene.getNonDeletedElementsMap(),
 | 
			
		||||
            );
 | 
			
		||||
        (element) => {
 | 
			
		||||
          const cachedElement = cachedElements?.get(element.id);
 | 
			
		||||
          if (cachedElement) {
 | 
			
		||||
            const newElement = newElementWith(element, {
 | 
			
		||||
              ...cachedElement,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return oldElement;
 | 
			
		||||
          return element;
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        elements: nextElements,
 | 
			
		||||
        appState: {
 | 
			
		||||
          ...appState,
 | 
			
		||||
          ...nextAppState,
 | 
			
		||||
        },
 | 
			
		||||
        storeAction: StoreAction.UPDATE,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { currentItemFontFamily, currentHoveredFontFamily } = value;
 | 
			
		||||
 | 
			
		||||
    let nexStoreAction: StoreActionType = StoreAction.NONE;
 | 
			
		||||
    let nextFontFamily: FontFamilyValues | undefined;
 | 
			
		||||
    let skipOnHoverRender = false;
 | 
			
		||||
 | 
			
		||||
    if (currentItemFontFamily) {
 | 
			
		||||
      nextFontFamily = currentItemFontFamily;
 | 
			
		||||
      nexStoreAction = StoreAction.CAPTURE;
 | 
			
		||||
    } else if (currentHoveredFontFamily) {
 | 
			
		||||
      nextFontFamily = currentHoveredFontFamily;
 | 
			
		||||
      nexStoreAction = StoreAction.NONE;
 | 
			
		||||
 | 
			
		||||
      const selectedTextElements = getSelectedElements(elements, appState, {
 | 
			
		||||
        includeBoundTextElement: true,
 | 
			
		||||
      }).filter((element) => isTextElement(element));
 | 
			
		||||
 | 
			
		||||
      // skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
 | 
			
		||||
      if (selectedTextElements.length > 200) {
 | 
			
		||||
        skipOnHoverRender = true;
 | 
			
		||||
      } else {
 | 
			
		||||
        let i = 0;
 | 
			
		||||
        let textLengthAccumulator = 0;
 | 
			
		||||
 | 
			
		||||
        while (
 | 
			
		||||
          i < selectedTextElements.length &&
 | 
			
		||||
          textLengthAccumulator < 5000
 | 
			
		||||
        ) {
 | 
			
		||||
          const textElement = selectedTextElements[i] as ExcalidrawTextElement;
 | 
			
		||||
          textLengthAccumulator += textElement?.originalText.length || 0;
 | 
			
		||||
          i++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (textLengthAccumulator > 5000) {
 | 
			
		||||
          skipOnHoverRender = true;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const result = {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemFontFamily: value,
 | 
			
		||||
        ...nextAppState,
 | 
			
		||||
      },
 | 
			
		||||
      storeAction: StoreAction.CAPTURE,
 | 
			
		||||
      storeAction: nexStoreAction,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (nextFontFamily && !skipOnHoverRender) {
 | 
			
		||||
      const elementContainerMapping = new Map<
 | 
			
		||||
        ExcalidrawTextElement,
 | 
			
		||||
        ExcalidrawElement | null
 | 
			
		||||
      >();
 | 
			
		||||
      let uniqueChars = new Set<string>();
 | 
			
		||||
      let skipFontFaceCheck = false;
 | 
			
		||||
 | 
			
		||||
      const fontsCache = Array.from(Fonts.loadedFontsCache.values());
 | 
			
		||||
      const fontFamily = Object.entries(FONT_FAMILY).find(
 | 
			
		||||
        ([_, value]) => value === nextFontFamily,
 | 
			
		||||
      )?.[0];
 | 
			
		||||
 | 
			
		||||
      // skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
 | 
			
		||||
      if (
 | 
			
		||||
        currentHoveredFontFamily &&
 | 
			
		||||
        fontFamily &&
 | 
			
		||||
        fontsCache.some((sig) => sig.startsWith(fontFamily))
 | 
			
		||||
      ) {
 | 
			
		||||
        skipFontFaceCheck = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // following causes re-render so make sure we changed the family
 | 
			
		||||
      // otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
 | 
			
		||||
      Object.assign(result, {
 | 
			
		||||
        elements: changeProperty(
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (oldElement) => {
 | 
			
		||||
            if (
 | 
			
		||||
              isTextElement(oldElement) &&
 | 
			
		||||
              (oldElement.fontFamily !== nextFontFamily ||
 | 
			
		||||
                currentItemFontFamily) // force update on selection
 | 
			
		||||
            ) {
 | 
			
		||||
              const newElement: ExcalidrawTextElement = newElementWith(
 | 
			
		||||
                oldElement,
 | 
			
		||||
                {
 | 
			
		||||
                  fontFamily: nextFontFamily,
 | 
			
		||||
                  lineHeight: getLineHeight(nextFontFamily!),
 | 
			
		||||
                },
 | 
			
		||||
              );
 | 
			
		||||
 | 
			
		||||
              const cachedContainer =
 | 
			
		||||
                cachedElements?.get(oldElement.containerId || "") || {};
 | 
			
		||||
 | 
			
		||||
              const container = app.scene.getContainerElement(oldElement);
 | 
			
		||||
 | 
			
		||||
              if (resetContainers && container && cachedContainer) {
 | 
			
		||||
                // reset the container back to it's cached version
 | 
			
		||||
                mutateElement(container, { ...cachedContainer }, false);
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (!skipFontFaceCheck) {
 | 
			
		||||
                uniqueChars = new Set([
 | 
			
		||||
                  ...uniqueChars,
 | 
			
		||||
                  ...Array.from(newElement.originalText),
 | 
			
		||||
                ]);
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              elementContainerMapping.set(newElement, container);
 | 
			
		||||
 | 
			
		||||
              return newElement;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return oldElement;
 | 
			
		||||
          },
 | 
			
		||||
          true,
 | 
			
		||||
        ),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // size is irrelevant, but necessary
 | 
			
		||||
      const fontString = `10px ${getFontFamilyString({
 | 
			
		||||
        fontFamily: nextFontFamily,
 | 
			
		||||
      })}`;
 | 
			
		||||
      const chars = Array.from(uniqueChars.values()).join();
 | 
			
		||||
 | 
			
		||||
      if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
 | 
			
		||||
        // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
 | 
			
		||||
        for (const [element, container] of elementContainerMapping) {
 | 
			
		||||
          // trigger synchronous redraw
 | 
			
		||||
          redrawTextBoundingBox(
 | 
			
		||||
            element,
 | 
			
		||||
            container,
 | 
			
		||||
            app.scene.getNonDeletedElementsMap(),
 | 
			
		||||
            false,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
 | 
			
		||||
        window.document.fonts.load(fontString, chars).then((fontFaces) => {
 | 
			
		||||
          for (const [element, container] of elementContainerMapping) {
 | 
			
		||||
            // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
 | 
			
		||||
            const latestElement = app.scene.getElement(element.id);
 | 
			
		||||
            const latestContainer = container
 | 
			
		||||
              ? app.scene.getElement(container.id)
 | 
			
		||||
              : null;
 | 
			
		||||
 | 
			
		||||
            if (latestElement) {
 | 
			
		||||
              // trigger async redraw
 | 
			
		||||
              redrawTextBoundingBox(
 | 
			
		||||
                latestElement as ExcalidrawTextElement,
 | 
			
		||||
                latestContainer,
 | 
			
		||||
                app.scene.getNonDeletedElementsMap(),
 | 
			
		||||
                false,
 | 
			
		||||
              );
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // trigger update once we've mutated all the elements, which also updates our cache
 | 
			
		||||
          app.fonts.onLoaded(fontFaces);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData, app }) => {
 | 
			
		||||
    const options: {
 | 
			
		||||
      value: FontFamilyValues;
 | 
			
		||||
      text: string;
 | 
			
		||||
      icon: JSX.Element;
 | 
			
		||||
      testId: string;
 | 
			
		||||
    }[] = [
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Virgil,
 | 
			
		||||
        text: t("labels.handDrawn"),
 | 
			
		||||
        icon: FreedrawIcon,
 | 
			
		||||
        testId: "font-family-virgil",
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Helvetica,
 | 
			
		||||
        text: t("labels.normal"),
 | 
			
		||||
        icon: FontFamilyNormalIcon,
 | 
			
		||||
        testId: "font-family-normal",
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        value: FONT_FAMILY.Cascadia,
 | 
			
		||||
        text: t("labels.code"),
 | 
			
		||||
        icon: FontFamilyCodeIcon,
 | 
			
		||||
        testId: "font-family-code",
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
  PanelComponent: ({ elements, appState, app, updateData }) => {
 | 
			
		||||
    const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
 | 
			
		||||
    const prevSelectedFontFamilyRef = useRef<number | null>(null);
 | 
			
		||||
    // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
 | 
			
		||||
    const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
 | 
			
		||||
    const isUnmounted = useRef(true);
 | 
			
		||||
 | 
			
		||||
    const selectedFontFamily = useMemo(() => {
 | 
			
		||||
      const getFontFamily = (
 | 
			
		||||
        elementsArray: readonly ExcalidrawElement[],
 | 
			
		||||
        elementsMap: Map<string, ExcalidrawElement>,
 | 
			
		||||
      ) =>
 | 
			
		||||
        getFormValue(
 | 
			
		||||
          elementsArray,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => {
 | 
			
		||||
            if (isTextElement(element)) {
 | 
			
		||||
              return element.fontFamily;
 | 
			
		||||
            }
 | 
			
		||||
            const boundTextElement = getBoundTextElement(element, elementsMap);
 | 
			
		||||
            if (boundTextElement) {
 | 
			
		||||
              return boundTextElement.fontFamily;
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
          },
 | 
			
		||||
          (element) =>
 | 
			
		||||
            isTextElement(element) ||
 | 
			
		||||
            getBoundTextElement(element, elementsMap) !== null,
 | 
			
		||||
          (hasSelection) =>
 | 
			
		||||
            hasSelection
 | 
			
		||||
              ? null
 | 
			
		||||
              : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
      // popup opened, use cached elements
 | 
			
		||||
      if (
 | 
			
		||||
        batchedData.openPopup === "fontFamily" &&
 | 
			
		||||
        appState.openPopup === "fontFamily"
 | 
			
		||||
      ) {
 | 
			
		||||
        return getFontFamily(
 | 
			
		||||
          Array.from(cachedElementsRef.current?.values() ?? []),
 | 
			
		||||
          cachedElementsRef.current,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // popup closed, use all elements
 | 
			
		||||
      if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
 | 
			
		||||
        return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
 | 
			
		||||
      return prevSelectedFontFamilyRef.current;
 | 
			
		||||
    }, [batchedData.openPopup, appState, elements, app.scene]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      prevSelectedFontFamilyRef.current = selectedFontFamily;
 | 
			
		||||
    }, [selectedFontFamily]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (Object.keys(batchedData).length) {
 | 
			
		||||
        updateData(batchedData);
 | 
			
		||||
        // reset the data after we've used the data
 | 
			
		||||
        setBatchedData({});
 | 
			
		||||
      }
 | 
			
		||||
      // call update only on internal state changes
 | 
			
		||||
      // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
    }, [batchedData]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      isUnmounted.current = false;
 | 
			
		||||
 | 
			
		||||
      return () => {
 | 
			
		||||
        isUnmounted.current = true;
 | 
			
		||||
      };
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.fontFamily")}</legend>
 | 
			
		||||
        <ButtonIconSelect<FontFamilyValues | false>
 | 
			
		||||
          group="font-family"
 | 
			
		||||
          options={options}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) => {
 | 
			
		||||
              if (isTextElement(element)) {
 | 
			
		||||
                return element.fontFamily;
 | 
			
		||||
        <FontPicker
 | 
			
		||||
          isOpened={appState.openPopup === "fontFamily"}
 | 
			
		||||
          selectedFontFamily={selectedFontFamily}
 | 
			
		||||
          hoveredFontFamily={appState.currentHoveredFontFamily}
 | 
			
		||||
          onSelect={(fontFamily) => {
 | 
			
		||||
            setBatchedData({
 | 
			
		||||
              openPopup: null,
 | 
			
		||||
              currentHoveredFontFamily: null,
 | 
			
		||||
              currentItemFontFamily: fontFamily,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // defensive clear so immediate close won't abuse the cached elements
 | 
			
		||||
            cachedElementsRef.current.clear();
 | 
			
		||||
          }}
 | 
			
		||||
          onHover={(fontFamily) => {
 | 
			
		||||
            setBatchedData({
 | 
			
		||||
              currentHoveredFontFamily: fontFamily,
 | 
			
		||||
              cachedElements: new Map(cachedElementsRef.current),
 | 
			
		||||
              resetContainers: true,
 | 
			
		||||
            });
 | 
			
		||||
          }}
 | 
			
		||||
          onLeave={() => {
 | 
			
		||||
            setBatchedData({
 | 
			
		||||
              currentHoveredFontFamily: null,
 | 
			
		||||
              cachedElements: new Map(cachedElementsRef.current),
 | 
			
		||||
              resetAll: true,
 | 
			
		||||
            });
 | 
			
		||||
          }}
 | 
			
		||||
          onPopupChange={(open) => {
 | 
			
		||||
            if (open) {
 | 
			
		||||
              // open, populate the cache from scratch
 | 
			
		||||
              cachedElementsRef.current.clear();
 | 
			
		||||
 | 
			
		||||
              const { editingElement } = appState;
 | 
			
		||||
 | 
			
		||||
              if (editingElement?.type === "text") {
 | 
			
		||||
                // retrieve the latest version from the scene, as `editingElement` isn't mutated
 | 
			
		||||
                const latestEditingElement = app.scene.getElement(
 | 
			
		||||
                  editingElement.id,
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                // inside the wysiwyg editor
 | 
			
		||||
                cachedElementsRef.current.set(
 | 
			
		||||
                  editingElement.id,
 | 
			
		||||
                  newElementWith(
 | 
			
		||||
                    latestEditingElement || editingElement,
 | 
			
		||||
                    {},
 | 
			
		||||
                    true,
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              } else {
 | 
			
		||||
                const selectedElements = getSelectedElements(
 | 
			
		||||
                  elements,
 | 
			
		||||
                  appState,
 | 
			
		||||
                  {
 | 
			
		||||
                    includeBoundTextElement: true,
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                for (const element of selectedElements) {
 | 
			
		||||
                  cachedElementsRef.current.set(
 | 
			
		||||
                    element.id,
 | 
			
		||||
                    newElementWith(element, {}, true),
 | 
			
		||||
                  );
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
              const boundTextElement = getBoundTextElement(
 | 
			
		||||
                element,
 | 
			
		||||
                app.scene.getNonDeletedElementsMap(),
 | 
			
		||||
              );
 | 
			
		||||
              if (boundTextElement) {
 | 
			
		||||
                return boundTextElement.fontFamily;
 | 
			
		||||
 | 
			
		||||
              setBatchedData({
 | 
			
		||||
                openPopup: "fontFamily",
 | 
			
		||||
              });
 | 
			
		||||
            } else {
 | 
			
		||||
              // close, use the cache and clear it afterwards
 | 
			
		||||
              const data = {
 | 
			
		||||
                openPopup: null,
 | 
			
		||||
                currentHoveredFontFamily: null,
 | 
			
		||||
                cachedElements: new Map(cachedElementsRef.current),
 | 
			
		||||
                resetAll: true,
 | 
			
		||||
              } as ChangeFontFamilyData;
 | 
			
		||||
 | 
			
		||||
              if (isUnmounted.current) {
 | 
			
		||||
                // in case the component was unmounted by the parent, trigger the update directly
 | 
			
		||||
                updateData({ ...batchedData, ...data });
 | 
			
		||||
              } else {
 | 
			
		||||
                setBatchedData(data);
 | 
			
		||||
              }
 | 
			
		||||
              return null;
 | 
			
		||||
            },
 | 
			
		||||
            (element) =>
 | 
			
		||||
              isTextElement(element) ||
 | 
			
		||||
              getBoundTextElement(
 | 
			
		||||
                element,
 | 
			
		||||
                app.scene.getNonDeletedElementsMap(),
 | 
			
		||||
              ) !== null,
 | 
			
		||||
            (hasSelection) =>
 | 
			
		||||
              hasSelection
 | 
			
		||||
                ? null
 | 
			
		||||
                : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
 | 
			
		||||
          )}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
 | 
			
		||||
              cachedElementsRef.current.clear();
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    );
 | 
			
		||||
@@ -1019,8 +1321,12 @@ export const actionChangeRoundness = register({
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (isElbowArrow(el)) {
 | 
			
		||||
          return el;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return newElementWith(el, {
 | 
			
		||||
          roundness:
 | 
			
		||||
            value === "round"
 | 
			
		||||
              ? {
 | 
			
		||||
@@ -1029,8 +1335,8 @@ export const actionChangeRoundness = register({
 | 
			
		||||
                    : ROUNDNESS.PROPORTIONAL_RADIUS,
 | 
			
		||||
                }
 | 
			
		||||
              : null,
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
        });
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemRoundness: value,
 | 
			
		||||
@@ -1070,7 +1376,8 @@ export const actionChangeRoundness = register({
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) =>
 | 
			
		||||
              hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
 | 
			
		||||
            (element) => element.hasOwnProperty("roundness"),
 | 
			
		||||
            (element) =>
 | 
			
		||||
              !isArrowElement(element) && element.hasOwnProperty("roundness"),
 | 
			
		||||
            (hasSelection) =>
 | 
			
		||||
              hasSelection ? null : appState.currentItemRoundness,
 | 
			
		||||
          )}
 | 
			
		||||
@@ -1233,3 +1540,219 @@ export const actionChangeArrowhead = register({
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionChangeArrowType = register({
 | 
			
		||||
  name: "changeArrowType",
 | 
			
		||||
  label: "Change arrow types",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) => {
 | 
			
		||||
        if (!isArrowElement(el)) {
 | 
			
		||||
          return el;
 | 
			
		||||
        }
 | 
			
		||||
        const newElement = newElementWith(el, {
 | 
			
		||||
          roundness:
 | 
			
		||||
            value === ARROW_TYPE.round
 | 
			
		||||
              ? {
 | 
			
		||||
                  type: ROUNDNESS.PROPORTIONAL_RADIUS,
 | 
			
		||||
                }
 | 
			
		||||
              : null,
 | 
			
		||||
          elbowed: value === ARROW_TYPE.elbow,
 | 
			
		||||
          points:
 | 
			
		||||
            value === ARROW_TYPE.elbow || el.elbowed
 | 
			
		||||
              ? [el.points[0], el.points[el.points.length - 1]]
 | 
			
		||||
              : el.points,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (isElbowArrow(newElement)) {
 | 
			
		||||
          const elementsMap = app.scene.getNonDeletedElementsMap();
 | 
			
		||||
 | 
			
		||||
          app.dismissLinearEditor();
 | 
			
		||||
 | 
			
		||||
          const startGlobalPoint =
 | 
			
		||||
            LinearElementEditor.getPointAtIndexGlobalCoordinates(
 | 
			
		||||
              newElement,
 | 
			
		||||
              0,
 | 
			
		||||
              elementsMap,
 | 
			
		||||
            );
 | 
			
		||||
          const endGlobalPoint =
 | 
			
		||||
            LinearElementEditor.getPointAtIndexGlobalCoordinates(
 | 
			
		||||
              newElement,
 | 
			
		||||
              -1,
 | 
			
		||||
              elementsMap,
 | 
			
		||||
            );
 | 
			
		||||
          const startHoveredElement =
 | 
			
		||||
            !newElement.startBinding &&
 | 
			
		||||
            getHoveredElementForBinding(
 | 
			
		||||
              tupleToCoors(startGlobalPoint),
 | 
			
		||||
              elements,
 | 
			
		||||
              elementsMap,
 | 
			
		||||
              true,
 | 
			
		||||
            );
 | 
			
		||||
          const endHoveredElement =
 | 
			
		||||
            !newElement.endBinding &&
 | 
			
		||||
            getHoveredElementForBinding(
 | 
			
		||||
              tupleToCoors(endGlobalPoint),
 | 
			
		||||
              elements,
 | 
			
		||||
              elementsMap,
 | 
			
		||||
              true,
 | 
			
		||||
            );
 | 
			
		||||
          const startElement = startHoveredElement
 | 
			
		||||
            ? startHoveredElement
 | 
			
		||||
            : newElement.startBinding &&
 | 
			
		||||
              (elementsMap.get(
 | 
			
		||||
                newElement.startBinding.elementId,
 | 
			
		||||
              ) as ExcalidrawBindableElement);
 | 
			
		||||
          const endElement = endHoveredElement
 | 
			
		||||
            ? endHoveredElement
 | 
			
		||||
            : newElement.endBinding &&
 | 
			
		||||
              (elementsMap.get(
 | 
			
		||||
                newElement.endBinding.elementId,
 | 
			
		||||
              ) as ExcalidrawBindableElement);
 | 
			
		||||
 | 
			
		||||
          const finalStartPoint = startHoveredElement
 | 
			
		||||
            ? bindPointToSnapToElementOutline(
 | 
			
		||||
                startGlobalPoint,
 | 
			
		||||
                endGlobalPoint,
 | 
			
		||||
                startHoveredElement,
 | 
			
		||||
                elementsMap,
 | 
			
		||||
              )
 | 
			
		||||
            : startGlobalPoint;
 | 
			
		||||
          const finalEndPoint = endHoveredElement
 | 
			
		||||
            ? bindPointToSnapToElementOutline(
 | 
			
		||||
                endGlobalPoint,
 | 
			
		||||
                startGlobalPoint,
 | 
			
		||||
                endHoveredElement,
 | 
			
		||||
                elementsMap,
 | 
			
		||||
              )
 | 
			
		||||
            : endGlobalPoint;
 | 
			
		||||
 | 
			
		||||
          startHoveredElement &&
 | 
			
		||||
            bindLinearElement(
 | 
			
		||||
              newElement,
 | 
			
		||||
              startHoveredElement,
 | 
			
		||||
              "start",
 | 
			
		||||
              elementsMap,
 | 
			
		||||
            );
 | 
			
		||||
          endHoveredElement &&
 | 
			
		||||
            bindLinearElement(
 | 
			
		||||
              newElement,
 | 
			
		||||
              endHoveredElement,
 | 
			
		||||
              "end",
 | 
			
		||||
              elementsMap,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
          mutateElbowArrow(
 | 
			
		||||
            newElement,
 | 
			
		||||
            elementsMap,
 | 
			
		||||
            [finalStartPoint, finalEndPoint].map(
 | 
			
		||||
              (point) =>
 | 
			
		||||
                [point[0] - newElement.x, point[1] - newElement.y] as Point,
 | 
			
		||||
            ),
 | 
			
		||||
            [0, 0],
 | 
			
		||||
            {
 | 
			
		||||
              ...(startElement && newElement.startBinding
 | 
			
		||||
                ? {
 | 
			
		||||
                    startBinding: {
 | 
			
		||||
                      // @ts-ignore TS cannot discern check above
 | 
			
		||||
                      ...newElement.startBinding!,
 | 
			
		||||
                      ...calculateFixedPointForElbowArrowBinding(
 | 
			
		||||
                        newElement,
 | 
			
		||||
                        startElement,
 | 
			
		||||
                        "start",
 | 
			
		||||
                        elementsMap,
 | 
			
		||||
                      ),
 | 
			
		||||
                    },
 | 
			
		||||
                  }
 | 
			
		||||
                : {}),
 | 
			
		||||
              ...(endElement && newElement.endBinding
 | 
			
		||||
                ? {
 | 
			
		||||
                    endBinding: {
 | 
			
		||||
                      // @ts-ignore TS cannot discern check above
 | 
			
		||||
                      ...newElement.endBinding,
 | 
			
		||||
                      ...calculateFixedPointForElbowArrowBinding(
 | 
			
		||||
                        newElement,
 | 
			
		||||
                        endElement,
 | 
			
		||||
                        "end",
 | 
			
		||||
                        elementsMap,
 | 
			
		||||
                      ),
 | 
			
		||||
                    },
 | 
			
		||||
                  }
 | 
			
		||||
                : {}),
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          mutateElement(
 | 
			
		||||
            newElement,
 | 
			
		||||
            {
 | 
			
		||||
              startBinding: newElement.startBinding
 | 
			
		||||
                ? { ...newElement.startBinding, fixedPoint: null }
 | 
			
		||||
                : null,
 | 
			
		||||
              endBinding: newElement.endBinding
 | 
			
		||||
                ? { ...newElement.endBinding, fixedPoint: null }
 | 
			
		||||
                : null,
 | 
			
		||||
            },
 | 
			
		||||
            false,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return newElement;
 | 
			
		||||
      }),
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        currentItemArrowType: value,
 | 
			
		||||
      },
 | 
			
		||||
      storeAction: StoreAction.CAPTURE,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  PanelComponent: ({ elements, appState, updateData }) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <legend>{t("labels.arrowtypes")}</legend>
 | 
			
		||||
        <ButtonIconSelect
 | 
			
		||||
          group="arrowtypes"
 | 
			
		||||
          options={[
 | 
			
		||||
            {
 | 
			
		||||
              value: ARROW_TYPE.sharp,
 | 
			
		||||
              text: t("labels.arrowtype_sharp"),
 | 
			
		||||
              icon: sharpArrowIcon,
 | 
			
		||||
              testId: "sharp-arrow",
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: ARROW_TYPE.round,
 | 
			
		||||
              text: t("labels.arrowtype_round"),
 | 
			
		||||
              icon: roundArrowIcon,
 | 
			
		||||
              testId: "round-arrow",
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              value: ARROW_TYPE.elbow,
 | 
			
		||||
              text: t("labels.arrowtype_elbowed"),
 | 
			
		||||
              icon: elbowArrowIcon,
 | 
			
		||||
              testId: "elbow-arrow",
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
          value={getFormValue(
 | 
			
		||||
            elements,
 | 
			
		||||
            appState,
 | 
			
		||||
            (element) => {
 | 
			
		||||
              if (isArrowElement(element)) {
 | 
			
		||||
                return element.elbowed
 | 
			
		||||
                  ? ARROW_TYPE.elbow
 | 
			
		||||
                  : element.roundness
 | 
			
		||||
                  ? ARROW_TYPE.round
 | 
			
		||||
                  : ARROW_TYPE.sharp;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return null;
 | 
			
		||||
            },
 | 
			
		||||
            (element) => isArrowElement(element),
 | 
			
		||||
            (hasSelection) =>
 | 
			
		||||
              hasSelection ? null : appState.currentItemArrowType,
 | 
			
		||||
          )}
 | 
			
		||||
          onChange={(value) => updateData(value)}
 | 
			
		||||
        />
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import { KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { selectGroupsForSelectedElements } from "../groups";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { isLinearElement } from "../element/typeChecks";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
 | 
			
		||||
 
 | 
			
		||||
@@ -12,10 +12,7 @@ import {
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  getDefaultLineHeight,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import { getBoundTextElement } from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  hasBoundTextElement,
 | 
			
		||||
  canApplyRoundnessTypeToElement,
 | 
			
		||||
@@ -24,9 +21,10 @@ import {
 | 
			
		||||
  isArrowElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import type { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { paintIcon } from "../components/icons";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import { getLineHeight } from "../fonts";
 | 
			
		||||
 | 
			
		||||
// `copiedStyles` is exported only for tests.
 | 
			
		||||
export let copiedStyles: string = "{}";
 | 
			
		||||
@@ -122,7 +120,7 @@ export const actionPasteStyles = register({
 | 
			
		||||
                DEFAULT_TEXT_ALIGN,
 | 
			
		||||
              lineHeight:
 | 
			
		||||
                (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
 | 
			
		||||
                getDefaultLineHeight(fontFamily),
 | 
			
		||||
                getLineHeight(fontFamily),
 | 
			
		||||
            });
 | 
			
		||||
            let container = null;
 | 
			
		||||
            if (newElement.containerId) {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										48
									
								
								packages/excalidraw/actions/actionTextAutoResize.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								packages/excalidraw/actions/actionTextAutoResize.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
import { isTextElement } from "../element";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { measureText } from "../element/textElement";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
import type { AppClassProperties } from "../types";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionTextAutoResize = register({
 | 
			
		||||
  name: "autoResize",
 | 
			
		||||
  label: "labels.autoResize",
 | 
			
		||||
  icon: null,
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
    return (
 | 
			
		||||
      selectedElements.length === 1 &&
 | 
			
		||||
      isTextElement(selectedElements[0]) &&
 | 
			
		||||
      !selectedElements[0].autoResize
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
      elements: elements.map((element) => {
 | 
			
		||||
        if (element.id === selectedElements[0].id && isTextElement(element)) {
 | 
			
		||||
          const metrics = measureText(
 | 
			
		||||
            element.originalText,
 | 
			
		||||
            getFontString(element),
 | 
			
		||||
            element.lineHeight,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          return newElementWith(element, {
 | 
			
		||||
            autoResize: true,
 | 
			
		||||
            width: metrics.width,
 | 
			
		||||
            height: metrics.height,
 | 
			
		||||
            text: element.originalText,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        return element;
 | 
			
		||||
      }),
 | 
			
		||||
      storeAction: StoreAction.CAPTURE,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import type { AppState } from "../types";
 | 
			
		||||
import { gridIcon } from "../components/icons";
 | 
			
		||||
import { StoreAction } from "../store";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,21 +5,22 @@ import { StoreAction } from "../store";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
  label: "stats.title",
 | 
			
		||||
  label: "stats.fullTitle",
 | 
			
		||||
  icon: abacusIcon,
 | 
			
		||||
  paletteName: "Toggle stats",
 | 
			
		||||
  viewMode: true,
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  keywords: ["edit", "attributes", "customize"],
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        showStats: !this.checked!(appState),
 | 
			
		||||
        stats: { ...appState.stats, open: !this.checked!(appState) },
 | 
			
		||||
      },
 | 
			
		||||
      storeAction: StoreAction.NONE,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  checked: (appState) => appState.showStats,
 | 
			
		||||
  checked: (appState) => appState.stats.open,
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ import { StoreAction } from "../store";
 | 
			
		||||
export const actionSendBackward = register({
 | 
			
		||||
  name: "sendBackward",
 | 
			
		||||
  label: "labels.sendBackward",
 | 
			
		||||
  keywords: ["move down", "zindex", "layer"],
 | 
			
		||||
  icon: SendBackwardIcon,
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
@@ -49,6 +50,7 @@ export const actionSendBackward = register({
 | 
			
		||||
export const actionBringForward = register({
 | 
			
		||||
  name: "bringForward",
 | 
			
		||||
  label: "labels.bringForward",
 | 
			
		||||
  keywords: ["move up", "zindex", "layer"],
 | 
			
		||||
  icon: BringForwardIcon,
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
@@ -78,6 +80,7 @@ export const actionBringForward = register({
 | 
			
		||||
export const actionSendToBack = register({
 | 
			
		||||
  name: "sendToBack",
 | 
			
		||||
  label: "labels.sendToBack",
 | 
			
		||||
  keywords: ["move down", "zindex", "layer"],
 | 
			
		||||
  icon: SendToBackIcon,
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
@@ -114,6 +117,7 @@ export const actionSendToBack = register({
 | 
			
		||||
export const actionBringToFront = register({
 | 
			
		||||
  name: "bringToFront",
 | 
			
		||||
  label: "labels.bringToFront",
 | 
			
		||||
  keywords: ["move up", "zindex", "layer"],
 | 
			
		||||
  icon: BringToFrontIcon,
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  Action,
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
@@ -7,8 +7,11 @@ import {
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
  ActionSource,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import type { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { isPromiseLike } from "../utils";
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Action } from "./types";
 | 
			
		||||
import type { Action } from "./types";
 | 
			
		||||
 | 
			
		||||
export let actions: readonly Action[] = [];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
import { isDarwin } from "../constants";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { SubtypeOf } from "../utility-types";
 | 
			
		||||
import type { SubtypeOf } from "../utility-types";
 | 
			
		||||
import { getShortcutKey } from "../utils";
 | 
			
		||||
import { ActionName } from "./types";
 | 
			
		||||
import type { ActionName } from "./types";
 | 
			
		||||
 | 
			
		||||
export type ShortcutName =
 | 
			
		||||
  | SubtypeOf<
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,17 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
 | 
			
		||||
import {
 | 
			
		||||
import type React from "react";
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  OrderedExcalidrawElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import type {
 | 
			
		||||
  AppClassProperties,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  UIAppState,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { MarkOptional } from "../utility-types";
 | 
			
		||||
import { StoreActionType } from "../store";
 | 
			
		||||
import type { MarkOptional } from "../utility-types";
 | 
			
		||||
import type { StoreActionType } from "../store";
 | 
			
		||||
 | 
			
		||||
export type ActionSource =
 | 
			
		||||
  | "ui"
 | 
			
		||||
@@ -67,6 +70,7 @@ export type ActionName =
 | 
			
		||||
  | "changeSloppiness"
 | 
			
		||||
  | "changeStrokeStyle"
 | 
			
		||||
  | "changeArrowhead"
 | 
			
		||||
  | "changeArrowType"
 | 
			
		||||
  | "changeOpacity"
 | 
			
		||||
  | "changeFontSize"
 | 
			
		||||
  | "toggleCanvasMenu"
 | 
			
		||||
@@ -131,7 +135,9 @@ export type ActionName =
 | 
			
		||||
  | "setEmbeddableAsActiveTool"
 | 
			
		||||
  | "createContainerFromText"
 | 
			
		||||
  | "wrapTextInContainer"
 | 
			
		||||
  | "commandPalette";
 | 
			
		||||
  | "commandPalette"
 | 
			
		||||
  | "autoResize"
 | 
			
		||||
  | "elementStats";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { ElementsMap, ExcalidrawElement } from "./element/types";
 | 
			
		||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
 | 
			
		||||
import { newElementWith } from "./element/mutateElement";
 | 
			
		||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
 | 
			
		||||
import type { BoundingBox } from "./element/bounds";
 | 
			
		||||
import { getCommonBoundingBox } from "./element/bounds";
 | 
			
		||||
import { getMaximumGroups } from "./groups";
 | 
			
		||||
 | 
			
		||||
export interface Alignment {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
// place here categories that you want to track. We want to track just a
 | 
			
		||||
// small subset of categories at a given time.
 | 
			
		||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
 | 
			
		||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
 | 
			
		||||
 | 
			
		||||
export const trackEvent = (
 | 
			
		||||
  category: string,
 | 
			
		||||
@@ -9,17 +9,20 @@ export const trackEvent = (
 | 
			
		||||
  value?: number,
 | 
			
		||||
) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // prettier-ignore
 | 
			
		||||
    if (
 | 
			
		||||
      typeof window === "undefined"
 | 
			
		||||
      || import.meta.env.VITE_WORKER_ID
 | 
			
		||||
      // comment out to debug locally
 | 
			
		||||
      || import.meta.env.PROD
 | 
			
		||||
      typeof window === "undefined" ||
 | 
			
		||||
      import.meta.env.VITE_WORKER_ID ||
 | 
			
		||||
      import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
 | 
			
		||||
    ) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
 | 
			
		||||
    if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (import.meta.env.DEV) {
 | 
			
		||||
      // comment out to debug in dev
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
 | 
			
		||||
import { AnimationFrameHandler } from "./animation-frame-handler";
 | 
			
		||||
import { AppState } from "./types";
 | 
			
		||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
 | 
			
		||||
import { LaserPointer } from "@excalidraw/laser-pointer";
 | 
			
		||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
 | 
			
		||||
import type { AppState } from "./types";
 | 
			
		||||
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
 | 
			
		||||
import type App from "./components/App";
 | 
			
		||||
import { SVG_NS } from "./constants";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,15 @@
 | 
			
		||||
import { COLOR_PALETTE } from "./colors";
 | 
			
		||||
import {
 | 
			
		||||
  ARROW_TYPE,
 | 
			
		||||
  DEFAULT_ELEMENT_PROPS,
 | 
			
		||||
  DEFAULT_FONT_FAMILY,
 | 
			
		||||
  DEFAULT_FONT_SIZE,
 | 
			
		||||
  DEFAULT_TEXT_ALIGN,
 | 
			
		||||
  EXPORT_SCALES,
 | 
			
		||||
  STATS_PANELS,
 | 
			
		||||
  THEME,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { AppState, NormalizedZoomValue } from "./types";
 | 
			
		||||
import type { AppState, NormalizedZoomValue } from "./types";
 | 
			
		||||
 | 
			
		||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
 | 
			
		||||
  ? devicePixelRatio
 | 
			
		||||
@@ -32,12 +34,14 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    currentItemStartArrowhead: null,
 | 
			
		||||
    currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
 | 
			
		||||
    currentItemRoundness: "round",
 | 
			
		||||
    currentItemArrowType: ARROW_TYPE.round,
 | 
			
		||||
    currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
 | 
			
		||||
    currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
 | 
			
		||||
    currentItemTextAlign: DEFAULT_TEXT_ALIGN,
 | 
			
		||||
    currentHoveredFontFamily: null,
 | 
			
		||||
    cursorButton: "up",
 | 
			
		||||
    activeEmbeddable: null,
 | 
			
		||||
    draggingElement: null,
 | 
			
		||||
    newElement: null,
 | 
			
		||||
    editingElement: null,
 | 
			
		||||
    editingGroupId: null,
 | 
			
		||||
    editingLinearElement: null,
 | 
			
		||||
@@ -80,7 +84,10 @@ export const getDefaultAppState = (): Omit<
 | 
			
		||||
    selectedElementsAreBeingDragged: false,
 | 
			
		||||
    selectionElement: null,
 | 
			
		||||
    shouldCacheIgnoreZoom: false,
 | 
			
		||||
    showStats: false,
 | 
			
		||||
    stats: {
 | 
			
		||||
      open: false,
 | 
			
		||||
      panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
 | 
			
		||||
    },
 | 
			
		||||
    startBoundElement: null,
 | 
			
		||||
    suggestedBindings: [],
 | 
			
		||||
    frameRendering: { enabled: true, clip: true, name: true, outline: true },
 | 
			
		||||
@@ -138,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
    export: false,
 | 
			
		||||
    server: false,
 | 
			
		||||
  },
 | 
			
		||||
  currentItemArrowType: {
 | 
			
		||||
    browser: true,
 | 
			
		||||
    export: false,
 | 
			
		||||
    server: false,
 | 
			
		||||
  },
 | 
			
		||||
  currentItemOpacity: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemRoughness: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStartArrowhead: { browser: true, export: false, server: false },
 | 
			
		||||
@@ -145,9 +157,10 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  currentItemStrokeStyle: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemStrokeWidth: { browser: true, export: false, server: false },
 | 
			
		||||
  currentItemTextAlign: { browser: true, export: false, server: false },
 | 
			
		||||
  currentHoveredFontFamily: { browser: false, export: false, server: false },
 | 
			
		||||
  cursorButton: { browser: true, export: false, server: false },
 | 
			
		||||
  activeEmbeddable: { browser: false, export: false, server: false },
 | 
			
		||||
  draggingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  newElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false, server: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false, server: false },
 | 
			
		||||
@@ -196,7 +209,7 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  },
 | 
			
		||||
  selectionElement: { browser: false, export: false, server: false },
 | 
			
		||||
  shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
 | 
			
		||||
  showStats: { browser: true, export: false, server: false },
 | 
			
		||||
  stats: { browser: true, export: false, server: false },
 | 
			
		||||
  startBoundElement: { browser: false, export: false, server: false },
 | 
			
		||||
  suggestedBindings: { browser: false, export: false, server: false },
 | 
			
		||||
  frameRendering: { browser: false, export: false, server: false },
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										105
									
								
								packages/excalidraw/binaryheap.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								packages/excalidraw/binaryheap.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
export default class BinaryHeap<T> {
 | 
			
		||||
  private content: T[] = [];
 | 
			
		||||
 | 
			
		||||
  constructor(private scoreFunction: (node: T) => number) {}
 | 
			
		||||
 | 
			
		||||
  sinkDown(idx: number) {
 | 
			
		||||
    const node = this.content[idx];
 | 
			
		||||
    while (idx > 0) {
 | 
			
		||||
      const parentN = ((idx + 1) >> 1) - 1;
 | 
			
		||||
      const parent = this.content[parentN];
 | 
			
		||||
      if (this.scoreFunction(node) < this.scoreFunction(parent)) {
 | 
			
		||||
        this.content[parentN] = node;
 | 
			
		||||
        this.content[idx] = parent;
 | 
			
		||||
        idx = parentN; // TODO: Optimize
 | 
			
		||||
      } else {
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bubbleUp(idx: number) {
 | 
			
		||||
    const length = this.content.length;
 | 
			
		||||
    const node = this.content[idx];
 | 
			
		||||
    const score = this.scoreFunction(node);
 | 
			
		||||
 | 
			
		||||
    while (true) {
 | 
			
		||||
      const child2N = (idx + 1) << 1;
 | 
			
		||||
      const child1N = child2N - 1;
 | 
			
		||||
      let swap = null;
 | 
			
		||||
      let child1Score = 0;
 | 
			
		||||
 | 
			
		||||
      if (child1N < length) {
 | 
			
		||||
        const child1 = this.content[child1N];
 | 
			
		||||
        child1Score = this.scoreFunction(child1);
 | 
			
		||||
        if (child1Score < score) {
 | 
			
		||||
          swap = child1N;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (child2N < length) {
 | 
			
		||||
        const child2 = this.content[child2N];
 | 
			
		||||
        const child2Score = this.scoreFunction(child2);
 | 
			
		||||
        if (child2Score < (swap === null ? score : child1Score)) {
 | 
			
		||||
          swap = child2N;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (swap !== null) {
 | 
			
		||||
        this.content[idx] = this.content[swap];
 | 
			
		||||
        this.content[swap] = node;
 | 
			
		||||
        idx = swap; // TODO: Optimize
 | 
			
		||||
      } else {
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  push(node: T) {
 | 
			
		||||
    this.content.push(node);
 | 
			
		||||
    this.sinkDown(this.content.length - 1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pop(): T | null {
 | 
			
		||||
    if (this.content.length === 0) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const result = this.content[0];
 | 
			
		||||
    const end = this.content.pop()!;
 | 
			
		||||
 | 
			
		||||
    if (this.content.length > 0) {
 | 
			
		||||
      this.content[0] = end;
 | 
			
		||||
      this.bubbleUp(0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  remove(node: T) {
 | 
			
		||||
    if (this.content.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const i = this.content.indexOf(node);
 | 
			
		||||
    const end = this.content.pop()!;
 | 
			
		||||
 | 
			
		||||
    if (i < this.content.length) {
 | 
			
		||||
      this.content[i] = end;
 | 
			
		||||
 | 
			
		||||
      if (this.scoreFunction(end) < this.scoreFunction(node)) {
 | 
			
		||||
        this.sinkDown(i);
 | 
			
		||||
      } else {
 | 
			
		||||
        this.bubbleUp(i);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  size(): number {
 | 
			
		||||
    return this.content.length;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  rescoreElement(node: T) {
 | 
			
		||||
    this.sinkDown(this.content.indexOf(node));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,18 +1,14 @@
 | 
			
		||||
import { ENV } from "./constants";
 | 
			
		||||
import type { BindableProp, BindingProp } from "./element/binding";
 | 
			
		||||
import {
 | 
			
		||||
  BoundElement,
 | 
			
		||||
  BindableElement,
 | 
			
		||||
  BindableProp,
 | 
			
		||||
  BindingProp,
 | 
			
		||||
  bindingProperties,
 | 
			
		||||
  updateBoundElements,
 | 
			
		||||
} from "./element/binding";
 | 
			
		||||
import { LinearElementEditor } from "./element/linearElementEditor";
 | 
			
		||||
import {
 | 
			
		||||
  ElementUpdate,
 | 
			
		||||
  mutateElement,
 | 
			
		||||
  newElementWith,
 | 
			
		||||
} from "./element/mutateElement";
 | 
			
		||||
import type { ElementUpdate } from "./element/mutateElement";
 | 
			
		||||
import { mutateElement, newElementWith } from "./element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElementId,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
@@ -23,7 +19,7 @@ import {
 | 
			
		||||
  isBoundToContainer,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
} from "./element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
@@ -34,13 +30,13 @@ import {
 | 
			
		||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
 | 
			
		||||
import { getNonDeletedGroupIds } from "./groups";
 | 
			
		||||
import { getObservedAppState } from "./store";
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  AppState,
 | 
			
		||||
  ObservedAppState,
 | 
			
		||||
  ObservedElementsAppState,
 | 
			
		||||
  ObservedStandaloneAppState,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { SubtypeOf, ValueOf } from "./utility-types";
 | 
			
		||||
import type { SubtypeOf, ValueOf } from "./utility-types";
 | 
			
		||||
import {
 | 
			
		||||
  arrayToMap,
 | 
			
		||||
  arrayToObject,
 | 
			
		||||
@@ -1104,7 +1100,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
 | 
			
		||||
    try {
 | 
			
		||||
      // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
 | 
			
		||||
      ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
 | 
			
		||||
      ElementsChange.redrawBoundArrows(nextElements, changedElements);
 | 
			
		||||
 | 
			
		||||
      // the following reorder performs also mutations, but only on new instances of changed elements
 | 
			
		||||
      // (unless something goes really bad and it fallbacks to fixing all invalid indices)
 | 
			
		||||
@@ -1113,6 +1108,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
 | 
			
		||||
        changedElements,
 | 
			
		||||
        flags,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Need ordered nextElements to avoid z-index binding issues
 | 
			
		||||
      ElementsChange.redrawBoundArrows(nextElements, changedElements);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error(
 | 
			
		||||
        `Couldn't mutate elements after applying elements change`,
 | 
			
		||||
@@ -1464,7 +1462,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
 | 
			
		||||
  ) {
 | 
			
		||||
    for (const element of changed.values()) {
 | 
			
		||||
      if (!element.isDeleted && isBindableElement(element)) {
 | 
			
		||||
        updateBoundElements(element, elements);
 | 
			
		||||
        updateBoundElements(element, elements, {
 | 
			
		||||
          changedElements: changed,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -1481,19 +1481,28 @@ export class ElementsChange implements Change<SceneElementsMap> {
 | 
			
		||||
      return elements;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const previous = Array.from(elements.values());
 | 
			
		||||
    const reordered = orderByFractionalIndex([...previous]);
 | 
			
		||||
    const unordered = Array.from(elements.values());
 | 
			
		||||
    const ordered = orderByFractionalIndex([...unordered]);
 | 
			
		||||
    const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
 | 
			
		||||
      (acc, arrayIndex) => {
 | 
			
		||||
        const candidate = unordered[Number(arrayIndex)];
 | 
			
		||||
        if (candidate && changed.has(candidate.id)) {
 | 
			
		||||
          acc.set(candidate.id, candidate);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      !flags.containsVisibleDifference &&
 | 
			
		||||
      Delta.isRightDifferent(previous, reordered, true)
 | 
			
		||||
    ) {
 | 
			
		||||
        return acc;
 | 
			
		||||
      },
 | 
			
		||||
      new Map(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!flags.containsVisibleDifference && moved.size) {
 | 
			
		||||
      // we found a difference in order!
 | 
			
		||||
      flags.containsVisibleDifference = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // let's synchronize all invalid indices of moved elements
 | 
			
		||||
    return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
 | 
			
		||||
    // synchronize all elements that were actually moved
 | 
			
		||||
    // could fallback to synchronizing all invalid indices
 | 
			
		||||
    return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,5 @@
 | 
			
		||||
import {
 | 
			
		||||
  Spreadsheet,
 | 
			
		||||
  tryParseCells,
 | 
			
		||||
  tryParseNumber,
 | 
			
		||||
  VALID_SPREADSHEET,
 | 
			
		||||
} from "./charts";
 | 
			
		||||
import type { Spreadsheet } from "./charts";
 | 
			
		||||
import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
 | 
			
		||||
describe("charts", () => {
 | 
			
		||||
  describe("tryParseNumber", () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ import {
 | 
			
		||||
  VERTICAL_ALIGN,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { newElement, newLinearElement, newTextElement } from "./element";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "./element/types";
 | 
			
		||||
import type { NonDeletedExcalidrawElement } from "./element/types";
 | 
			
		||||
import { randomId } from "./random";
 | 
			
		||||
 | 
			
		||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
@@ -257,8 +257,6 @@ const chartLines = (
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    width: chartWidth,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
@@ -273,8 +271,6 @@ const chartLines = (
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    height: chartHeight,
 | 
			
		||||
    points: [
 | 
			
		||||
      [0, 0],
 | 
			
		||||
@@ -289,8 +285,6 @@ const chartLines = (
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x,
 | 
			
		||||
    y: y - BAR_HEIGHT - BAR_GAP,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    strokeStyle: "dotted",
 | 
			
		||||
    width: chartWidth,
 | 
			
		||||
    opacity: GRID_OPACITY,
 | 
			
		||||
@@ -418,8 +412,6 @@ const chartTypeLine = (
 | 
			
		||||
    type: "line",
 | 
			
		||||
    x: x + BAR_GAP + BAR_WIDTH / 2,
 | 
			
		||||
    y: y - BAR_GAP,
 | 
			
		||||
    startArrowhead: null,
 | 
			
		||||
    endArrowhead: null,
 | 
			
		||||
    height: maxY - minY,
 | 
			
		||||
    width: maxX - minX,
 | 
			
		||||
    strokeWidth: 2,
 | 
			
		||||
@@ -453,8 +445,6 @@ const chartTypeLine = (
 | 
			
		||||
      type: "line",
 | 
			
		||||
      x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
 | 
			
		||||
      y: y - cy,
 | 
			
		||||
      startArrowhead: null,
 | 
			
		||||
      endArrowhead: null,
 | 
			
		||||
      height: cy,
 | 
			
		||||
      strokeStyle: "dotted",
 | 
			
		||||
      opacity: GRID_OPACITY,
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,13 @@ import {
 | 
			
		||||
  THEME,
 | 
			
		||||
} from "./constants";
 | 
			
		||||
import { roundRect } from "./renderer/roundRect";
 | 
			
		||||
import { InteractiveCanvasRenderConfig } from "./scene/types";
 | 
			
		||||
import {
 | 
			
		||||
import type { InteractiveCanvasRenderConfig } from "./scene/types";
 | 
			
		||||
import type {
 | 
			
		||||
  Collaborator,
 | 
			
		||||
  InteractiveCanvasAppState,
 | 
			
		||||
  SocketId,
 | 
			
		||||
  UserIdleState,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { UserIdleState } from "./types";
 | 
			
		||||
 | 
			
		||||
function hashToInteger(id: string) {
 | 
			
		||||
  let hash = 0;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,10 @@
 | 
			
		||||
import {
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "./element/types";
 | 
			
		||||
import { BinaryFiles } from "./types";
 | 
			
		||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import type { BinaryFiles } from "./types";
 | 
			
		||||
import type { Spreadsheet } from "./charts";
 | 
			
		||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
 | 
			
		||||
import {
 | 
			
		||||
  ALLOWED_PASTE_MIME_TYPES,
 | 
			
		||||
  EXPORT_DATA_TYPES,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import oc from "open-color";
 | 
			
		||||
import { Merge } from "./utility-types";
 | 
			
		||||
import type { Merge } from "./utility-types";
 | 
			
		||||
 | 
			
		||||
// FIXME can't put to utils.ts rn because of circular dependency
 | 
			
		||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import {
 | 
			
		||||
import type { ActionManager } from "../actions/manager";
 | 
			
		||||
import type {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  ExcalidrawElementType,
 | 
			
		||||
  NonDeletedElementsMap,
 | 
			
		||||
@@ -17,13 +17,18 @@ import {
 | 
			
		||||
  hasStrokeWidth,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
 | 
			
		||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
 | 
			
		||||
import { capitalizeString, isTransparent } from "../utils";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import { ToolButton } from "./ToolButton";
 | 
			
		||||
import { hasStrokeColor } from "../scene/comparisons";
 | 
			
		||||
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  hasBoundTextElement,
 | 
			
		||||
  isElbowArrow,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
  isTextElement,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { actionToggleZenMode } from "../actions";
 | 
			
		||||
import { Tooltip } from "./Tooltip";
 | 
			
		||||
@@ -114,6 +119,12 @@ export const SelectedShapeActions = ({
 | 
			
		||||
  const showLinkIcon =
 | 
			
		||||
    targetElements.length === 1 || isSingleElementBoundContainer;
 | 
			
		||||
 | 
			
		||||
  const showLineEditorAction =
 | 
			
		||||
    !appState.editingLinearElement &&
 | 
			
		||||
    targetElements.length === 1 &&
 | 
			
		||||
    isLinearElement(targetElements[0]) &&
 | 
			
		||||
    !isElbowArrow(targetElements[0]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="panelColumn">
 | 
			
		||||
      <div>
 | 
			
		||||
@@ -146,13 +157,16 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        <>{renderAction("changeRoundness")}</>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(toolIsArrow(appState.activeTool.type) ||
 | 
			
		||||
        targetElements.some((element) => toolIsArrow(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeArrowType")}</>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(appState.activeTool.type === "text" ||
 | 
			
		||||
        targetElements.some(isTextElement)) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeFontSize")}
 | 
			
		||||
 | 
			
		||||
          {renderAction("changeFontFamily")}
 | 
			
		||||
 | 
			
		||||
          {renderAction("changeFontSize")}
 | 
			
		||||
          {(appState.activeTool.type === "text" ||
 | 
			
		||||
            suppportsHorizontalAlign(targetElements, elementsMap)) &&
 | 
			
		||||
            renderAction("changeTextAlign")}
 | 
			
		||||
@@ -173,8 +187,8 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        <div className="buttonList">
 | 
			
		||||
          {renderAction("sendToBack")}
 | 
			
		||||
          {renderAction("sendBackward")}
 | 
			
		||||
          {renderAction("bringToFront")}
 | 
			
		||||
          {renderAction("bringForward")}
 | 
			
		||||
          {renderAction("bringToFront")}
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
 | 
			
		||||
@@ -229,6 +243,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
            {renderAction("group")}
 | 
			
		||||
            {renderAction("ungroup")}
 | 
			
		||||
            {showLinkIcon && renderAction("hyperlink")}
 | 
			
		||||
            {showLineEditorAction && renderAction("toggleLinearEditor")}
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -333,8 +348,8 @@ export const ShapesSwitcher = ({
 | 
			
		||||
                fontSize: 8,
 | 
			
		||||
                fontFamily: "Cascadia, monospace",
 | 
			
		||||
                position: "absolute",
 | 
			
		||||
                background: "pink",
 | 
			
		||||
                color: "black",
 | 
			
		||||
                background: "var(--color-promo)",
 | 
			
		||||
                color: "var(--color-surface-lowest)",
 | 
			
		||||
                bottom: 3,
 | 
			
		||||
                right: 4,
 | 
			
		||||
              }}
 | 
			
		||||
@@ -458,6 +473,7 @@ export const ExitZenModeAction = ({
 | 
			
		||||
  showExitZenModeBtn: boolean;
 | 
			
		||||
}) => (
 | 
			
		||||
  <button
 | 
			
		||||
    type="button"
 | 
			
		||||
    className={clsx("disable-zen-mode", {
 | 
			
		||||
      "disable-zen-mode--visible": showExitZenModeBtn,
 | 
			
		||||
    })}
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										12
									
								
								packages/excalidraw/components/ButtonIcon.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								packages/excalidraw/components/ButtonIcon.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
@import "../css/theme";
 | 
			
		||||
 | 
			
		||||
.excalidraw {
 | 
			
		||||
  button.standalone {
 | 
			
		||||
    @include outlineButtonIconStyles;
 | 
			
		||||
 | 
			
		||||
    & > * {
 | 
			
		||||
      // dissalow pointer events on children, so we always have event.target on the button itself
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										36
									
								
								packages/excalidraw/components/ButtonIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/excalidraw/components/ButtonIcon.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
import { forwardRef } from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
import "./ButtonIcon.scss";
 | 
			
		||||
 | 
			
		||||
interface ButtonIconProps {
 | 
			
		||||
  icon: JSX.Element;
 | 
			
		||||
  title: string;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  testId?: string;
 | 
			
		||||
  /** if not supplied, defaults to value identity check */
 | 
			
		||||
  active?: boolean;
 | 
			
		||||
  /** include standalone style (could interfere with parent styles) */
 | 
			
		||||
  standalone?: boolean;
 | 
			
		||||
  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
 | 
			
		||||
  (props, ref) => {
 | 
			
		||||
    const { title, className, testId, active, standalone, icon, onClick } =
 | 
			
		||||
      props;
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        ref={ref}
 | 
			
		||||
        key={title}
 | 
			
		||||
        title={title}
 | 
			
		||||
        data-testid={testId}
 | 
			
		||||
        className={clsx(className, { standalone, active })}
 | 
			
		||||
        onClick={onClick}
 | 
			
		||||
      >
 | 
			
		||||
        {icon}
 | 
			
		||||
      </button>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { ButtonIcon } from "./ButtonIcon";
 | 
			
		||||
 | 
			
		||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
 | 
			
		||||
export const ButtonIconSelect = <T extends Object>(
 | 
			
		||||
@@ -24,20 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
 | 
			
		||||
      }
 | 
			
		||||
  ),
 | 
			
		||||
) => (
 | 
			
		||||
  <div className="buttonList buttonListIcon">
 | 
			
		||||
  <div className="buttonList">
 | 
			
		||||
    {props.options.map((option) =>
 | 
			
		||||
      props.type === "button" ? (
 | 
			
		||||
        <button
 | 
			
		||||
        <ButtonIcon
 | 
			
		||||
          key={option.text}
 | 
			
		||||
          onClick={(event) => props.onClick(option.value, event)}
 | 
			
		||||
          className={clsx({
 | 
			
		||||
            active: option.active ?? props.value === option.value,
 | 
			
		||||
          })}
 | 
			
		||||
          data-testid={option.testId}
 | 
			
		||||
          icon={option.icon}
 | 
			
		||||
          title={option.text}
 | 
			
		||||
        >
 | 
			
		||||
          {option.icon}
 | 
			
		||||
        </button>
 | 
			
		||||
          testId={option.testId}
 | 
			
		||||
          active={option.active ?? props.value === option.value}
 | 
			
		||||
          onClick={(event) => props.onClick(option.value, event)}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <label
 | 
			
		||||
          key={option.text}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								packages/excalidraw/components/ButtonSeparator.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								packages/excalidraw/components/ButtonSeparator.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
export const ButtonSeparator = () => (
 | 
			
		||||
  <div
 | 
			
		||||
    style={{
 | 
			
		||||
      width: 1,
 | 
			
		||||
      height: "1rem",
 | 
			
		||||
      backgroundColor: "var(--default-border-color)",
 | 
			
		||||
      margin: "0 auto",
 | 
			
		||||
    }}
 | 
			
		||||
  />
 | 
			
		||||
);
 | 
			
		||||
@@ -22,7 +22,12 @@ export const CheckboxItem: React.FC<{
 | 
			
		||||
        ).focus();
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        className="Checkbox-box"
 | 
			
		||||
        role="checkbox"
 | 
			
		||||
        aria-checked={checked}
 | 
			
		||||
      >
 | 
			
		||||
        {checkIcon}
 | 
			
		||||
      </button>
 | 
			
		||||
      <div className="Checkbox-label">{children}</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,8 @@
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from "react";
 | 
			
		||||
import { getColor } from "./ColorPicker";
 | 
			
		||||
import { useAtom } from "jotai";
 | 
			
		||||
import {
 | 
			
		||||
  ColorPickerType,
 | 
			
		||||
  activeColorPickerSectionAtom,
 | 
			
		||||
} from "./colorPickerUtils";
 | 
			
		||||
import type { ColorPickerType } from "./colorPickerUtils";
 | 
			
		||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
 | 
			
		||||
import { eyeDropperIcon } from "../icons";
 | 
			
		||||
import { jotaiScope } from "../../jotai";
 | 
			
		||||
import { KEYS } from "../../keys";
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@
 | 
			
		||||
    align-items: center;
 | 
			
		||||
 | 
			
		||||
    @include isMobile {
 | 
			
		||||
      max-width: 175px;
 | 
			
		||||
      max-width: 11rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,24 @@
 | 
			
		||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
 | 
			
		||||
import { ExcalidrawElement } from "../../element/types";
 | 
			
		||||
import { AppState } from "../../types";
 | 
			
		||||
import { isTransparent } from "../../utils";
 | 
			
		||||
import type { ExcalidrawElement } from "../../element/types";
 | 
			
		||||
import type { AppState } from "../../types";
 | 
			
		||||
import { TopPicks } from "./TopPicks";
 | 
			
		||||
import { ButtonSeparator } from "../ButtonSeparator";
 | 
			
		||||
import { Picker } from "./Picker";
 | 
			
		||||
import * as Popover from "@radix-ui/react-popover";
 | 
			
		||||
import { useAtom } from "jotai";
 | 
			
		||||
import {
 | 
			
		||||
  activeColorPickerSectionAtom,
 | 
			
		||||
  ColorPickerType,
 | 
			
		||||
} from "./colorPickerUtils";
 | 
			
		||||
import { useDevice, useExcalidrawContainer } from "../App";
 | 
			
		||||
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
 | 
			
		||||
import type { ColorPickerType } from "./colorPickerUtils";
 | 
			
		||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
 | 
			
		||||
import { useExcalidrawContainer } from "../App";
 | 
			
		||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
 | 
			
		||||
import { COLOR_PALETTE } from "../../colors";
 | 
			
		||||
import PickerHeading from "./PickerHeading";
 | 
			
		||||
import { t } from "../../i18n";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { useRef } from "react";
 | 
			
		||||
import { jotaiScope } from "../../jotai";
 | 
			
		||||
import { ColorInput } from "./ColorInput";
 | 
			
		||||
import { useRef } from "react";
 | 
			
		||||
import { activeEyeDropperAtom } from "../EyeDropper";
 | 
			
		||||
import { PropertiesPopover } from "../PropertiesPopover";
 | 
			
		||||
 | 
			
		||||
import "./ColorPicker.scss";
 | 
			
		||||
 | 
			
		||||
@@ -72,6 +73,7 @@ const ColorPickerPopupContent = ({
 | 
			
		||||
  | "palette"
 | 
			
		||||
  | "updateData"
 | 
			
		||||
>) => {
 | 
			
		||||
  const { container } = useExcalidrawContainer();
 | 
			
		||||
  const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
 | 
			
		||||
 | 
			
		||||
  const [eyeDropperState, setEyeDropperState] = useAtom(
 | 
			
		||||
@@ -79,9 +81,6 @@ const ColorPickerPopupContent = ({
 | 
			
		||||
    jotaiScope,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const { container } = useExcalidrawContainer();
 | 
			
		||||
  const device = useDevice();
 | 
			
		||||
 | 
			
		||||
  const colorInputJSX = (
 | 
			
		||||
    <div>
 | 
			
		||||
      <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
 | 
			
		||||
@@ -95,6 +94,7 @@ const ColorPickerPopupContent = ({
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const popoverRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
  const focusPickerContent = () => {
 | 
			
		||||
@@ -104,120 +104,73 @@ const ColorPickerPopupContent = ({
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover.Portal container={container}>
 | 
			
		||||
      <Popover.Content
 | 
			
		||||
        ref={popoverRef}
 | 
			
		||||
        className="focus-visible-none"
 | 
			
		||||
        data-prevent-outside-click
 | 
			
		||||
        onFocusOutside={(event) => {
 | 
			
		||||
          focusPickerContent();
 | 
			
		||||
    <PropertiesPopover
 | 
			
		||||
      container={container}
 | 
			
		||||
      style={{ maxWidth: "208px" }}
 | 
			
		||||
      onFocusOutside={(event) => {
 | 
			
		||||
        // refocus due to eye dropper
 | 
			
		||||
        focusPickerContent();
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
      }}
 | 
			
		||||
      onPointerDownOutside={(event) => {
 | 
			
		||||
        if (eyeDropperState) {
 | 
			
		||||
          // prevent from closing if we click outside the popover
 | 
			
		||||
          // while eyedropping (e.g. click when clicking the sidebar;
 | 
			
		||||
          // the eye-dropper-backdrop is prevented downstream)
 | 
			
		||||
          event.preventDefault();
 | 
			
		||||
        }}
 | 
			
		||||
        onPointerDownOutside={(event) => {
 | 
			
		||||
          if (eyeDropperState) {
 | 
			
		||||
            // prevent from closing if we click outside the popover
 | 
			
		||||
            // while eyedropping (e.g. click when clicking the sidebar;
 | 
			
		||||
            // the eye-dropper-backdrop is prevented downstream)
 | 
			
		||||
            event.preventDefault();
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        onCloseAutoFocus={(e) => {
 | 
			
		||||
          e.stopPropagation();
 | 
			
		||||
          // prevents focusing the trigger
 | 
			
		||||
          e.preventDefault();
 | 
			
		||||
 | 
			
		||||
          // return focus to excalidraw container unless
 | 
			
		||||
          // user focuses an interactive element, such as a button, or
 | 
			
		||||
          // enters the text editor by clicking on canvas with the text tool
 | 
			
		||||
          if (container && !isInteractive(document.activeElement)) {
 | 
			
		||||
            container.focus();
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          updateData({ openPopup: null });
 | 
			
		||||
          setActiveColorPickerSection(null);
 | 
			
		||||
        }}
 | 
			
		||||
        side={
 | 
			
		||||
          device.editor.isMobile && !device.viewport.isLandscape
 | 
			
		||||
            ? "bottom"
 | 
			
		||||
            : "right"
 | 
			
		||||
        }
 | 
			
		||||
        align={
 | 
			
		||||
          device.editor.isMobile && !device.viewport.isLandscape
 | 
			
		||||
            ? "center"
 | 
			
		||||
            : "start"
 | 
			
		||||
        }
 | 
			
		||||
        alignOffset={-16}
 | 
			
		||||
        sideOffset={20}
 | 
			
		||||
        style={{
 | 
			
		||||
          zIndex: "var(--zIndex-layerUI)",
 | 
			
		||||
          backgroundColor: "var(--popup-bg-color)",
 | 
			
		||||
          maxWidth: "208px",
 | 
			
		||||
          maxHeight: window.innerHeight,
 | 
			
		||||
          padding: "12px",
 | 
			
		||||
          borderRadius: "8px",
 | 
			
		||||
          boxSizing: "border-box",
 | 
			
		||||
          overflowY: "auto",
 | 
			
		||||
          boxShadow:
 | 
			
		||||
            "0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {palette ? (
 | 
			
		||||
          <Picker
 | 
			
		||||
            palette={palette}
 | 
			
		||||
            color={color}
 | 
			
		||||
            onChange={(changedColor) => {
 | 
			
		||||
              onChange(changedColor);
 | 
			
		||||
            }}
 | 
			
		||||
            onEyeDropperToggle={(force) => {
 | 
			
		||||
              setEyeDropperState((state) => {
 | 
			
		||||
                if (force) {
 | 
			
		||||
                  state = state || {
 | 
			
		||||
                    keepOpenOnAlt: true,
 | 
			
		||||
      }}
 | 
			
		||||
      onClose={() => {
 | 
			
		||||
        updateData({ openPopup: null });
 | 
			
		||||
        setActiveColorPickerSection(null);
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {palette ? (
 | 
			
		||||
        <Picker
 | 
			
		||||
          palette={palette}
 | 
			
		||||
          color={color}
 | 
			
		||||
          onChange={(changedColor) => {
 | 
			
		||||
            onChange(changedColor);
 | 
			
		||||
          }}
 | 
			
		||||
          onEyeDropperToggle={(force) => {
 | 
			
		||||
            setEyeDropperState((state) => {
 | 
			
		||||
              if (force) {
 | 
			
		||||
                state = state || {
 | 
			
		||||
                  keepOpenOnAlt: true,
 | 
			
		||||
                  onSelect: onChange,
 | 
			
		||||
                  colorPickerType: type,
 | 
			
		||||
                };
 | 
			
		||||
                state.keepOpenOnAlt = true;
 | 
			
		||||
                return state;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return force === false || state
 | 
			
		||||
                ? null
 | 
			
		||||
                : {
 | 
			
		||||
                    keepOpenOnAlt: false,
 | 
			
		||||
                    onSelect: onChange,
 | 
			
		||||
                    colorPickerType: type,
 | 
			
		||||
                  };
 | 
			
		||||
                  state.keepOpenOnAlt = true;
 | 
			
		||||
                  return state;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return force === false || state
 | 
			
		||||
                  ? null
 | 
			
		||||
                  : {
 | 
			
		||||
                      keepOpenOnAlt: false,
 | 
			
		||||
                      onSelect: onChange,
 | 
			
		||||
                      colorPickerType: type,
 | 
			
		||||
                    };
 | 
			
		||||
              });
 | 
			
		||||
            }}
 | 
			
		||||
            onEscape={(event) => {
 | 
			
		||||
              if (eyeDropperState) {
 | 
			
		||||
                setEyeDropperState(null);
 | 
			
		||||
              } else if (isWritableElement(event.target)) {
 | 
			
		||||
                focusPickerContent();
 | 
			
		||||
              } else {
 | 
			
		||||
                updateData({ openPopup: null });
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
            label={label}
 | 
			
		||||
            type={type}
 | 
			
		||||
            elements={elements}
 | 
			
		||||
            updateData={updateData}
 | 
			
		||||
          >
 | 
			
		||||
            {colorInputJSX}
 | 
			
		||||
          </Picker>
 | 
			
		||||
        ) : (
 | 
			
		||||
          colorInputJSX
 | 
			
		||||
        )}
 | 
			
		||||
        <Popover.Arrow
 | 
			
		||||
          width={20}
 | 
			
		||||
          height={10}
 | 
			
		||||
          style={{
 | 
			
		||||
            fill: "var(--popup-bg-color)",
 | 
			
		||||
            filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
 | 
			
		||||
            });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </Popover.Content>
 | 
			
		||||
    </Popover.Portal>
 | 
			
		||||
          onEscape={(event) => {
 | 
			
		||||
            if (eyeDropperState) {
 | 
			
		||||
              setEyeDropperState(null);
 | 
			
		||||
            } else {
 | 
			
		||||
              updateData({ openPopup: null });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          label={label}
 | 
			
		||||
          type={type}
 | 
			
		||||
          elements={elements}
 | 
			
		||||
          updateData={updateData}
 | 
			
		||||
        >
 | 
			
		||||
          {colorInputJSX}
 | 
			
		||||
        </Picker>
 | 
			
		||||
      ) : (
 | 
			
		||||
        colorInputJSX
 | 
			
		||||
      )}
 | 
			
		||||
    </PropertiesPopover>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -233,7 +186,7 @@ const ColorPickerTrigger = ({
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover.Trigger
 | 
			
		||||
      type="button"
 | 
			
		||||
      className={clsx("color-picker__button active-color", {
 | 
			
		||||
      className={clsx("color-picker__button active-color properties-trigger", {
 | 
			
		||||
        "is-transparent": color === "transparent" || !color,
 | 
			
		||||
      })}
 | 
			
		||||
      aria-label={label}
 | 
			
		||||
@@ -269,14 +222,7 @@ export const ColorPicker = ({
 | 
			
		||||
          type={type}
 | 
			
		||||
          topPicks={topPicks}
 | 
			
		||||
        />
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            width: 1,
 | 
			
		||||
            height: "100%",
 | 
			
		||||
            backgroundColor: "var(--default-border-color)",
 | 
			
		||||
            margin: "0 auto",
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <ButtonSeparator />
 | 
			
		||||
        <Popover.Root
 | 
			
		||||
          open={appState.openPopup === type}
 | 
			
		||||
          onOpenChange={(open) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import { t } from "../../i18n";
 | 
			
		||||
 | 
			
		||||
import { ExcalidrawElement } from "../../element/types";
 | 
			
		||||
import type { ExcalidrawElement } from "../../element/types";
 | 
			
		||||
import { ShadeList } from "./ShadeList";
 | 
			
		||||
 | 
			
		||||
import PickerColorList from "./PickerColorList";
 | 
			
		||||
@@ -9,15 +9,15 @@ import { useAtom } from "jotai";
 | 
			
		||||
import { CustomColorList } from "./CustomColorList";
 | 
			
		||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
 | 
			
		||||
import PickerHeading from "./PickerHeading";
 | 
			
		||||
import type { ColorPickerType } from "./colorPickerUtils";
 | 
			
		||||
import {
 | 
			
		||||
  ColorPickerType,
 | 
			
		||||
  activeColorPickerSectionAtom,
 | 
			
		||||
  getColorNameAndShadeFromColor,
 | 
			
		||||
  getMostUsedCustomColors,
 | 
			
		||||
  isCustomColor,
 | 
			
		||||
} from "./colorPickerUtils";
 | 
			
		||||
import type { ColorPaletteCustom } from "../../colors";
 | 
			
		||||
import {
 | 
			
		||||
  ColorPaletteCustom,
 | 
			
		||||
  DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
 | 
			
		||||
  DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
 | 
			
		||||
} from "../../colors";
 | 
			
		||||
@@ -138,7 +138,7 @@ export const Picker = ({
 | 
			
		||||
            event.stopPropagation();
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        className="color-picker-content"
 | 
			
		||||
        className="color-picker-content properties-content"
 | 
			
		||||
        // to allow focusing by clicking but not by tabbing
 | 
			
		||||
        tabIndex={-1}
 | 
			
		||||
      >
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user