mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	Compare commits
	
		
			74 Commits
		
	
	
		
			v0.18.0
			...
			fix-better
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | fe04998f17 | ||
|   | fc3e062074 | ||
|   | 87c87a9fb1 | ||
|   | 4dc205537c | ||
|   | cc571c4681 | ||
|   | 14d512f321 | ||
|   | 41c036e1a5 | ||
|   | 91d36e9b81 | ||
|   | 27522110df | ||
|   | 712f267519 | ||
|   | 41a7613dff | ||
|   | 95d89a751a | ||
|   | 6b5fb30d69 | ||
|   | d92a849038 | ||
|   | 0a534f1bc6 | ||
|   | 4ca5f53b1f | ||
|   | f7dcc893ea | ||
|   | 4dfb8a3f8e | ||
|   | 298812e1d0 | ||
|   | 35bb449a4b | ||
|   | c4c064982f | ||
|   | 51dbd4831b | ||
|   | 7e41026812 | ||
|   | a8ebe514da | ||
|   | a30e1b25c6 | ||
|   | ff2ed5d26a | ||
|   | e058a08b33 | ||
|   | a306a909a0 | ||
|   | 3dc54a724a | ||
|   | a7c61319dd | ||
|   | cec5232a7a | ||
|   | d4f70e9f31 | ||
|   | e19fd1332a | ||
|   | 6e655cdb24 | ||
|   | 192c4e7658 | ||
|   | 195a743874 | ||
|   | 4a60fe3d22 | ||
|   | 2a0d15799c | ||
|   | a18b139a60 | ||
|   | 1913599594 | ||
|   | debf2ad608 | ||
|   | 8fb2f70414 | ||
|   | 5fc13e4309 | ||
|   | b5d60973b7 | ||
|   | a5d6939826 | ||
|   | 0cf36d6b30 | ||
|   | 58f7d33d80 | ||
|   | 6fe7de8020 | ||
|   | 01304aac49 | ||
|   | dff69e9191 | ||
|   | 6fc85022ae | ||
|   | e48b63a0ae | ||
|   | c2caf78e95 | ||
|   | ce267aa0d3 | ||
|   | 6e47fadb59 | ||
|   | b3d5ba0567 | ||
|   | c79e892e55 | ||
|   | 57a9e301d4 | ||
|   | 7c58477382 | ||
|   | 83fac6d0db | ||
|   | f2e8404c7b | ||
|   | d797c2e210 | ||
|   | 0cd5a259ae | ||
|   | 432a46ef9e | ||
|   | a18f059188 | ||
|   | ab89d4c16f | ||
|   | 6c3a434f2a | ||
|   | e1bb59fb8f | ||
|   | 77aca48c84 | ||
|   | 58990b41ae | ||
|   | 99d8bff175 | ||
|   | 30983d801a | ||
|   | 21ffaf4d76 | ||
|   | 82b9a6b464 | 
| @@ -1,3 +1,5 @@ | |||||||
|  | MODE="development" | ||||||
|  |  | ||||||
| VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ | VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ | ||||||
| VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ | VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ | ||||||
|  |  | ||||||
| @@ -48,3 +50,6 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD | |||||||
| s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot | s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot | ||||||
| kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS | kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS | ||||||
| HQIDAQAB' | HQIDAQAB' | ||||||
|  |  | ||||||
|  | # set to true in .env.development.local to disable the prevent unload dialog | ||||||
|  | VITE_APP_DISABLE_PREVENT_UNLOAD= | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | MODE="production" | ||||||
|  |  | ||||||
| VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ | VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ | ||||||
| VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ | VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,21 @@ | |||||||
| { | { | ||||||
|   "extends": ["@excalidraw/eslint-config", "react-app"], |   "extends": ["@excalidraw/eslint-config", "react-app"], | ||||||
|   "rules": { |   "rules": { | ||||||
|  |     "import/order": [ | ||||||
|  |       "warn", | ||||||
|  |       { | ||||||
|  |         "groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"], | ||||||
|  |         "pathGroups": [ | ||||||
|  |           { | ||||||
|  |             "pattern": "@excalidraw/**", | ||||||
|  |             "group": "external", | ||||||
|  |             "position": "after" | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "newlines-between": "always-and-inside-groups", | ||||||
|  |         "warnOnUnassignedImports": true | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|     "import/no-anonymous-default-export": "off", |     "import/no-anonymous-default-export": "off", | ||||||
|     "no-restricted-globals": "off", |     "no-restricted-globals": "off", | ||||||
|     "@typescript-eslint/consistent-type-imports": [ |     "@typescript-eslint/consistent-type-imports": [ | ||||||
| @@ -17,6 +32,12 @@ | |||||||
|         "name": "jotai", |         "name": "jotai", | ||||||
|         "message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")." |         "message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")." | ||||||
|       } |       } | ||||||
|  |     ], | ||||||
|  |     "react/jsx-no-target-blank": [ | ||||||
|  |       "error", | ||||||
|  |       { | ||||||
|  |         "allowReferrer": true | ||||||
|  |       } | ||||||
|     ] |     ] | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | # Project coding standards | ||||||
|  |  | ||||||
|  | ## Generic Communication Guidelines | ||||||
|  |  | ||||||
|  | - Be succint and be aware that expansive generative AI answers are costly and slow | ||||||
|  | - Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert | ||||||
|  | - Stop apologising if corrected, just provide the correct information or code | ||||||
|  | - Prefer code unless asked for explanation | ||||||
|  | - Stop summarizing what you've changed after modifications unless asked for | ||||||
|  |  | ||||||
|  | ## TypeScript Guidelines | ||||||
|  |  | ||||||
|  | - Use TypeScript for all new code | ||||||
|  | - Where possible, prefer implementations without allocation | ||||||
|  | - When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles | ||||||
|  | - Prefer immutable data (const, readonly) | ||||||
|  | - Use optional chaining (?.) and nullish coalescing (??) operators | ||||||
|  |  | ||||||
|  | ## React Guidelines | ||||||
|  |  | ||||||
|  | - Use functional components with hooks | ||||||
|  | - Follow the React hooks rules (no conditional hooks) | ||||||
|  | - Keep components small and focused | ||||||
|  | - Use CSS modules for component styling | ||||||
|  |  | ||||||
|  | ## Naming Conventions | ||||||
|  |  | ||||||
|  | - Use PascalCase for component names, interfaces, and type aliases | ||||||
|  | - Use camelCase for variables, functions, and methods | ||||||
|  | - Use ALL_CAPS for constants | ||||||
|  |  | ||||||
|  | ## Error Handling | ||||||
|  |  | ||||||
|  | - Use try/catch blocks for async operations | ||||||
|  | - Implement proper error boundaries in React components | ||||||
|  | - Always log errors with contextual information | ||||||
|  |  | ||||||
|  | ## Testing | ||||||
|  |  | ||||||
|  | - Always attempt to fix #problems | ||||||
|  | - Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported | ||||||
|  |  | ||||||
|  | ## Types | ||||||
|  |  | ||||||
|  | - Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y} | ||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -26,3 +26,4 @@ coverage | |||||||
| dev-dist | dev-dist | ||||||
| html | html | ||||||
| meta*.json | meta*.json | ||||||
|  | .claude | ||||||
|   | |||||||
							
								
								
									
										34
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | # CLAUDE.md | ||||||
|  |  | ||||||
|  | ## Project Structure | ||||||
|  |  | ||||||
|  | Excalidraw is a **monorepo** with a clear separation between the core library and the application: | ||||||
|  |  | ||||||
|  | - **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw` | ||||||
|  | - **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library | ||||||
|  | - **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils` | ||||||
|  | - **`examples/`** - Integration examples (NextJS, browser script) | ||||||
|  |  | ||||||
|  | ## Development Workflow | ||||||
|  |  | ||||||
|  | 1. **Package Development**: Work in `packages/*` for editor features | ||||||
|  | 2. **App Development**: Work in `excalidraw-app/` for app-specific features | ||||||
|  | 3. **Testing**: Always run `yarn test:update` before committing | ||||||
|  | 4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript | ||||||
|  |  | ||||||
|  | ## Development Commands | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | yarn test:typecheck  # TypeScript type checking | ||||||
|  | yarn test:update     # Run all tests (with snapshot updates) | ||||||
|  | yarn fix             # Auto-fix formatting and linting issues | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Architecture Notes | ||||||
|  |  | ||||||
|  | ### Package System | ||||||
|  |  | ||||||
|  | - Uses Yarn workspaces for monorepo management | ||||||
|  | - Internal packages use path aliases (see `vitest.config.mts`) | ||||||
|  | - Build system uses esbuild for packages, Vite for the app | ||||||
|  | - TypeScript throughout with strict configuration | ||||||
| @@ -34,6 +34,9 @@ | |||||||
|   <a href="https://discord.gg/UexuTaE"> |   <a href="https://discord.gg/UexuTaE"> | ||||||
|     <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/> |     <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/> | ||||||
|   </a> |   </a> | ||||||
|  |   <a href="https://deepwiki.com/excalidraw/excalidraw"> | ||||||
|  |     <img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /> | ||||||
|  |   </a> | ||||||
|   <a href="https://twitter.com/excalidraw"> |   <a href="https://twitter.com/excalidraw"> | ||||||
|     <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/> |     <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/> | ||||||
|   </a> |   </a> | ||||||
| @@ -63,7 +66,7 @@ The Excalidraw editor (npm package) supports: | |||||||
| - 🏗️ Customizable. | - 🏗️ Customizable. | ||||||
| - 📷 Image support. | - 📷 Image support. | ||||||
| - 😀 Shape libraries support. | - 😀 Shape libraries support. | ||||||
| - 👅 Localization (i18n) support. | - 🌐 Localization (i18n) support. | ||||||
| - 🖼️ Export to PNG, SVG & clipboard. | - 🖼️ Export to PNG, SVG & clipboard. | ||||||
| - 💾 Open format - export drawings as an `.excalidraw` json file. | - 💾 Open format - export drawings as an `.excalidraw` json file. | ||||||
| - ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser... | - ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser... | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer. | Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer. | ||||||
|  |  | ||||||
| You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node. | You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node. | ||||||
|  |  | ||||||
| **Usage** | **Usage** | ||||||
|  |  | ||||||
| @@ -25,7 +25,7 @@ function App() { | |||||||
| } | } | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| This will only for `Desktop` devices. | This will only work for `Desktop` devices. | ||||||
|  |  | ||||||
| For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component. | For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
| All `props` are _optional_. | All `props` are _optional_. | ||||||
|  |  | ||||||
| | Name | Type | Default | Description | | | Name | Type | Default | Description | | ||||||
| | --- | --- | --- | --- | --- | --- | --- | --- | --- | | | --- | --- | --- | --- | | ||||||
| | [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. | | | [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. | | ||||||
| | [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered | | | [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered | | ||||||
| | [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode | | | [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode | | ||||||
| @@ -13,7 +13,7 @@ All `props` are _optional_. | |||||||
| | [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. | | | [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. | | ||||||
| | [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into 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. | | | [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. | | ||||||
| | [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. | | | [`generateLinkForSelection`](#generatelinkforselection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. | | ||||||
| | [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. | | | [`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 | | | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw | | ||||||
| | [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner | | | [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner | | ||||||
| @@ -29,8 +29,9 @@ All `props` are _optional_. | |||||||
| | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | | [`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 | | | [`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 | | | [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean | undefined)</code> | \_ | 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>` | | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` | | ||||||
|  | | [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown | ||||||
|  |  | ||||||
| ### Storing custom data on Excalidraw elements | ### Storing custom data on Excalidraw elements | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import React from "react"; |  | ||||||
| import clsx from "clsx"; | import clsx from "clsx"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| import styles from "./styles.module.css"; | import styles from "./styles.module.css"; | ||||||
|  |  | ||||||
| const FeatureList = [ | const FeatureList = [ | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import React from "react"; |  | ||||||
| import clsx from "clsx"; | import clsx from "clsx"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| import styles from "./styles.module.css"; | import styles from "./styles.module.css"; | ||||||
|  |  | ||||||
| type FeatureItem = { | type FeatureItem = { | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import React from "react"; |  | ||||||
| import clsx from "clsx"; |  | ||||||
| import Layout from "@theme/Layout"; |  | ||||||
| import Link from "@docusaurus/Link"; | import Link from "@docusaurus/Link"; | ||||||
| import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; | ||||||
| import styles from "./index.module.css"; |  | ||||||
| import HomepageFeatures from "@site/src/components/Homepage"; | import HomepageFeatures from "@site/src/components/Homepage"; | ||||||
|  | import Layout from "@theme/Layout"; | ||||||
|  | import clsx from "clsx"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
|  | import styles from "./index.module.css"; | ||||||
|  |  | ||||||
| function HomepageHeader() { | function HomepageHeader() { | ||||||
|   const { siteConfig } = useDocusaurusContext(); |   const { siteConfig } = useDocusaurusContext(); | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| // Import the original mapper | // Import the original mapper | ||||||
| import MDXComponents from "@theme-original/MDXComponents"; |  | ||||||
| import Highlight from "@site/src/components/Highlight"; | import Highlight from "@site/src/components/Highlight"; | ||||||
|  | import MDXComponents from "@theme-original/MDXComponents"; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   // Re-use the default mapping |   // Re-use the default mapping | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import dynamic from "next/dynamic"; | import dynamic from "next/dynamic"; | ||||||
| import Script from "next/script"; | import Script from "next/script"; | ||||||
|  |  | ||||||
| import "../common.scss"; | import "../common.scss"; | ||||||
|  |  | ||||||
| // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically | // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| "use client"; | "use client"; | ||||||
| import * as excalidrawLib from "@excalidraw/excalidraw"; | import * as excalidrawLib from "@excalidraw/excalidraw"; | ||||||
| import { Excalidraw } from "@excalidraw/excalidraw"; | import { Excalidraw } from "@excalidraw/excalidraw"; | ||||||
| import App from "../../with-script-in-browser/components/ExampleApp"; |  | ||||||
|  |  | ||||||
| import "@excalidraw/excalidraw/index.css"; | import "@excalidraw/excalidraw/index.css"; | ||||||
|  |  | ||||||
|  | import App from "../../with-script-in-browser/components/ExampleApp"; | ||||||
|  |  | ||||||
| const ExcalidrawWrapper: React.FC = () => { | const ExcalidrawWrapper: React.FC = () => { | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import dynamic from "next/dynamic"; | import dynamic from "next/dynamic"; | ||||||
|  |  | ||||||
| import "../common.scss"; | import "../common.scss"; | ||||||
|  |  | ||||||
| // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically | // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import React from "react"; | import React from "react"; | ||||||
|  |  | ||||||
| import type * as TExcalidraw from "@excalidraw/excalidraw"; | import type * as TExcalidraw from "@excalidraw/excalidraw"; | ||||||
| import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; | import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -52,7 +52,7 @@ | |||||||
|   transform: none; |   transform: none; | ||||||
| } | } | ||||||
|  |  | ||||||
| .excalidraw .panelColumn { | .excalidraw .selected-shape-actions { | ||||||
|   text-align: left; |   text-align: left; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import { nanoid } from "nanoid"; | ||||||
| import React, { | import React, { | ||||||
|   useEffect, |   useEffect, | ||||||
|   useState, |   useState, | ||||||
| @@ -6,13 +7,24 @@ import React, { | |||||||
|   Children, |   Children, | ||||||
|   cloneElement, |   cloneElement, | ||||||
| } from "react"; | } from "react"; | ||||||
| import ExampleSidebar from "./sidebar/ExampleSidebar"; |  | ||||||
|  |  | ||||||
| import type * as TExcalidraw from "@excalidraw/excalidraw"; | import type * as TExcalidraw from "@excalidraw/excalidraw"; | ||||||
|  | import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types"; | ||||||
|  | import type { | ||||||
|  |   NonDeletedExcalidrawElement, | ||||||
|  |   Theme, | ||||||
|  | } from "@excalidraw/excalidraw/element/types"; | ||||||
|  | import type { | ||||||
|  |   AppState, | ||||||
|  |   BinaryFileData, | ||||||
|  |   ExcalidrawImperativeAPI, | ||||||
|  |   ExcalidrawInitialDataState, | ||||||
|  |   Gesture, | ||||||
|  |   LibraryItems, | ||||||
|  |   PointerDownState as ExcalidrawPointerDownState, | ||||||
|  | } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
| import { nanoid } from "nanoid"; | import initialData from "../initialData"; | ||||||
|  |  | ||||||
| import type { ResolvablePromise } from "../utils"; |  | ||||||
| import { | import { | ||||||
|   resolvablePromise, |   resolvablePromise, | ||||||
|   distance2d, |   distance2d, | ||||||
| @@ -23,25 +35,12 @@ import { | |||||||
|  |  | ||||||
| import CustomFooter from "./CustomFooter"; | import CustomFooter from "./CustomFooter"; | ||||||
| import MobileFooter from "./MobileFooter"; | import MobileFooter from "./MobileFooter"; | ||||||
| import initialData from "../initialData"; | import ExampleSidebar from "./sidebar/ExampleSidebar"; | ||||||
|  |  | ||||||
| import type { |  | ||||||
|   AppState, |  | ||||||
|   BinaryFileData, |  | ||||||
|   ExcalidrawImperativeAPI, |  | ||||||
|   ExcalidrawInitialDataState, |  | ||||||
|   Gesture, |  | ||||||
|   LibraryItems, |  | ||||||
|   PointerDownState as ExcalidrawPointerDownState, |  | ||||||
| } from "@excalidraw/excalidraw/types"; |  | ||||||
| import type { |  | ||||||
|   NonDeletedExcalidrawElement, |  | ||||||
|   Theme, |  | ||||||
| } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types"; |  | ||||||
|  |  | ||||||
| import "./ExampleApp.scss"; | import "./ExampleApp.scss"; | ||||||
|  |  | ||||||
|  | import type { ResolvablePromise } from "../utils"; | ||||||
|  |  | ||||||
| type Comment = { | type Comment = { | ||||||
|   x: number; |   x: number; | ||||||
|   y: number; |   y: number; | ||||||
| @@ -105,6 +104,7 @@ export default function ExampleApp({ | |||||||
|   const [viewModeEnabled, setViewModeEnabled] = useState(false); |   const [viewModeEnabled, setViewModeEnabled] = useState(false); | ||||||
|   const [zenModeEnabled, setZenModeEnabled] = useState(false); |   const [zenModeEnabled, setZenModeEnabled] = useState(false); | ||||||
|   const [gridModeEnabled, setGridModeEnabled] = useState(false); |   const [gridModeEnabled, setGridModeEnabled] = useState(false); | ||||||
|  |   const [renderScrollbars, setRenderScrollbars] = useState(false); | ||||||
|   const [blobUrl, setBlobUrl] = useState<string>(""); |   const [blobUrl, setBlobUrl] = useState<string>(""); | ||||||
|   const [canvasUrl, setCanvasUrl] = useState<string>(""); |   const [canvasUrl, setCanvasUrl] = useState<string>(""); | ||||||
|   const [exportWithDarkMode, setExportWithDarkMode] = useState(false); |   const [exportWithDarkMode, setExportWithDarkMode] = useState(false); | ||||||
| @@ -193,6 +193,7 @@ export default function ExampleApp({ | |||||||
|         }) => setPointerData(payload), |         }) => setPointerData(payload), | ||||||
|         viewModeEnabled, |         viewModeEnabled, | ||||||
|         zenModeEnabled, |         zenModeEnabled, | ||||||
|  |         renderScrollbars, | ||||||
|         gridModeEnabled, |         gridModeEnabled, | ||||||
|         theme, |         theme, | ||||||
|         name: "Custom name of drawing", |         name: "Custom name of drawing", | ||||||
| @@ -711,6 +712,14 @@ export default function ExampleApp({ | |||||||
|             /> |             /> | ||||||
|             Grid mode |             Grid mode | ||||||
|           </label> |           </label> | ||||||
|  |           <label> | ||||||
|  |             <input | ||||||
|  |               type="checkbox" | ||||||
|  |               checked={renderScrollbars} | ||||||
|  |               onChange={() => setRenderScrollbars(!renderScrollbars)} | ||||||
|  |             /> | ||||||
|  |             Render scrollbars | ||||||
|  |           </label> | ||||||
|           <label> |           <label> | ||||||
|             <input |             <input | ||||||
|               type="checkbox" |               type="checkbox" | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
| import React from "react"; | import React from "react"; | ||||||
| import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; |  | ||||||
| import CustomFooter from "./CustomFooter"; |  | ||||||
| import type * as TExcalidraw from "@excalidraw/excalidraw"; | import type * as TExcalidraw from "@excalidraw/excalidraw"; | ||||||
|  | import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
|  | import CustomFooter from "./CustomFooter"; | ||||||
|  |  | ||||||
| const MobileFooter = ({ | const MobileFooter = ({ | ||||||
|   excalidrawAPI, |   excalidrawAPI, | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import React, { useState } from "react"; | import React, { useState } from "react"; | ||||||
|  |  | ||||||
| import "./ExampleSidebar.scss"; | import "./ExampleSidebar.scss"; | ||||||
|  |  | ||||||
| export default function Sidebar({ children }: { children: React.ReactNode }) { | export default function Sidebar({ children }: { children: React.ReactNode }) { | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import App from "./components/ExampleApp"; |  | ||||||
| import React, { StrictMode } from "react"; | import React, { StrictMode } from "react"; | ||||||
| import { createRoot } from "react-dom/client"; | import { createRoot } from "react-dom/client"; | ||||||
|  |  | ||||||
|  | import "@excalidraw/excalidraw/index.css"; | ||||||
|  |  | ||||||
| import type * as TExcalidraw from "@excalidraw/excalidraw"; | import type * as TExcalidraw from "@excalidraw/excalidraw"; | ||||||
|  |  | ||||||
| import "@excalidraw/excalidraw/index.css"; | import App from "./components/ExampleApp"; | ||||||
|  |  | ||||||
| declare global { | declare global { | ||||||
|   interface Window { |   interface Window { | ||||||
|   | |||||||
| @@ -15,7 +15,8 @@ | |||||||
|   "scripts": { |   "scripts": { | ||||||
|     "start": "vite", |     "start": "vite", | ||||||
|     "build": "vite build", |     "build": "vite build", | ||||||
|     "build:preview": "yarn build && vite preview --port 5002", |     "preview": "vite preview --port 5002", | ||||||
|  |     "build:preview": "yarn build && yarn preview", | ||||||
|     "build:package": "yarn workspace @excalidraw/excalidraw run build:esm" |     "build:package": "yarn workspace @excalidraw/excalidraw run build:esm" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { unstable_batchedUpdates } from "react-dom"; |  | ||||||
| import { fileOpen as _fileOpen } from "browser-fs-access"; |  | ||||||
| import { MIME_TYPES } from "@excalidraw/excalidraw"; | import { MIME_TYPES } from "@excalidraw/excalidraw"; | ||||||
|  | import { fileOpen as _fileOpen } from "browser-fs-access"; | ||||||
|  | import { unstable_batchedUpdates } from "react-dom"; | ||||||
|  |  | ||||||
| type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; | type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,24 +1,3 @@ | |||||||
| import polyfill from "@excalidraw/excalidraw/polyfill"; |  | ||||||
| import { useCallback, useEffect, useRef, useState } from "react"; |  | ||||||
| import { trackEvent } from "@excalidraw/excalidraw/analytics"; |  | ||||||
| import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; |  | ||||||
| import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; |  | ||||||
| import { TopErrorBoundary } from "./components/TopErrorBoundary"; |  | ||||||
| import { |  | ||||||
|   APP_NAME, |  | ||||||
|   EVENT, |  | ||||||
|   THEME, |  | ||||||
|   TITLE_TIMEOUT, |  | ||||||
|   VERSION_TIMEOUT, |  | ||||||
| } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; |  | ||||||
| import type { |  | ||||||
|   FileId, |  | ||||||
|   NonDeletedExcalidrawElement, |  | ||||||
|   OrderedExcalidrawElement, |  | ||||||
| } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState"; |  | ||||||
| import { t } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import { | import { | ||||||
|   Excalidraw, |   Excalidraw, | ||||||
|   LiveCollaborationTrigger, |   LiveCollaborationTrigger, | ||||||
| @@ -26,15 +5,23 @@ import { | |||||||
|   CaptureUpdateAction, |   CaptureUpdateAction, | ||||||
|   reconcileElements, |   reconcileElements, | ||||||
| } from "@excalidraw/excalidraw"; | } from "@excalidraw/excalidraw"; | ||||||
| import type { | import { trackEvent } from "@excalidraw/excalidraw/analytics"; | ||||||
|   AppState, | import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; | ||||||
|   ExcalidrawImperativeAPI, |  | ||||||
|   BinaryFiles, |  | ||||||
|   ExcalidrawInitialDataState, |  | ||||||
|   UIAppState, |  | ||||||
| } from "@excalidraw/excalidraw/types"; |  | ||||||
| import type { ResolvablePromise } from "@excalidraw/excalidraw/utils"; |  | ||||||
| import { | import { | ||||||
|  |   CommandPalette, | ||||||
|  |   DEFAULT_CATEGORIES, | ||||||
|  | } from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette"; | ||||||
|  | import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; | ||||||
|  | import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; | ||||||
|  | import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; | ||||||
|  | import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog"; | ||||||
|  | import Trans from "@excalidraw/excalidraw/components/Trans"; | ||||||
|  | import { | ||||||
|  |   APP_NAME, | ||||||
|  |   EVENT, | ||||||
|  |   THEME, | ||||||
|  |   TITLE_TIMEOUT, | ||||||
|  |   VERSION_TIMEOUT, | ||||||
|   debounce, |   debounce, | ||||||
|   getVersion, |   getVersion, | ||||||
|   getFrame, |   getFrame, | ||||||
| @@ -42,75 +29,14 @@ import { | |||||||
|   preventUnload, |   preventUnload, | ||||||
|   resolvablePromise, |   resolvablePromise, | ||||||
|   isRunningInIframe, |   isRunningInIframe, | ||||||
| } from "@excalidraw/excalidraw/utils"; |   isDevEnv, | ||||||
| import { | } from "@excalidraw/common"; | ||||||
|   FIREBASE_STORAGE_PREFIXES, | import polyfill from "@excalidraw/excalidraw/polyfill"; | ||||||
|   isExcalidrawPlusSignedUser, | import { useCallback, useEffect, useRef, useState } from "react"; | ||||||
|   STORAGE_KEYS, | import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; | ||||||
|   SYNC_BROWSER_TABS_TIMEOUT, | import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState"; | ||||||
| } from "./app_constants"; | import { t } from "@excalidraw/excalidraw/i18n"; | ||||||
| import type { CollabAPI } from "./collab/Collab"; |  | ||||||
| import Collab, { |  | ||||||
|   collabAPIAtom, |  | ||||||
|   isCollaboratingAtom, |  | ||||||
|   isOfflineAtom, |  | ||||||
| } from "./collab/Collab"; |  | ||||||
| import { |  | ||||||
|   exportToBackend, |  | ||||||
|   getCollaborationLinkData, |  | ||||||
|   isCollaborationLink, |  | ||||||
|   loadScene, |  | ||||||
| } from "./data"; |  | ||||||
| import { |  | ||||||
|   importFromLocalStorage, |  | ||||||
|   importUsernameFromLocalStorage, |  | ||||||
| } from "./data/localStorage"; |  | ||||||
| import CustomStats from "./CustomStats"; |  | ||||||
| import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore"; |  | ||||||
| import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore"; |  | ||||||
| import { |  | ||||||
|   ExportToExcalidrawPlus, |  | ||||||
|   exportToExcalidrawPlus, |  | ||||||
| } from "./components/ExportToExcalidrawPlus"; |  | ||||||
| import { updateStaleImageStatuses } from "./data/FileManager"; |  | ||||||
| import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; |  | ||||||
| import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; |  | ||||||
| import { loadFilesFromFirebase } from "./data/firebase"; |  | ||||||
| import { |  | ||||||
|   LibraryIndexedDBAdapter, |  | ||||||
|   LibraryLocalStorageMigrationAdapter, |  | ||||||
|   LocalData, |  | ||||||
| } from "./data/LocalData"; |  | ||||||
| import { isBrowserStorageStateNewer } from "./data/tabSync"; |  | ||||||
| import clsx from "clsx"; |  | ||||||
| import { |  | ||||||
|   parseLibraryTokensFromUrl, |  | ||||||
|   useHandleLibrary, |  | ||||||
| } from "@excalidraw/excalidraw/data/library"; |  | ||||||
| import { AppMainMenu } from "./components/AppMainMenu"; |  | ||||||
| import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; |  | ||||||
| import { AppFooter } from "./components/AppFooter"; |  | ||||||
| import { |  | ||||||
|   Provider, |  | ||||||
|   useAtom, |  | ||||||
|   useAtomValue, |  | ||||||
|   useAtomWithInitialValue, |  | ||||||
|   appJotaiStore, |  | ||||||
| } from "./app-jotai"; |  | ||||||
|  |  | ||||||
| import "./index.scss"; |  | ||||||
| import type { ResolutionType } from "@excalidraw/excalidraw/utility-types"; |  | ||||||
| import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog"; |  | ||||||
| import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; |  | ||||||
| import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; |  | ||||||
| import Trans from "@excalidraw/excalidraw/components/Trans"; |  | ||||||
| import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; |  | ||||||
| import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; |  | ||||||
| import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; |  | ||||||
| import { |  | ||||||
|   CommandPalette, |  | ||||||
|   DEFAULT_CATEGORIES, |  | ||||||
| } from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette"; |  | ||||||
| import { | import { | ||||||
|   GithubIcon, |   GithubIcon, | ||||||
|   XBrandIcon, |   XBrandIcon, | ||||||
| @@ -121,6 +47,83 @@ import { | |||||||
|   share, |   share, | ||||||
|   youtubeIcon, |   youtubeIcon, | ||||||
| } from "@excalidraw/excalidraw/components/icons"; | } from "@excalidraw/excalidraw/components/icons"; | ||||||
|  | import { isElementLink } from "@excalidraw/element"; | ||||||
|  | import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore"; | ||||||
|  | import { newElementWith } from "@excalidraw/element"; | ||||||
|  | import { isInitializedImageElement } from "@excalidraw/element"; | ||||||
|  | import clsx from "clsx"; | ||||||
|  | import { | ||||||
|  |   parseLibraryTokensFromUrl, | ||||||
|  |   useHandleLibrary, | ||||||
|  | } from "@excalidraw/excalidraw/data/library"; | ||||||
|  |  | ||||||
|  | import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; | ||||||
|  | import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore"; | ||||||
|  | import type { | ||||||
|  |   FileId, | ||||||
|  |   NonDeletedExcalidrawElement, | ||||||
|  |   OrderedExcalidrawElement, | ||||||
|  | } from "@excalidraw/element/types"; | ||||||
|  | import type { | ||||||
|  |   AppState, | ||||||
|  |   ExcalidrawImperativeAPI, | ||||||
|  |   BinaryFiles, | ||||||
|  |   ExcalidrawInitialDataState, | ||||||
|  |   UIAppState, | ||||||
|  | } from "@excalidraw/excalidraw/types"; | ||||||
|  | import type { ResolutionType } from "@excalidraw/common/utility-types"; | ||||||
|  | import type { ResolvablePromise } from "@excalidraw/common/utils"; | ||||||
|  |  | ||||||
|  | import CustomStats from "./CustomStats"; | ||||||
|  | import { | ||||||
|  |   Provider, | ||||||
|  |   useAtom, | ||||||
|  |   useAtomValue, | ||||||
|  |   useAtomWithInitialValue, | ||||||
|  |   appJotaiStore, | ||||||
|  | } from "./app-jotai"; | ||||||
|  | import { | ||||||
|  |   FIREBASE_STORAGE_PREFIXES, | ||||||
|  |   isExcalidrawPlusSignedUser, | ||||||
|  |   STORAGE_KEYS, | ||||||
|  |   SYNC_BROWSER_TABS_TIMEOUT, | ||||||
|  | } from "./app_constants"; | ||||||
|  | import Collab, { | ||||||
|  |   collabAPIAtom, | ||||||
|  |   isCollaboratingAtom, | ||||||
|  |   isOfflineAtom, | ||||||
|  | } from "./collab/Collab"; | ||||||
|  | import { AppFooter } from "./components/AppFooter"; | ||||||
|  | import { AppMainMenu } from "./components/AppMainMenu"; | ||||||
|  | import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; | ||||||
|  | import { | ||||||
|  |   ExportToExcalidrawPlus, | ||||||
|  |   exportToExcalidrawPlus, | ||||||
|  | } from "./components/ExportToExcalidrawPlus"; | ||||||
|  | import { TopErrorBoundary } from "./components/TopErrorBoundary"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   exportToBackend, | ||||||
|  |   getCollaborationLinkData, | ||||||
|  |   isCollaborationLink, | ||||||
|  |   loadScene, | ||||||
|  | } from "./data"; | ||||||
|  |  | ||||||
|  | import { updateStaleImageStatuses } from "./data/FileManager"; | ||||||
|  | import { | ||||||
|  |   importFromLocalStorage, | ||||||
|  |   importUsernameFromLocalStorage, | ||||||
|  | } from "./data/localStorage"; | ||||||
|  |  | ||||||
|  | import { loadFilesFromFirebase } from "./data/firebase"; | ||||||
|  | import { | ||||||
|  |   LibraryIndexedDBAdapter, | ||||||
|  |   LibraryLocalStorageMigrationAdapter, | ||||||
|  |   LocalData, | ||||||
|  | } from "./data/LocalData"; | ||||||
|  | import { isBrowserStorageStateNewer } from "./data/tabSync"; | ||||||
|  | import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; | ||||||
|  | import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; | ||||||
| import { useHandleAppTheme } from "./useHandleAppTheme"; | import { useHandleAppTheme } from "./useHandleAppTheme"; | ||||||
| import { getPreferredLanguage } from "./app-language/language-detector"; | import { getPreferredLanguage } from "./app-language/language-detector"; | ||||||
| import { useAppLangCode } from "./app-language/language-state"; | import { useAppLangCode } from "./app-language/language-state"; | ||||||
| @@ -131,7 +134,10 @@ import DebugCanvas, { | |||||||
| } from "./components/DebugCanvas"; | } from "./components/DebugCanvas"; | ||||||
| import { AIComponents } from "./components/AI"; | import { AIComponents } from "./components/AI"; | ||||||
| import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; | import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; | ||||||
| import { isElementLink } from "@excalidraw/excalidraw/element/elementLink"; |  | ||||||
|  | import "./index.scss"; | ||||||
|  |  | ||||||
|  | import type { CollabAPI } from "./collab/Collab"; | ||||||
|  |  | ||||||
| polyfill(); | polyfill(); | ||||||
|  |  | ||||||
| @@ -377,7 +383,7 @@ const ExcalidrawWrapper = () => { | |||||||
|   const [, forceRefresh] = useState(false); |   const [, forceRefresh] = useState(false); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (import.meta.env.DEV) { |     if (isDevEnv()) { | ||||||
|       const debugState = loadSavedDebugState(); |       const debugState = loadSavedDebugState(); | ||||||
|  |  | ||||||
|       if (debugState.enabled && !window.visualDebug) { |       if (debugState.enabled && !window.visualDebug) { | ||||||
| @@ -602,7 +608,13 @@ const ExcalidrawWrapper = () => { | |||||||
|           excalidrawAPI.getSceneElements(), |           excalidrawAPI.getSceneElements(), | ||||||
|         ) |         ) | ||||||
|       ) { |       ) { | ||||||
|  |         if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") { | ||||||
|           preventUnload(event); |           preventUnload(event); | ||||||
|  |         } else { | ||||||
|  |           console.warn( | ||||||
|  |             "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)", | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|     window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler); |     window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler); | ||||||
|   | |||||||
| @@ -1,15 +1,21 @@ | |||||||
|  | import { Stats } from "@excalidraw/excalidraw"; | ||||||
|  | import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard"; | ||||||
|  | import { | ||||||
|  |   DEFAULT_VERSION, | ||||||
|  |   debounce, | ||||||
|  |   getVersion, | ||||||
|  |   nFormatter, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  | import { t } from "@excalidraw/excalidraw/i18n"; | ||||||
| import { useEffect, useState } from "react"; | import { useEffect, useState } from "react"; | ||||||
| import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils"; |  | ||||||
|  | import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; | ||||||
|  | import type { UIAppState } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
| import { | import { | ||||||
|   getElementsStorageSize, |   getElementsStorageSize, | ||||||
|   getTotalStorageSize, |   getTotalStorageSize, | ||||||
| } from "./data/localStorage"; | } from "./data/localStorage"; | ||||||
| import { DEFAULT_VERSION } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import { t } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard"; |  | ||||||
| import type { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import type { UIAppState } from "@excalidraw/excalidraw/types"; |  | ||||||
| import { Stats } from "@excalidraw/excalidraw"; |  | ||||||
|  |  | ||||||
| type StorageSizes = { scene: number; total: number }; | type StorageSizes = { scene: number; total: number }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,13 +1,15 @@ | |||||||
|  | import { base64urlToString } from "@excalidraw/excalidraw/data/encode"; | ||||||
|  | import { ExcalidrawError } from "@excalidraw/excalidraw/errors"; | ||||||
| import { useLayoutEffect, useRef } from "react"; | import { useLayoutEffect, useRef } from "react"; | ||||||
| import { STORAGE_KEYS } from "./app_constants"; |  | ||||||
| import { LocalData } from "./data/LocalData"; |  | ||||||
| import type { | import type { | ||||||
|   FileId, |   FileId, | ||||||
|   OrderedExcalidrawElement, |   OrderedExcalidrawElement, | ||||||
| } from "@excalidraw/excalidraw/element/types"; | } from "@excalidraw/element/types"; | ||||||
| import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types"; | import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types"; | ||||||
| import { ExcalidrawError } from "@excalidraw/excalidraw/errors"; |  | ||||||
| import { base64urlToString } from "@excalidraw/excalidraw/data/encode"; | import { STORAGE_KEYS } from "./app_constants"; | ||||||
|  | import { LocalData } from "./data/LocalData"; | ||||||
|  |  | ||||||
| const EVENT_REQUEST_SCENE = "REQUEST_SCENE"; | const EVENT_REQUEST_SCENE = "REQUEST_SCENE"; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import React from "react"; |  | ||||||
| import { useI18n, languages } from "@excalidraw/excalidraw/i18n"; | import { useI18n, languages } from "@excalidraw/excalidraw/i18n"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| import { useSetAtom } from "../app-jotai"; | import { useSetAtom } from "../app-jotai"; | ||||||
|  |  | ||||||
| import { appLangCodeAtom } from "./language-state"; | import { appLangCodeAtom } from "./language-state"; | ||||||
|  |  | ||||||
| export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { | export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import LanguageDetector from "i18next-browser-languagedetector"; |  | ||||||
| import { defaultLang, languages } from "@excalidraw/excalidraw"; | import { defaultLang, languages } from "@excalidraw/excalidraw"; | ||||||
|  | import LanguageDetector from "i18next-browser-languagedetector"; | ||||||
|  |  | ||||||
| export const languageDetector = new LanguageDetector(); | export const languageDetector = new LanguageDetector(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
| import { useEffect } from "react"; | import { useEffect } from "react"; | ||||||
|  |  | ||||||
| import { atom, useAtom } from "../app-jotai"; | import { atom, useAtom } from "../app-jotai"; | ||||||
|  |  | ||||||
| import { getPreferredLanguage, languageDetector } from "./language-detector"; | import { getPreferredLanguage, languageDetector } from "./language-detector"; | ||||||
|  |  | ||||||
| export const appLangCodeAtom = atom(getPreferredLanguage()); | export const appLangCodeAtom = atom(getPreferredLanguage()); | ||||||
|   | |||||||
| @@ -1,21 +1,3 @@ | |||||||
| import throttle from "lodash.throttle"; |  | ||||||
| import { PureComponent } from "react"; |  | ||||||
| import type { |  | ||||||
|   BinaryFileData, |  | ||||||
|   ExcalidrawImperativeAPI, |  | ||||||
|   SocketId, |  | ||||||
|   Collaborator, |  | ||||||
|   Gesture, |  | ||||||
| } from "@excalidraw/excalidraw/types"; |  | ||||||
| import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; |  | ||||||
| import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; |  | ||||||
| import type { |  | ||||||
|   ExcalidrawElement, |  | ||||||
|   FileId, |  | ||||||
|   InitializedExcalidrawImageElement, |  | ||||||
|   OrderedExcalidrawElement, |  | ||||||
| } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import { | import { | ||||||
|   CaptureUpdateAction, |   CaptureUpdateAction, | ||||||
|   getSceneVersion, |   getSceneVersion, | ||||||
| @@ -23,12 +5,51 @@ import { | |||||||
|   zoomToFitBounds, |   zoomToFitBounds, | ||||||
|   reconcileElements, |   reconcileElements, | ||||||
| } from "@excalidraw/excalidraw"; | } from "@excalidraw/excalidraw"; | ||||||
|  | import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; | ||||||
|  | import { APP_NAME, EVENT } from "@excalidraw/common"; | ||||||
| import { | import { | ||||||
|  |   IDLE_THRESHOLD, | ||||||
|  |   ACTIVE_THRESHOLD, | ||||||
|  |   UserIdleState, | ||||||
|   assertNever, |   assertNever, | ||||||
|  |   isDevEnv, | ||||||
|  |   isTestEnv, | ||||||
|   preventUnload, |   preventUnload, | ||||||
|   resolvablePromise, |   resolvablePromise, | ||||||
|   throttleRAF, |   throttleRAF, | ||||||
| } from "@excalidraw/excalidraw/utils"; | } from "@excalidraw/common"; | ||||||
|  | import { decryptData } from "@excalidraw/excalidraw/data/encryption"; | ||||||
|  | import { getVisibleSceneBounds } from "@excalidraw/element"; | ||||||
|  | import { newElementWith } from "@excalidraw/element"; | ||||||
|  | import { isImageElement, isInitializedImageElement } from "@excalidraw/element"; | ||||||
|  | import { AbortError } from "@excalidraw/excalidraw/errors"; | ||||||
|  | import { t } from "@excalidraw/excalidraw/i18n"; | ||||||
|  | import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils"; | ||||||
|  |  | ||||||
|  | import throttle from "lodash.throttle"; | ||||||
|  | import { PureComponent } from "react"; | ||||||
|  |  | ||||||
|  | import type { | ||||||
|  |   ReconciledExcalidrawElement, | ||||||
|  |   RemoteExcalidrawElement, | ||||||
|  | } from "@excalidraw/excalidraw/data/reconcile"; | ||||||
|  | import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; | ||||||
|  | import type { | ||||||
|  |   ExcalidrawElement, | ||||||
|  |   FileId, | ||||||
|  |   InitializedExcalidrawImageElement, | ||||||
|  |   OrderedExcalidrawElement, | ||||||
|  | } from "@excalidraw/element/types"; | ||||||
|  | import type { | ||||||
|  |   BinaryFileData, | ||||||
|  |   ExcalidrawImperativeAPI, | ||||||
|  |   SocketId, | ||||||
|  |   Collaborator, | ||||||
|  |   Gesture, | ||||||
|  | } from "@excalidraw/excalidraw/types"; | ||||||
|  | import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; | ||||||
|  |  | ||||||
|  | import { appJotaiStore, atom } from "../app-jotai"; | ||||||
| import { | import { | ||||||
|   CURSOR_SYNC_TIMEOUT, |   CURSOR_SYNC_TIMEOUT, | ||||||
|   FILE_UPLOAD_MAX_BYTES, |   FILE_UPLOAD_MAX_BYTES, | ||||||
| @@ -39,15 +60,17 @@ import { | |||||||
|   SYNC_FULL_SCENE_INTERVAL_MS, |   SYNC_FULL_SCENE_INTERVAL_MS, | ||||||
|   WS_EVENTS, |   WS_EVENTS, | ||||||
| } from "../app_constants"; | } from "../app_constants"; | ||||||
| import type { |  | ||||||
|   SocketUpdateDataSource, |  | ||||||
|   SyncableExcalidrawElement, |  | ||||||
| } from "../data"; |  | ||||||
| import { | import { | ||||||
|   generateCollaborationLinkData, |   generateCollaborationLinkData, | ||||||
|   getCollaborationLink, |   getCollaborationLink, | ||||||
|   getSyncableElements, |   getSyncableElements, | ||||||
| } from "../data"; | } from "../data"; | ||||||
|  | import { | ||||||
|  |   encodeFilesForUpload, | ||||||
|  |   FileManager, | ||||||
|  |   updateStaleImageStatuses, | ||||||
|  | } from "../data/FileManager"; | ||||||
|  | import { LocalData } from "../data/LocalData"; | ||||||
| import { | import { | ||||||
|   isSavedToFirebase, |   isSavedToFirebase, | ||||||
|   loadFilesFromFirebase, |   loadFilesFromFirebase, | ||||||
| @@ -59,36 +82,15 @@ import { | |||||||
|   importUsernameFromLocalStorage, |   importUsernameFromLocalStorage, | ||||||
|   saveUsernameToLocalStorage, |   saveUsernameToLocalStorage, | ||||||
| } from "../data/localStorage"; | } from "../data/localStorage"; | ||||||
| import Portal from "./Portal"; |  | ||||||
| import { t } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import { |  | ||||||
|   IDLE_THRESHOLD, |  | ||||||
|   ACTIVE_THRESHOLD, |  | ||||||
|   UserIdleState, |  | ||||||
| } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import { |  | ||||||
|   encodeFilesForUpload, |  | ||||||
|   FileManager, |  | ||||||
|   updateStaleImageStatuses, |  | ||||||
| } from "../data/FileManager"; |  | ||||||
| import { AbortError } from "@excalidraw/excalidraw/errors"; |  | ||||||
| import { |  | ||||||
|   isImageElement, |  | ||||||
|   isInitializedImageElement, |  | ||||||
| } from "@excalidraw/excalidraw/element/typeChecks"; |  | ||||||
| import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; |  | ||||||
| import { decryptData } from "@excalidraw/excalidraw/data/encryption"; |  | ||||||
| import { resetBrowserStateVersions } from "../data/tabSync"; | import { resetBrowserStateVersions } from "../data/tabSync"; | ||||||
| import { LocalData } from "../data/LocalData"; |  | ||||||
| import { appJotaiStore, atom } from "../app-jotai"; |  | ||||||
| import type { Mutable, ValueOf } from "@excalidraw/excalidraw/utility-types"; |  | ||||||
| import { getVisibleSceneBounds } from "@excalidraw/excalidraw/element/bounds"; |  | ||||||
| import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils"; |  | ||||||
| import { collabErrorIndicatorAtom } from "./CollabError"; | import { collabErrorIndicatorAtom } from "./CollabError"; | ||||||
|  | import Portal from "./Portal"; | ||||||
|  |  | ||||||
| import type { | import type { | ||||||
|   ReconciledExcalidrawElement, |   SocketUpdateDataSource, | ||||||
|   RemoteExcalidrawElement, |   SyncableExcalidrawElement, | ||||||
| } from "@excalidraw/excalidraw/data/reconcile"; | } from "../data"; | ||||||
|  |  | ||||||
| export const collabAPIAtom = atom<CollabAPI | null>(null); | export const collabAPIAtom = atom<CollabAPI | null>(null); | ||||||
| export const isCollaboratingAtom = atom(false); | export const isCollaboratingAtom = atom(false); | ||||||
| @@ -236,7 +238,7 @@ class Collab extends PureComponent<CollabProps, CollabState> { | |||||||
|  |  | ||||||
|     appJotaiStore.set(collabAPIAtom, collabAPI); |     appJotaiStore.set(collabAPIAtom, collabAPI); | ||||||
|  |  | ||||||
|     if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { |     if (isTestEnv() || isDevEnv()) { | ||||||
|       window.collab = window.collab || ({} as Window["collab"]); |       window.collab = window.collab || ({} as Window["collab"]); | ||||||
|       Object.defineProperties(window, { |       Object.defineProperties(window, { | ||||||
|         collab: { |         collab: { | ||||||
| @@ -296,7 +298,13 @@ class Collab extends PureComponent<CollabProps, CollabState> { | |||||||
|       //  the purpose is to run in immediately after user decides to stay |       //  the purpose is to run in immediately after user decides to stay | ||||||
|       this.saveCollabRoomToFirebase(syncableElements); |       this.saveCollabRoomToFirebase(syncableElements); | ||||||
|  |  | ||||||
|  |       if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") { | ||||||
|         preventUnload(event); |         preventUnload(event); | ||||||
|  |       } else { | ||||||
|  |         console.warn( | ||||||
|  |           "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)", | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -1009,7 +1017,7 @@ declare global { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { | if (isTestEnv() || isDevEnv()) { | ||||||
|   window.collab = window.collab || ({} as Window["collab"]); |   window.collab = window.collab || ({} as Window["collab"]); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip"; | |||||||
| import { warning } from "@excalidraw/excalidraw/components/icons"; | import { warning } from "@excalidraw/excalidraw/components/icons"; | ||||||
| import clsx from "clsx"; | import clsx from "clsx"; | ||||||
| import { useEffect, useRef, useState } from "react"; | import { useEffect, useRef, useState } from "react"; | ||||||
|  |  | ||||||
| import { atom } from "../app-jotai"; | import { atom } from "../app-jotai"; | ||||||
|  |  | ||||||
| import "./CollabError.scss"; | import "./CollabError.scss"; | ||||||
|   | |||||||
| @@ -1,25 +1,26 @@ | |||||||
|  | import { CaptureUpdateAction } from "@excalidraw/excalidraw"; | ||||||
|  | import { trackEvent } from "@excalidraw/excalidraw/analytics"; | ||||||
|  | import { encryptData } from "@excalidraw/excalidraw/data/encryption"; | ||||||
|  | import { newElementWith } from "@excalidraw/element"; | ||||||
|  | import throttle from "lodash.throttle"; | ||||||
|  |  | ||||||
|  | import type { UserIdleState } from "@excalidraw/common"; | ||||||
|  | import type { OrderedExcalidrawElement } from "@excalidraw/element/types"; | ||||||
|  | import type { | ||||||
|  |   OnUserFollowedPayload, | ||||||
|  |   SocketId, | ||||||
|  | } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
|  | import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; | ||||||
|  | import { isSyncableElement } from "../data"; | ||||||
|  |  | ||||||
| import type { | import type { | ||||||
|   SocketUpdateData, |   SocketUpdateData, | ||||||
|   SocketUpdateDataSource, |   SocketUpdateDataSource, | ||||||
|   SyncableExcalidrawElement, |   SyncableExcalidrawElement, | ||||||
| } from "../data"; | } from "../data"; | ||||||
| import { isSyncableElement } from "../data"; |  | ||||||
|  |  | ||||||
| import type { TCollabClass } from "./Collab"; | import type { TCollabClass } from "./Collab"; | ||||||
|  |  | ||||||
| import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; |  | ||||||
| import type { |  | ||||||
|   OnUserFollowedPayload, |  | ||||||
|   SocketId, |  | ||||||
| } from "@excalidraw/excalidraw/types"; |  | ||||||
| import type { UserIdleState } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import { trackEvent } from "@excalidraw/excalidraw/analytics"; |  | ||||||
| import throttle from "lodash.throttle"; |  | ||||||
| import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; |  | ||||||
| import { encryptData } from "@excalidraw/excalidraw/data/encryption"; |  | ||||||
| import type { Socket } from "socket.io-client"; | import type { Socket } from "socket.io-client"; | ||||||
| import { CaptureUpdateAction } from "@excalidraw/excalidraw"; |  | ||||||
|  |  | ||||||
| class Portal { | class Portal { | ||||||
|   collab: TCollabClass; |   collab: TCollabClass; | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; |  | ||||||
| import { | import { | ||||||
|   DiagramToCodePlugin, |   DiagramToCodePlugin, | ||||||
|   exportToBlob, |   exportToBlob, | ||||||
| @@ -7,7 +6,9 @@ import { | |||||||
|   TTDDialog, |   TTDDialog, | ||||||
| } from "@excalidraw/excalidraw"; | } from "@excalidraw/excalidraw"; | ||||||
| import { getDataURL } from "@excalidraw/excalidraw/data/blob"; | import { getDataURL } from "@excalidraw/excalidraw/data/blob"; | ||||||
| import { safelyParseJSON } from "@excalidraw/excalidraw/utils"; | import { safelyParseJSON } from "@excalidraw/common"; | ||||||
|  |  | ||||||
|  | import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
| export const AIComponents = ({ | export const AIComponents = ({ | ||||||
|   excalidrawAPI, |   excalidrawAPI, | ||||||
| @@ -72,7 +73,7 @@ export const AIComponents = ({ | |||||||
|                   </br> |                   </br> | ||||||
|                   <div>You can also try <a href="${ |                   <div>You can also try <a href="${ | ||||||
|                     import.meta.env.VITE_APP_PLUS_LP |                     import.meta.env.VITE_APP_PLUS_LP | ||||||
|                   }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div> |                   }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div> | ||||||
|                 </div> |                 </div> | ||||||
|                 </body> |                 </body> | ||||||
|                 </html>`, |                 </html>`, | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import React from "react"; |  | ||||||
| import { Footer } from "@excalidraw/excalidraw/index"; | import { Footer } from "@excalidraw/excalidraw/index"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
|  | import { isExcalidrawPlusSignedUser } from "../app_constants"; | ||||||
|  |  | ||||||
|  | import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas"; | ||||||
| import { EncryptedIcon } from "./EncryptedIcon"; | import { EncryptedIcon } from "./EncryptedIcon"; | ||||||
| import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; | import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; | ||||||
| import { isExcalidrawPlusSignedUser } from "../app_constants"; |  | ||||||
| import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas"; |  | ||||||
|  |  | ||||||
| export const AppFooter = React.memo( | export const AppFooter = React.memo( | ||||||
|   ({ onChange }: { onChange: () => void }) => { |   ({ onChange }: { onChange: () => void }) => { | ||||||
|   | |||||||
| @@ -1,13 +1,18 @@ | |||||||
| import React from "react"; |  | ||||||
| import { | import { | ||||||
|   loginIcon, |   loginIcon, | ||||||
|   ExcalLogo, |   ExcalLogo, | ||||||
|   eyeIcon, |   eyeIcon, | ||||||
| } from "@excalidraw/excalidraw/components/icons"; | } from "@excalidraw/excalidraw/components/icons"; | ||||||
| import type { Theme } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import { MainMenu } from "@excalidraw/excalidraw/index"; | import { MainMenu } from "@excalidraw/excalidraw/index"; | ||||||
| import { isExcalidrawPlusSignedUser } from "../app_constants"; | import React from "react"; | ||||||
|  |  | ||||||
|  | import { isDevEnv } from "@excalidraw/common"; | ||||||
|  |  | ||||||
|  | import type { Theme } from "@excalidraw/element/types"; | ||||||
|  |  | ||||||
| import { LanguageList } from "../app-language/LanguageList"; | import { LanguageList } from "../app-language/LanguageList"; | ||||||
|  | import { isExcalidrawPlusSignedUser } from "../app_constants"; | ||||||
|  |  | ||||||
| import { saveDebugState } from "./DebugCanvas"; | import { saveDebugState } from "./DebugCanvas"; | ||||||
|  |  | ||||||
| export const AppMainMenu: React.FC<{ | export const AppMainMenu: React.FC<{ | ||||||
| @@ -54,7 +59,7 @@ export const AppMainMenu: React.FC<{ | |||||||
|       > |       > | ||||||
|         {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} |         {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} | ||||||
|       </MainMenu.ItemLink> |       </MainMenu.ItemLink> | ||||||
|       {import.meta.env.DEV && ( |       {isDevEnv() && ( | ||||||
|         <MainMenu.Item |         <MainMenu.Item | ||||||
|           icon={eyeIcon} |           icon={eyeIcon} | ||||||
|           onClick={() => { |           onClick={() => { | ||||||
|   | |||||||
| @@ -1,9 +1,10 @@ | |||||||
| import React from "react"; |  | ||||||
| import { loginIcon } from "@excalidraw/excalidraw/components/icons"; | import { loginIcon } from "@excalidraw/excalidraw/components/icons"; | ||||||
|  | import { POINTER_EVENTS } from "@excalidraw/common"; | ||||||
| import { useI18n } from "@excalidraw/excalidraw/i18n"; | import { useI18n } from "@excalidraw/excalidraw/i18n"; | ||||||
| import { WelcomeScreen } from "@excalidraw/excalidraw/index"; | import { WelcomeScreen } from "@excalidraw/excalidraw/index"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| import { isExcalidrawPlusSignedUser } from "../app_constants"; | import { isExcalidrawPlusSignedUser } from "../app_constants"; | ||||||
| import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants"; |  | ||||||
|  |  | ||||||
| export const AppWelcomeScreen: React.FC<{ | export const AppWelcomeScreen: React.FC<{ | ||||||
|   onCollabDialogOpen: () => any; |   onCollabDialogOpen: () => any; | ||||||
|   | |||||||
| @@ -1,24 +1,28 @@ | |||||||
| import { useCallback, useImperativeHandle, useRef } from "react"; |  | ||||||
| import { type AppState } from "@excalidraw/excalidraw/types"; |  | ||||||
| import { throttleRAF } from "@excalidraw/excalidraw/utils"; |  | ||||||
| import { |  | ||||||
|   bootstrapCanvas, |  | ||||||
|   getNormalizedCanvasDimensions, |  | ||||||
| } from "@excalidraw/excalidraw/renderer/helpers"; |  | ||||||
| import type { DebugElement } from "@excalidraw/excalidraw/visualdebug"; |  | ||||||
| import { | import { | ||||||
|   ArrowheadArrowIcon, |   ArrowheadArrowIcon, | ||||||
|   CloseIcon, |   CloseIcon, | ||||||
|   TrashIcon, |   TrashIcon, | ||||||
| } from "@excalidraw/excalidraw/components/icons"; | } from "@excalidraw/excalidraw/components/icons"; | ||||||
| import { STORAGE_KEYS } from "../app_constants"; | import { | ||||||
| import type { Curve } from "../../packages/math"; |   bootstrapCanvas, | ||||||
|  |   getNormalizedCanvasDimensions, | ||||||
|  | } from "@excalidraw/excalidraw/renderer/helpers"; | ||||||
|  | import { type AppState } from "@excalidraw/excalidraw/types"; | ||||||
|  | import { throttleRAF } from "@excalidraw/common"; | ||||||
|  | import { useCallback, useImperativeHandle, useRef } from "react"; | ||||||
|  |  | ||||||
| import { | import { | ||||||
|   isLineSegment, |   isLineSegment, | ||||||
|   type GlobalPoint, |   type GlobalPoint, | ||||||
|   type LineSegment, |   type LineSegment, | ||||||
| } from "../../packages/math"; | } from "@excalidraw/math"; | ||||||
| import { isCurve } from "../../packages/math/curve"; | import { isCurve } from "@excalidraw/math/curve"; | ||||||
|  |  | ||||||
|  | import type { DebugElement } from "@excalidraw/excalidraw/visualdebug"; | ||||||
|  |  | ||||||
|  | import type { Curve } from "@excalidraw/math"; | ||||||
|  |  | ||||||
|  | import { STORAGE_KEYS } from "../app_constants"; | ||||||
|  |  | ||||||
| const renderLine = ( | const renderLine = ( | ||||||
|   context: CanvasRenderingContext2D, |   context: CanvasRenderingContext2D, | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { shield } from "@excalidraw/excalidraw/components/icons"; |  | ||||||
| import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip"; | import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip"; | ||||||
|  | import { shield } from "@excalidraw/excalidraw/components/icons"; | ||||||
| import { useI18n } from "@excalidraw/excalidraw/i18n"; | import { useI18n } from "@excalidraw/excalidraw/i18n"; | ||||||
|  |  | ||||||
| export const EncryptedIcon = () => { | export const EncryptedIcon = () => { | ||||||
| @@ -10,7 +10,7 @@ export const EncryptedIcon = () => { | |||||||
|       className="encrypted-icon tooltip" |       className="encrypted-icon tooltip" | ||||||
|       href="https://plus.excalidraw.com/blog/end-to-end-encryption" |       href="https://plus.excalidraw.com/blog/end-to-end-encryption" | ||||||
|       target="_blank" |       target="_blank" | ||||||
|       rel="noopener noreferrer" |       rel="noopener" | ||||||
|       aria-label={t("encrypted.link")} |       aria-label={t("encrypted.link")} | ||||||
|     > |     > | ||||||
|       <Tooltip label={t("encrypted.tooltip")} long={true}> |       <Tooltip label={t("encrypted.tooltip")} long={true}> | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => { | |||||||
|         import.meta.env.VITE_APP_PLUS_APP |         import.meta.env.VITE_APP_PLUS_APP | ||||||
|       }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`} |       }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`} | ||||||
|       target="_blank" |       target="_blank" | ||||||
|       rel="noreferrer" |       rel="noopener" | ||||||
|       className="plus-button" |       className="plus-button" | ||||||
|     > |     > | ||||||
|       Go to Excalidraw+ |       Go to Excalidraw+ | ||||||
|   | |||||||
| @@ -1,31 +1,33 @@ | |||||||
| import React from "react"; | import React from "react"; | ||||||
|  | import { uploadBytes, ref } from "firebase/storage"; | ||||||
|  | import { nanoid } from "nanoid"; | ||||||
|  |  | ||||||
|  | import { trackEvent } from "@excalidraw/excalidraw/analytics"; | ||||||
| import { Card } from "@excalidraw/excalidraw/components/Card"; | import { Card } from "@excalidraw/excalidraw/components/Card"; | ||||||
|  | import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo"; | ||||||
| import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton"; | import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton"; | ||||||
|  | import { MIME_TYPES, getFrame } from "@excalidraw/common"; | ||||||
|  | import { | ||||||
|  |   encryptData, | ||||||
|  |   generateEncryptionKey, | ||||||
|  | } from "@excalidraw/excalidraw/data/encryption"; | ||||||
| import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; | import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; | ||||||
| import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; | import { isInitializedImageElement } from "@excalidraw/element"; | ||||||
|  | import { useI18n } from "@excalidraw/excalidraw/i18n"; | ||||||
|  |  | ||||||
| import type { | import type { | ||||||
|   FileId, |   FileId, | ||||||
|   NonDeletedExcalidrawElement, |   NonDeletedExcalidrawElement, | ||||||
| } from "@excalidraw/excalidraw/element/types"; | } from "@excalidraw/element/types"; | ||||||
| import type { | import type { | ||||||
|   AppState, |   AppState, | ||||||
|   BinaryFileData, |   BinaryFileData, | ||||||
|   BinaryFiles, |   BinaryFiles, | ||||||
| } from "@excalidraw/excalidraw/types"; | } from "@excalidraw/excalidraw/types"; | ||||||
| import { nanoid } from "nanoid"; |  | ||||||
| import { useI18n } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import { |  | ||||||
|   encryptData, |  | ||||||
|   generateEncryptionKey, |  | ||||||
| } from "@excalidraw/excalidraw/data/encryption"; |  | ||||||
| import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; |  | ||||||
| import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; | import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; | ||||||
| import { encodeFilesForUpload } from "../data/FileManager"; | import { encodeFilesForUpload } from "../data/FileManager"; | ||||||
| import { uploadBytes, ref } from "firebase/storage"; | import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; | ||||||
| import { MIME_TYPES } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import { trackEvent } from "@excalidraw/excalidraw/analytics"; |  | ||||||
| import { getFrame } from "@excalidraw/excalidraw/utils"; |  | ||||||
| import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo"; |  | ||||||
|  |  | ||||||
| export const exportToExcalidrawPlus = async ( | export const exportToExcalidrawPlus = async ( | ||||||
|   elements: readonly NonDeletedExcalidrawElement[], |   elements: readonly NonDeletedExcalidrawElement[], | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
|  | import { THEME } from "@excalidraw/common"; | ||||||
| import oc from "open-color"; | import oc from "open-color"; | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { THEME } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import type { Theme } from "@excalidraw/excalidraw/element/types"; | import type { Theme } from "@excalidraw/element/types"; | ||||||
|  |  | ||||||
| // https://github.com/tholman/github-corners | // https://github.com/tholman/github-corners | ||||||
| export const GitHubCorner = React.memo( | export const GitHubCorner = React.memo( | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import React from "react"; |  | ||||||
| import * as Sentry from "@sentry/browser"; |  | ||||||
| import { t } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import Trans from "@excalidraw/excalidraw/components/Trans"; | import Trans from "@excalidraw/excalidraw/components/Trans"; | ||||||
|  | import { t } from "@excalidraw/excalidraw/i18n"; | ||||||
|  | import * as Sentry from "@sentry/browser"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| interface TopErrorBoundaryState { | interface TopErrorBoundaryState { | ||||||
|   hasError: boolean; |   hasError: boolean; | ||||||
|   | |||||||
| @@ -1,14 +1,15 @@ | |||||||
| import { CaptureUpdateAction } from "@excalidraw/excalidraw"; | import { CaptureUpdateAction } from "@excalidraw/excalidraw"; | ||||||
| import { compressData } from "@excalidraw/excalidraw/data/encode"; | import { compressData } from "@excalidraw/excalidraw/data/encode"; | ||||||
| import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; | import { newElementWith } from "@excalidraw/element"; | ||||||
| import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; | import { isInitializedImageElement } from "@excalidraw/element"; | ||||||
|  | import { t } from "@excalidraw/excalidraw/i18n"; | ||||||
|  |  | ||||||
| import type { | import type { | ||||||
|   ExcalidrawElement, |   ExcalidrawElement, | ||||||
|   ExcalidrawImageElement, |   ExcalidrawImageElement, | ||||||
|   FileId, |   FileId, | ||||||
|   InitializedExcalidrawImageElement, |   InitializedExcalidrawImageElement, | ||||||
| } from "@excalidraw/excalidraw/element/types"; | } from "@excalidraw/element/types"; | ||||||
| import { t } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import type { | import type { | ||||||
|   BinaryFileData, |   BinaryFileData, | ||||||
|   BinaryFileMetadata, |   BinaryFileMetadata, | ||||||
|   | |||||||
| @@ -10,6 +10,13 @@ | |||||||
|  *   (localStorage, indexedDB). |  *   (localStorage, indexedDB). | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
|  | import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState"; | ||||||
|  | import { | ||||||
|  |   CANVAS_SEARCH_TAB, | ||||||
|  |   DEFAULT_SIDEBAR, | ||||||
|  |   debounce, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  | import { clearElementsForLocalStorage } from "@excalidraw/element"; | ||||||
| import { | import { | ||||||
|   createStore, |   createStore, | ||||||
|   entries, |   entries, | ||||||
| @@ -19,26 +26,19 @@ import { | |||||||
|   setMany, |   setMany, | ||||||
|   get, |   get, | ||||||
| } from "idb-keyval"; | } from "idb-keyval"; | ||||||
| import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState"; |  | ||||||
| import { |  | ||||||
|   CANVAS_SEARCH_TAB, |  | ||||||
|   DEFAULT_SIDEBAR, |  | ||||||
| } from "@excalidraw/excalidraw/constants"; |  | ||||||
| import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; | import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; | ||||||
| import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; | import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; | ||||||
| import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; | import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; | ||||||
| import type { |  | ||||||
|   ExcalidrawElement, |  | ||||||
|   FileId, |  | ||||||
| } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import type { | import type { | ||||||
|   AppState, |   AppState, | ||||||
|   BinaryFileData, |   BinaryFileData, | ||||||
|   BinaryFiles, |   BinaryFiles, | ||||||
| } from "@excalidraw/excalidraw/types"; | } from "@excalidraw/excalidraw/types"; | ||||||
| import type { MaybePromise } from "@excalidraw/excalidraw/utility-types"; | import type { MaybePromise } from "@excalidraw/common/utility-types"; | ||||||
| import { debounce } from "@excalidraw/excalidraw/utils"; |  | ||||||
| import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; | import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; | ||||||
|  |  | ||||||
| import { FileManager } from "./FileManager"; | import { FileManager } from "./FileManager"; | ||||||
| import { Locker } from "./Locker"; | import { Locker } from "./Locker"; | ||||||
| import { updateBrowserStateVersion } from "./tabSync"; | import { updateBrowserStateVersion } from "./tabSync"; | ||||||
|   | |||||||
| @@ -1,27 +1,12 @@ | |||||||
| import { reconcileElements } from "@excalidraw/excalidraw"; | import { reconcileElements } from "@excalidraw/excalidraw"; | ||||||
| import type { | import { MIME_TYPES } from "@excalidraw/common"; | ||||||
|   ExcalidrawElement, |  | ||||||
|   FileId, |  | ||||||
|   OrderedExcalidrawElement, |  | ||||||
| } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import { getSceneVersion } from "@excalidraw/excalidraw/element"; |  | ||||||
| import type Portal from "../collab/Portal"; |  | ||||||
| import { restoreElements } from "@excalidraw/excalidraw/data/restore"; |  | ||||||
| import type { |  | ||||||
|   AppState, |  | ||||||
|   BinaryFileData, |  | ||||||
|   BinaryFileMetadata, |  | ||||||
|   DataURL, |  | ||||||
| } from "@excalidraw/excalidraw/types"; |  | ||||||
| import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; |  | ||||||
| import { decompressData } from "@excalidraw/excalidraw/data/encode"; | import { decompressData } from "@excalidraw/excalidraw/data/encode"; | ||||||
| import { | import { | ||||||
|   encryptData, |   encryptData, | ||||||
|   decryptData, |   decryptData, | ||||||
| } from "@excalidraw/excalidraw/data/encryption"; | } from "@excalidraw/excalidraw/data/encryption"; | ||||||
| import { MIME_TYPES } from "@excalidraw/excalidraw/constants"; | import { restoreElements } from "@excalidraw/excalidraw/data/restore"; | ||||||
| import type { SyncableExcalidrawElement } from "."; | import { getSceneVersion } from "@excalidraw/element"; | ||||||
| import { getSyncableElements } from "."; |  | ||||||
| import { initializeApp } from "firebase/app"; | import { initializeApp } from "firebase/app"; | ||||||
| import { | import { | ||||||
|   getFirestore, |   getFirestore, | ||||||
| @@ -31,8 +16,27 @@ import { | |||||||
|   Bytes, |   Bytes, | ||||||
| } from "firebase/firestore"; | } from "firebase/firestore"; | ||||||
| import { getStorage, ref, uploadBytes } from "firebase/storage"; | import { getStorage, ref, uploadBytes } from "firebase/storage"; | ||||||
| import type { Socket } from "socket.io-client"; |  | ||||||
| import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; | import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; | ||||||
|  | import type { | ||||||
|  |   ExcalidrawElement, | ||||||
|  |   FileId, | ||||||
|  |   OrderedExcalidrawElement, | ||||||
|  | } from "@excalidraw/element/types"; | ||||||
|  | import type { | ||||||
|  |   AppState, | ||||||
|  |   BinaryFileData, | ||||||
|  |   BinaryFileMetadata, | ||||||
|  |   DataURL, | ||||||
|  | } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
|  | import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; | ||||||
|  |  | ||||||
|  | import { getSyncableElements } from "."; | ||||||
|  |  | ||||||
|  | import type { SyncableExcalidrawElement } from "."; | ||||||
|  | import type Portal from "../collab/Portal"; | ||||||
|  | import type { Socket } from "socket.io-client"; | ||||||
|  |  | ||||||
| // private | // private | ||||||
| // ----------------------------------------------------------------------------- | // ----------------------------------------------------------------------------- | ||||||
|   | |||||||
| @@ -9,34 +9,38 @@ import { | |||||||
| } from "@excalidraw/excalidraw/data/encryption"; | } from "@excalidraw/excalidraw/data/encryption"; | ||||||
| import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; | import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; | ||||||
| import { restore } from "@excalidraw/excalidraw/data/restore"; | import { restore } from "@excalidraw/excalidraw/data/restore"; | ||||||
|  | import { isInvisiblySmallElement } from "@excalidraw/element"; | ||||||
|  | import { isInitializedImageElement } from "@excalidraw/element"; | ||||||
|  | import { t } from "@excalidraw/excalidraw/i18n"; | ||||||
|  | import { bytesToHexString } from "@excalidraw/common"; | ||||||
|  |  | ||||||
|  | import type { UserIdleState } from "@excalidraw/common"; | ||||||
| import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; | import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; | ||||||
| import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds"; | import type { SceneBounds } from "@excalidraw/element"; | ||||||
| import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers"; |  | ||||||
| import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; |  | ||||||
| import type { | import type { | ||||||
|   ExcalidrawElement, |   ExcalidrawElement, | ||||||
|   FileId, |   FileId, | ||||||
|   OrderedExcalidrawElement, |   OrderedExcalidrawElement, | ||||||
| } from "@excalidraw/excalidraw/element/types"; | } from "@excalidraw/element/types"; | ||||||
| import { t } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import type { | import type { | ||||||
|   AppState, |   AppState, | ||||||
|   BinaryFileData, |   BinaryFileData, | ||||||
|   BinaryFiles, |   BinaryFiles, | ||||||
|   SocketId, |   SocketId, | ||||||
| } from "@excalidraw/excalidraw/types"; | } from "@excalidraw/excalidraw/types"; | ||||||
| import type { UserIdleState } from "@excalidraw/excalidraw/constants"; | import type { MakeBrand } from "@excalidraw/common/utility-types"; | ||||||
| import type { MakeBrand } from "@excalidraw/excalidraw/utility-types"; |  | ||||||
| import { bytesToHexString } from "@excalidraw/excalidraw/utils"; |  | ||||||
| import type { WS_SUBTYPES } from "../app_constants"; |  | ||||||
| import { | import { | ||||||
|   DELETED_ELEMENT_TIMEOUT, |   DELETED_ELEMENT_TIMEOUT, | ||||||
|   FILE_UPLOAD_MAX_BYTES, |   FILE_UPLOAD_MAX_BYTES, | ||||||
|   ROOM_ID_BYTES, |   ROOM_ID_BYTES, | ||||||
| } from "../app_constants"; | } from "../app_constants"; | ||||||
|  |  | ||||||
| import { encodeFilesForUpload } from "./FileManager"; | import { encodeFilesForUpload } from "./FileManager"; | ||||||
| import { saveFilesToFirebase } from "./firebase"; | import { saveFilesToFirebase } from "./firebase"; | ||||||
|  |  | ||||||
|  | import type { WS_SUBTYPES } from "../app_constants"; | ||||||
|  |  | ||||||
| export type SyncableExcalidrawElement = OrderedExcalidrawElement & | export type SyncableExcalidrawElement = OrderedExcalidrawElement & | ||||||
|   MakeBrand<"SyncableExcalidrawElement">; |   MakeBrand<"SyncableExcalidrawElement">; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,10 +1,12 @@ | |||||||
| import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; |  | ||||||
| import type { AppState } from "@excalidraw/excalidraw/types"; |  | ||||||
| import { | import { | ||||||
|   clearAppStateForLocalStorage, |   clearAppStateForLocalStorage, | ||||||
|   getDefaultAppState, |   getDefaultAppState, | ||||||
| } from "@excalidraw/excalidraw/appState"; | } from "@excalidraw/excalidraw/appState"; | ||||||
| import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; | import { clearElementsForLocalStorage } from "@excalidraw/element"; | ||||||
|  |  | ||||||
|  | import type { ExcalidrawElement } from "@excalidraw/element/types"; | ||||||
|  | import type { AppState } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
| import { STORAGE_KEYS } from "../app_constants"; | import { STORAGE_KEYS } from "../app_constants"; | ||||||
|  |  | ||||||
| export const saveUsernameToLocalStorage = (username: string) => { | export const saveUsernameToLocalStorage = (username: string) => { | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import { StrictMode } from "react"; | import { StrictMode } from "react"; | ||||||
| import { createRoot } from "react-dom/client"; | import { createRoot } from "react-dom/client"; | ||||||
| import ExcalidrawApp from "./App"; |  | ||||||
| import { registerSW } from "virtual:pwa-register"; | import { registerSW } from "virtual:pwa-register"; | ||||||
|  |  | ||||||
| import "../excalidraw-app/sentry"; | import "../excalidraw-app/sentry"; | ||||||
|  |  | ||||||
|  | import ExcalidrawApp from "./App"; | ||||||
|  |  | ||||||
| window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA; | window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA; | ||||||
| const rootElement = document.getElementById("root")!; | const rootElement = document.getElementById("root")!; | ||||||
| const root = createRoot(rootElement); | const root = createRoot(rootElement); | ||||||
|   | |||||||
| @@ -1,10 +1,8 @@ | |||||||
| import { useEffect, useRef, useState } from "react"; |  | ||||||
| import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard"; |  | ||||||
| import { trackEvent } from "@excalidraw/excalidraw/analytics"; | import { trackEvent } from "@excalidraw/excalidraw/analytics"; | ||||||
| import { getFrame } from "@excalidraw/excalidraw/utils"; | import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard"; | ||||||
| import { useI18n } from "@excalidraw/excalidraw/i18n"; |  | ||||||
| import { KEYS } from "@excalidraw/excalidraw/keys"; |  | ||||||
| import { Dialog } from "@excalidraw/excalidraw/components/Dialog"; | import { Dialog } from "@excalidraw/excalidraw/components/Dialog"; | ||||||
|  | import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton"; | ||||||
|  | import { TextField } from "@excalidraw/excalidraw/components/TextField"; | ||||||
| import { | import { | ||||||
|   copyIcon, |   copyIcon, | ||||||
|   LinkIcon, |   LinkIcon, | ||||||
| @@ -14,16 +12,19 @@ import { | |||||||
|   shareIOS, |   shareIOS, | ||||||
|   shareWindows, |   shareWindows, | ||||||
| } from "@excalidraw/excalidraw/components/icons"; | } from "@excalidraw/excalidraw/components/icons"; | ||||||
| import { TextField } from "@excalidraw/excalidraw/components/TextField"; |  | ||||||
| import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton"; |  | ||||||
| import type { CollabAPI } from "../collab/Collab"; |  | ||||||
| import { activeRoomLinkAtom } from "../collab/Collab"; |  | ||||||
| import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState"; | import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState"; | ||||||
| import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator"; | import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator"; | ||||||
|  | import { useI18n } from "@excalidraw/excalidraw/i18n"; | ||||||
|  | import { KEYS, getFrame } from "@excalidraw/common"; | ||||||
|  | import { useEffect, useRef, useState } from "react"; | ||||||
|  |  | ||||||
| import { atom, useAtom, useAtomValue } from "../app-jotai"; | import { atom, useAtom, useAtomValue } from "../app-jotai"; | ||||||
|  | import { activeRoomLinkAtom } from "../collab/Collab"; | ||||||
|  |  | ||||||
| import "./ShareDialog.scss"; | import "./ShareDialog.scss"; | ||||||
|  |  | ||||||
|  | import type { CollabAPI } from "../collab/Collab"; | ||||||
|  |  | ||||||
| type OnExportToBackend = () => void; | type OnExportToBackend = () => void; | ||||||
| type ShareDialogType = "share" | "collaborationOnly"; | type ShareDialogType = "share" | "collaborationOnly"; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| import ExcalidrawApp from "../App"; | import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; | ||||||
| import { | import { | ||||||
|   mockBoundingClientRect, |   mockBoundingClientRect, | ||||||
|   render, |   render, | ||||||
|   restoreOriginalGetBoundingClientRect, |   restoreOriginalGetBoundingClientRect, | ||||||
| } from "@excalidraw/excalidraw/tests/test-utils"; | } from "@excalidraw/excalidraw/tests/test-utils"; | ||||||
|  |  | ||||||
| import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; | import ExcalidrawApp from "../App"; | ||||||
|  |  | ||||||
| describe("Test MobileMenu", () => { | describe("Test MobileMenu", () => { | ||||||
|   const { h } = window; |   const { h } = window; | ||||||
|   | |||||||
| @@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u | |||||||
|     <a |     <a | ||||||
|       class="welcome-screen-menu-item " |       class="welcome-screen-menu-item " | ||||||
|       href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest" |       href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest" | ||||||
|       rel="noreferrer" |       rel="noopener" | ||||||
|       target="_blank" |       target="_blank" | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|   | |||||||
| @@ -1,13 +1,18 @@ | |||||||
| import { vi } from "vitest"; | import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw"; | ||||||
| import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils"; |  | ||||||
| import ExcalidrawApp from "../App"; |  | ||||||
| import { API } from "@excalidraw/excalidraw/tests/helpers/api"; |  | ||||||
| import { syncInvalidIndices } from "@excalidraw/excalidraw/fractionalIndex"; |  | ||||||
| import { | import { | ||||||
|   createRedoAction, |   createRedoAction, | ||||||
|   createUndoAction, |   createUndoAction, | ||||||
| } from "@excalidraw/excalidraw/actions/actionHistory"; | } from "@excalidraw/excalidraw/actions/actionHistory"; | ||||||
| import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw"; | import { syncInvalidIndices } from "@excalidraw/element"; | ||||||
|  | import { API } from "@excalidraw/excalidraw/tests/helpers/api"; | ||||||
|  | import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils"; | ||||||
|  | import { vi } from "vitest"; | ||||||
|  |  | ||||||
|  | import { StoreIncrement } from "@excalidraw/element"; | ||||||
|  |  | ||||||
|  | import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element"; | ||||||
|  |  | ||||||
|  | import ExcalidrawApp from "../App"; | ||||||
|  |  | ||||||
| const { h } = window; | const { h } = window; | ||||||
|  |  | ||||||
| @@ -64,6 +69,79 @@ vi.mock("socket.io-client", () => { | |||||||
|  * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. |  * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. | ||||||
|  */ |  */ | ||||||
| describe("collaboration", () => { | describe("collaboration", () => { | ||||||
|  |   it("should emit two ephemeral increments even though updates get batched", async () => { | ||||||
|  |     const durableIncrements: DurableIncrement[] = []; | ||||||
|  |     const ephemeralIncrements: EphemeralIncrement[] = []; | ||||||
|  |  | ||||||
|  |     await render(<ExcalidrawApp />); | ||||||
|  |  | ||||||
|  |     h.store.onStoreIncrementEmitter.on((increment) => { | ||||||
|  |       if (StoreIncrement.isDurable(increment)) { | ||||||
|  |         durableIncrements.push(increment); | ||||||
|  |       } else { | ||||||
|  |         ephemeralIncrements.push(increment); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // eslint-disable-next-line dot-notation | ||||||
|  |     expect(h.store["scheduledMicroActions"].length).toBe(0); | ||||||
|  |     expect(durableIncrements.length).toBe(0); | ||||||
|  |     expect(ephemeralIncrements.length).toBe(0); | ||||||
|  |  | ||||||
|  |     const rectProps = { | ||||||
|  |       type: "rectangle", | ||||||
|  |       id: "A", | ||||||
|  |       height: 200, | ||||||
|  |       width: 100, | ||||||
|  |       x: 0, | ||||||
|  |       y: 0, | ||||||
|  |     } as const; | ||||||
|  |  | ||||||
|  |     const rect = API.createElement({ ...rectProps }); | ||||||
|  |  | ||||||
|  |     API.updateScene({ | ||||||
|  |       elements: [rect], | ||||||
|  |       captureUpdate: CaptureUpdateAction.IMMEDIATELY, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     await waitFor(() => { | ||||||
|  |       // expect(commitSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(durableIncrements.length).toBe(1); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // simulate two batched remote updates | ||||||
|  |     act(() => { | ||||||
|  |       h.app.updateScene({ | ||||||
|  |         elements: [newElementWith(h.elements[0], { x: 100 })], | ||||||
|  |         captureUpdate: CaptureUpdateAction.NEVER, | ||||||
|  |       }); | ||||||
|  |       h.app.updateScene({ | ||||||
|  |         elements: [newElementWith(h.elements[0], { x: 200 })], | ||||||
|  |         captureUpdate: CaptureUpdateAction.NEVER, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // we scheduled two micro actions, | ||||||
|  |       // which confirms they are going to be executed as part of one batched component update | ||||||
|  |       // eslint-disable-next-line dot-notation | ||||||
|  |       expect(h.store["scheduledMicroActions"].length).toBe(2); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     await waitFor(() => { | ||||||
|  |       // altough the updates get batched, | ||||||
|  |       // we expect two ephemeral increments for each update, | ||||||
|  |       // and each such update should have the expected change | ||||||
|  |       expect(ephemeralIncrements.length).toBe(2); | ||||||
|  |       expect(ephemeralIncrements[0].change.elements.A).toEqual( | ||||||
|  |         expect.objectContaining({ x: 100 }), | ||||||
|  |       ); | ||||||
|  |       expect(ephemeralIncrements[1].change.elements.A).toEqual( | ||||||
|  |         expect.objectContaining({ x: 200 }), | ||||||
|  |       ); | ||||||
|  |       // eslint-disable-next-line dot-notation | ||||||
|  |       expect(h.store["scheduledMicroActions"].length).toBe(0); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   it("should allow to undo / redo even on force-deleted elements", async () => { |   it("should allow to undo / redo even on force-deleted elements", async () => { | ||||||
|     await render(<ExcalidrawApp />); |     await render(<ExcalidrawApp />); | ||||||
|     const rect1Props = { |     const rect1Props = { | ||||||
| @@ -121,7 +199,7 @@ describe("collaboration", () => { | |||||||
|       expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); |       expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const undoAction = createUndoAction(h.history, h.store); |     const undoAction = createUndoAction(h.history); | ||||||
|     act(() => h.app.actionManager.executeAction(undoAction)); |     act(() => h.app.actionManager.executeAction(undoAction)); | ||||||
|  |  | ||||||
|     // with explicit undo (as addition) we expect our item to be restored from the snapshot! |     // with explicit undo (as addition) we expect our item to be restored from the snapshot! | ||||||
| @@ -153,7 +231,7 @@ describe("collaboration", () => { | |||||||
|       expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); |       expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const redoAction = createRedoAction(h.history, h.store); |     const redoAction = createRedoAction(h.history); | ||||||
|     act(() => h.app.actionManager.executeAction(redoAction)); |     act(() => h.app.actionManager.executeAction(redoAction)); | ||||||
|  |  | ||||||
|     // with explicit redo (as removal) we again restore the element from the snapshot! |     // with explicit redo (as removal) we again restore the element from the snapshot! | ||||||
|   | |||||||
| @@ -1,8 +1,9 @@ | |||||||
| import { useEffect, useLayoutEffect, useState } from "react"; |  | ||||||
| import { THEME } from "@excalidraw/excalidraw"; | import { THEME } from "@excalidraw/excalidraw"; | ||||||
| import { EVENT } from "@excalidraw/excalidraw/constants"; | import { EVENT, CODES, KEYS } from "@excalidraw/common"; | ||||||
| import type { Theme } from "@excalidraw/excalidraw/element/types"; | import { useEffect, useLayoutEffect, useState } from "react"; | ||||||
| import { CODES, KEYS } from "@excalidraw/excalidraw/keys"; |  | ||||||
|  | import type { Theme } from "@excalidraw/element/types"; | ||||||
|  |  | ||||||
| import { STORAGE_KEYS } from "./app_constants"; | import { STORAGE_KEYS } from "./app_constants"; | ||||||
|  |  | ||||||
| const getDarkThemeMediaQuery = (): MediaQueryList | undefined => | const getDarkThemeMediaQuery = (): MediaQueryList | undefined => | ||||||
|   | |||||||
| @@ -23,29 +23,57 @@ export default defineConfig(({ mode }) => { | |||||||
|     envDir: "../", |     envDir: "../", | ||||||
|     resolve: { |     resolve: { | ||||||
|       alias: [ |       alias: [ | ||||||
|  |         { | ||||||
|  |           find: /^@excalidraw\/common$/, | ||||||
|  |           replacement: path.resolve( | ||||||
|  |             __dirname, | ||||||
|  |             "../packages/common/src/index.ts", | ||||||
|  |           ), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           find: /^@excalidraw\/common\/(.*?)/, | ||||||
|  |           replacement: path.resolve(__dirname, "../packages/common/src/$1"), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           find: /^@excalidraw\/element$/, | ||||||
|  |           replacement: path.resolve( | ||||||
|  |             __dirname, | ||||||
|  |             "../packages/element/src/index.ts", | ||||||
|  |           ), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           find: /^@excalidraw\/element\/(.*?)/, | ||||||
|  |           replacement: path.resolve(__dirname, "../packages/element/src/$1"), | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|           find: /^@excalidraw\/excalidraw$/, |           find: /^@excalidraw\/excalidraw$/, | ||||||
|           replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"), |           replacement: path.resolve( | ||||||
|  |             __dirname, | ||||||
|  |             "../packages/excalidraw/index.tsx", | ||||||
|  |           ), | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           find: /^@excalidraw\/excalidraw\/(.*?)/, |           find: /^@excalidraw\/excalidraw\/(.*?)/, | ||||||
|           replacement: path.resolve(__dirname, "../packages/excalidraw/$1"), |           replacement: path.resolve(__dirname, "../packages/excalidraw/$1"), | ||||||
|         }, |         }, | ||||||
|         { |  | ||||||
|           find: /^@excalidraw\/utils$/, |  | ||||||
|           replacement: path.resolve(__dirname, "../packages/utils/index.ts"), |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           find: /^@excalidraw\/utils\/(.*?)/, |  | ||||||
|           replacement: path.resolve(__dirname, "../packages/utils/$1"), |  | ||||||
|         }, |  | ||||||
|         { |         { | ||||||
|           find: /^@excalidraw\/math$/, |           find: /^@excalidraw\/math$/, | ||||||
|           replacement: path.resolve(__dirname, "../packages/math/index.ts"), |           replacement: path.resolve(__dirname, "../packages/math/src/index.ts"), | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           find: /^@excalidraw\/math\/(.*?)/, |           find: /^@excalidraw\/math\/(.*?)/, | ||||||
|           replacement: path.resolve(__dirname, "../packages/math/$1"), |           replacement: path.resolve(__dirname, "../packages/math/src/$1"), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           find: /^@excalidraw\/utils$/, | ||||||
|  |           replacement: path.resolve( | ||||||
|  |             __dirname, | ||||||
|  |             "../packages/utils/src/index.ts", | ||||||
|  |           ), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           find: /^@excalidraw\/utils\/(.*?)/, | ||||||
|  |           replacement: path.resolve(__dirname, "../packages/utils/src/$1"), | ||||||
|         }, |         }, | ||||||
|       ], |       ], | ||||||
|     }, |     }, | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							| @@ -4,9 +4,7 @@ | |||||||
|   "packageManager": "yarn@1.22.22", |   "packageManager": "yarn@1.22.22", | ||||||
|   "workspaces": [ |   "workspaces": [ | ||||||
|     "excalidraw-app", |     "excalidraw-app", | ||||||
|     "packages/excalidraw", |     "packages/*", | ||||||
|     "packages/utils", |  | ||||||
|     "packages/math", |  | ||||||
|     "examples/*" |     "examples/*" | ||||||
|   ], |   ], | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
| @@ -26,6 +24,7 @@ | |||||||
|     "dotenv": "16.0.1", |     "dotenv": "16.0.1", | ||||||
|     "eslint-config-prettier": "8.5.0", |     "eslint-config-prettier": "8.5.0", | ||||||
|     "eslint-config-react-app": "7.0.1", |     "eslint-config-react-app": "7.0.1", | ||||||
|  |     "eslint-plugin-import": "2.31.0", | ||||||
|     "eslint-plugin-prettier": "3.3.1", |     "eslint-plugin-prettier": "3.3.1", | ||||||
|     "http-server": "14.1.1", |     "http-server": "14.1.1", | ||||||
|     "husky": "7.0.4", |     "husky": "7.0.4", | ||||||
| @@ -34,6 +33,7 @@ | |||||||
|     "pepjs": "0.5.3", |     "pepjs": "0.5.3", | ||||||
|     "prettier": "2.6.2", |     "prettier": "2.6.2", | ||||||
|     "rewire": "6.0.0", |     "rewire": "6.0.0", | ||||||
|  |     "rimraf": "^5.0.0", | ||||||
|     "typescript": "4.9.4", |     "typescript": "4.9.4", | ||||||
|     "vite": "5.0.12", |     "vite": "5.0.12", | ||||||
|     "vite-plugin-checker": "0.7.2", |     "vite-plugin-checker": "0.7.2", | ||||||
| @@ -79,8 +79,8 @@ | |||||||
|     "autorelease": "node scripts/autorelease.js", |     "autorelease": "node scripts/autorelease.js", | ||||||
|     "prerelease:excalidraw": "node scripts/prerelease.js", |     "prerelease:excalidraw": "node scripts/prerelease.js", | ||||||
|     "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:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist", | ||||||
|     "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules", |     "rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules", | ||||||
|     "clean-install": "yarn rm:node_modules && yarn install" |     "clean-install": "yarn rm:node_modules && yarn install" | ||||||
|   }, |   }, | ||||||
|   "resolutions": { |   "resolutions": { | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								packages/common/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/common/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | { | ||||||
|  |   "extends": ["../eslintrc.base.json"] | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								packages/common/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/common/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | # @excalidraw/common | ||||||
|  |  | ||||||
|  | ## Install | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | npm install @excalidraw/common | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | If you prefer Yarn over npm, use this command to install the Excalidraw utils package: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | yarn add @excalidraw/common | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | With PNPM, similarly install the package with this command: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | pnpm add @excalidraw/common | ||||||
|  | ``` | ||||||
							
								
								
									
										3
									
								
								packages/common/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/common/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | /// <reference types="vite/client" /> | ||||||
|  | import "@excalidraw/excalidraw/global"; | ||||||
|  | import "@excalidraw/excalidraw/css"; | ||||||
							
								
								
									
										56
									
								
								packages/common/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								packages/common/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | { | ||||||
|  |   "name": "@excalidraw/common", | ||||||
|  |   "version": "0.1.0", | ||||||
|  |   "type": "module", | ||||||
|  |   "types": "./dist/types/common/src/index.d.ts", | ||||||
|  |   "main": "./dist/prod/index.js", | ||||||
|  |   "module": "./dist/prod/index.js", | ||||||
|  |   "exports": { | ||||||
|  |     ".": { | ||||||
|  |       "types": "./dist/types/common/src/index.d.ts", | ||||||
|  |       "development": "./dist/dev/index.js", | ||||||
|  |       "production": "./dist/prod/index.js", | ||||||
|  |       "default": "./dist/prod/index.js" | ||||||
|  |     }, | ||||||
|  |     "./*": { | ||||||
|  |       "types": "./dist/types/common/src/*.d.ts" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "files": [ | ||||||
|  |     "dist/*" | ||||||
|  |   ], | ||||||
|  |   "description": "Excalidraw common functions, constants, etc.", | ||||||
|  |   "publishConfig": { | ||||||
|  |     "access": "public" | ||||||
|  |   }, | ||||||
|  |   "license": "MIT", | ||||||
|  |   "keywords": [ | ||||||
|  |     "excalidraw", | ||||||
|  |     "excalidraw-utils" | ||||||
|  |   ], | ||||||
|  |   "browserslist": { | ||||||
|  |     "production": [ | ||||||
|  |       ">0.2%", | ||||||
|  |       "not dead", | ||||||
|  |       "not ie <= 11", | ||||||
|  |       "not op_mini all", | ||||||
|  |       "not safari < 12", | ||||||
|  |       "not kaios <= 2.5", | ||||||
|  |       "not edge < 79", | ||||||
|  |       "not chrome < 70", | ||||||
|  |       "not and_uc < 13", | ||||||
|  |       "not samsung < 10" | ||||||
|  |     ], | ||||||
|  |     "development": [ | ||||||
|  |       "last 1 chrome version", | ||||||
|  |       "last 1 firefox version", | ||||||
|  |       "last 1 safari version" | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "bugs": "https://github.com/excalidraw/excalidraw/issues", | ||||||
|  |   "repository": "https://github.com/excalidraw/excalidraw", | ||||||
|  |   "scripts": { | ||||||
|  |     "gen:types": "rimraf types && tsc", | ||||||
|  |     "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| export default class BinaryHeap<T> { | export class BinaryHeap<T> { | ||||||
|   private content: T[] = []; |   private content: T[] = []; | ||||||
| 
 | 
 | ||||||
|   constructor(private scoreFunction: (node: T) => number) {} |   constructor(private scoreFunction: (node: T) => number) {} | ||||||
| @@ -1,6 +1,9 @@ | |||||||
| import oc from "open-color"; | import oc from "open-color"; | ||||||
|  | 
 | ||||||
| import type { Merge } from "./utility-types"; | import type { Merge } from "./utility-types"; | ||||||
| 
 | 
 | ||||||
|  | export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240; | ||||||
|  | 
 | ||||||
| // FIXME can't put to utils.ts rn because of circular dependency
 | // FIXME can't put to utils.ts rn because of circular dependency
 | ||||||
| const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( | const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( | ||||||
|   source: R, |   source: R, | ||||||
| @@ -1,11 +1,16 @@ | |||||||
| import type { AppProps, AppState } from "./types"; | import type { | ||||||
| import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; |   ExcalidrawElement, | ||||||
|  |   FontFamilyValues, | ||||||
|  | } from "@excalidraw/element/types"; | ||||||
|  | import type { AppProps, AppState } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
| import { COLOR_PALETTE } from "./colors"; | import { COLOR_PALETTE } from "./colors"; | ||||||
| 
 | 
 | ||||||
| export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); | export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); | ||||||
| export const isWindows = /^Win/.test(navigator.platform); | export const isWindows = /^Win/.test(navigator.platform); | ||||||
| export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); | export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); | ||||||
| export const isFirefox = | export const isFirefox = | ||||||
|  |   typeof window !== "undefined" && | ||||||
|   "netscape" in window && |   "netscape" in window && | ||||||
|   navigator.userAgent.indexOf("rv:") > 1 && |   navigator.userAgent.indexOf("rv:") > 1 && | ||||||
|   navigator.userAgent.indexOf("Gecko") > 1; |   navigator.userAgent.indexOf("Gecko") > 1; | ||||||
| @@ -108,12 +113,14 @@ export const YOUTUBE_STATES = { | |||||||
| export const ENV = { | export const ENV = { | ||||||
|   TEST: "test", |   TEST: "test", | ||||||
|   DEVELOPMENT: "development", |   DEVELOPMENT: "development", | ||||||
|  |   PRODUCTION: "production", | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const CLASSES = { | export const CLASSES = { | ||||||
|   SHAPE_ACTIONS_MENU: "App-menu__left", |   SHAPE_ACTIONS_MENU: "App-menu__left", | ||||||
|   ZOOM_ACTIONS: "zoom-actions", |   ZOOM_ACTIONS: "zoom-actions", | ||||||
|   SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", |   SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", | ||||||
|  |   CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; | export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; | ||||||
| @@ -137,6 +144,7 @@ export const FONT_FAMILY = { | |||||||
|   "Lilita One": 7, |   "Lilita One": 7, | ||||||
|   "Comic Shanns": 8, |   "Comic Shanns": 8, | ||||||
|   "Liberation Sans": 9, |   "Liberation Sans": 9, | ||||||
|  |   Assistant: 10, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const FONT_FAMILY_FALLBACKS = { | export const FONT_FAMILY_FALLBACKS = { | ||||||
| @@ -248,7 +256,7 @@ export const EXPORT_DATA_TYPES = { | |||||||
|   excalidrawClipboardWithAPI: "excalidraw-api/clipboard", |   excalidrawClipboardWithAPI: "excalidraw-api/clipboard", | ||||||
| } as const; | } as const; | ||||||
| 
 | 
 | ||||||
| export const EXPORT_SOURCE = | export const getExportSource = () => | ||||||
|   window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin; |   window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin; | ||||||
| 
 | 
 | ||||||
| // time in milliseconds
 | // time in milliseconds
 | ||||||
| @@ -314,6 +322,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; | |||||||
| export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; | export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; | ||||||
| 
 | 
 | ||||||
| export const SVG_NS = "http://www.w3.org/2000/svg"; | export const SVG_NS = "http://www.w3.org/2000/svg"; | ||||||
|  | export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
 | ||||||
|  | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||||||
|  | `;
 | ||||||
| 
 | 
 | ||||||
| export const ENCRYPTION_KEY_BITS = 128; | export const ENCRYPTION_KEY_BITS = 128; | ||||||
| 
 | 
 | ||||||
| @@ -415,6 +426,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([ | |||||||
| // use these constants to easily identify reference sites
 | // use these constants to easily identify reference sites
 | ||||||
| export const TOOL_TYPE = { | export const TOOL_TYPE = { | ||||||
|   selection: "selection", |   selection: "selection", | ||||||
|  |   lasso: "lasso", | ||||||
|   rectangle: "rectangle", |   rectangle: "rectangle", | ||||||
|   diamond: "diamond", |   diamond: "diamond", | ||||||
|   ellipse: "ellipse", |   ellipse: "ellipse", | ||||||
| @@ -465,3 +477,10 @@ export enum UserIdleState { | |||||||
|   AWAY = "away", |   AWAY = "away", | ||||||
|   IDLE = "idle", |   IDLE = "idle", | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * distance at which we merge points instead of adding a new merge-point | ||||||
|  |  * when converting a line to a polygon (merge currently means overlaping | ||||||
|  |  * the start and end points) | ||||||
|  |  */ | ||||||
|  | export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import type { UnsubscribeCallback } from "./types"; | import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types"; | ||||||
| 
 | 
 | ||||||
| type Subscriber<T extends any[]> = (...payload: T) => void; | type Subscriber<T extends any[]> = (...payload: T) => void; | ||||||
| 
 | 
 | ||||||
| @@ -1,11 +1,9 @@ | |||||||
| import type { JSX } from "react"; | import type { | ||||||
| import { |   ExcalidrawTextElement, | ||||||
|   FreedrawIcon, |   FontFamilyValues, | ||||||
|   FontFamilyNormalIcon, | } from "@excalidraw/element/types"; | ||||||
|   FontFamilyHeadingIcon, | 
 | ||||||
|   FontFamilyCodeIcon, | import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "./constants"; | ||||||
| } from "../components/icons"; |  | ||||||
| import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "../constants"; |  | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Encapsulates font metrics with additional font metadata. |  * Encapsulates font metrics with additional font metadata. | ||||||
| @@ -22,12 +20,12 @@ export interface FontMetadata { | |||||||
|     /** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */ |     /** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */ | ||||||
|     lineHeight: number; |     lineHeight: number; | ||||||
|   }; |   }; | ||||||
|   /** element to be displayed as an icon  */ |  | ||||||
|   icon?: JSX.Element; |  | ||||||
|   /** flag to indicate a deprecated font */ |   /** flag to indicate a deprecated font */ | ||||||
|   deprecated?: true; |   deprecated?: true; | ||||||
|   /** flag to indicate a server-side only font */ |   /** | ||||||
|   serverSide?: true; |    * whether this is a font that users can use (= shown in font picker) | ||||||
|  |    */ | ||||||
|  |   private?: true; | ||||||
|   /** flag to indiccate a local-only font */ |   /** flag to indiccate a local-only font */ | ||||||
|   local?: true; |   local?: true; | ||||||
|   /** flag to indicate a fallback font */ |   /** flag to indicate a fallback font */ | ||||||
| @@ -42,16 +40,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -374, |       descender: -374, | ||||||
|       lineHeight: 1.25, |       lineHeight: 1.25, | ||||||
|     }, |     }, | ||||||
|     icon: FreedrawIcon, |  | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY.Nunito]: { |   [FONT_FAMILY.Nunito]: { | ||||||
|     metrics: { |     metrics: { | ||||||
|       unitsPerEm: 1000, |       unitsPerEm: 1000, | ||||||
|       ascender: 1011, |       ascender: 1011, | ||||||
|       descender: -353, |       descender: -353, | ||||||
|       lineHeight: 1.35, |       lineHeight: 1.25, | ||||||
|     }, |     }, | ||||||
|     icon: FontFamilyNormalIcon, |  | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY["Lilita One"]]: { |   [FONT_FAMILY["Lilita One"]]: { | ||||||
|     metrics: { |     metrics: { | ||||||
| @@ -60,7 +56,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -220, |       descender: -220, | ||||||
|       lineHeight: 1.15, |       lineHeight: 1.15, | ||||||
|     }, |     }, | ||||||
|     icon: FontFamilyHeadingIcon, |  | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY["Comic Shanns"]]: { |   [FONT_FAMILY["Comic Shanns"]]: { | ||||||
|     metrics: { |     metrics: { | ||||||
| @@ -69,7 +64,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -250, |       descender: -250, | ||||||
|       lineHeight: 1.25, |       lineHeight: 1.25, | ||||||
|     }, |     }, | ||||||
|     icon: FontFamilyCodeIcon, |  | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY.Virgil]: { |   [FONT_FAMILY.Virgil]: { | ||||||
|     metrics: { |     metrics: { | ||||||
| @@ -78,7 +72,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -374, |       descender: -374, | ||||||
|       lineHeight: 1.25, |       lineHeight: 1.25, | ||||||
|     }, |     }, | ||||||
|     icon: FreedrawIcon, |  | ||||||
|     deprecated: true, |     deprecated: true, | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY.Helvetica]: { |   [FONT_FAMILY.Helvetica]: { | ||||||
| @@ -88,7 +81,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -471, |       descender: -471, | ||||||
|       lineHeight: 1.15, |       lineHeight: 1.15, | ||||||
|     }, |     }, | ||||||
|     icon: FontFamilyNormalIcon, |  | ||||||
|     deprecated: true, |     deprecated: true, | ||||||
|     local: true, |     local: true, | ||||||
|   }, |   }, | ||||||
| @@ -99,7 +91,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -480, |       descender: -480, | ||||||
|       lineHeight: 1.2, |       lineHeight: 1.2, | ||||||
|     }, |     }, | ||||||
|     icon: FontFamilyCodeIcon, |  | ||||||
|     deprecated: true, |     deprecated: true, | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY["Liberation Sans"]]: { |   [FONT_FAMILY["Liberation Sans"]]: { | ||||||
| @@ -109,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = { | |||||||
|       descender: -434, |       descender: -434, | ||||||
|       lineHeight: 1.15, |       lineHeight: 1.15, | ||||||
|     }, |     }, | ||||||
|     serverSide: true, |     private: true, | ||||||
|  |   }, | ||||||
|  |   [FONT_FAMILY.Assistant]: { | ||||||
|  |     metrics: { | ||||||
|  |       unitsPerEm: 2048, | ||||||
|  |       ascender: 1021, | ||||||
|  |       descender: -287, | ||||||
|  |       lineHeight: 1.25, | ||||||
|  |     }, | ||||||
|  |     private: true, | ||||||
|   }, |   }, | ||||||
|   [FONT_FAMILY_FALLBACKS.Xiaolai]: { |   [FONT_FAMILY_FALLBACKS.Xiaolai]: { | ||||||
|     metrics: { |     metrics: { | ||||||
|       unitsPerEm: 1000, |       unitsPerEm: 1000, | ||||||
|       ascender: 880, |       ascender: 880, | ||||||
|       descender: -144, |       descender: -144, | ||||||
|       lineHeight: 1.15, |       lineHeight: 1.25, | ||||||
|     }, |     }, | ||||||
|     fallback: true, |     fallback: true, | ||||||
|   }, |   }, | ||||||
| @@ -148,3 +148,34 @@ export const GOOGLE_FONTS_RANGES = { | |||||||
| 
 | 
 | ||||||
| /** local protocol to skip the local font from registering or inlining */ | /** local protocol to skip the local font from registering or inlining */ | ||||||
| export const LOCAL_FONT_PROTOCOL = "local:"; | export const LOCAL_FONT_PROTOCOL = "local:"; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Calculates vertical offset for a text with alphabetic baseline. | ||||||
|  |  */ | ||||||
|  | export const getVerticalOffset = ( | ||||||
|  |   fontFamily: ExcalidrawTextElement["fontFamily"], | ||||||
|  |   fontSize: ExcalidrawTextElement["fontSize"], | ||||||
|  |   lineHeightPx: number, | ||||||
|  | ) => { | ||||||
|  |   const { unitsPerEm, ascender, descender } = | ||||||
|  |     FONT_METADATA[fontFamily]?.metrics || | ||||||
|  |     FONT_METADATA[FONT_FAMILY.Excalifont].metrics; | ||||||
|  | 
 | ||||||
|  |   const fontSizeEm = fontSize / unitsPerEm; | ||||||
|  |   const lineGap = | ||||||
|  |     (lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2; | ||||||
|  | 
 | ||||||
|  |   const verticalOffset = fontSizeEm * ascender + lineGap; | ||||||
|  |   return verticalOffset; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Gets line height for a selected family. | ||||||
|  |  */ | ||||||
|  | export const getLineHeight = (fontFamily: FontFamilyValues) => { | ||||||
|  |   const { lineHeight } = | ||||||
|  |     FONT_METADATA[fontFamily]?.metrics || | ||||||
|  |     FONT_METADATA[FONT_FAMILY.Excalifont].metrics; | ||||||
|  | 
 | ||||||
|  |   return lineHeight as ExcalidrawTextElement["lineHeight"]; | ||||||
|  | }; | ||||||
							
								
								
									
										12
									
								
								packages/common/src/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								packages/common/src/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | export * from "./binary-heap"; | ||||||
|  | export * from "./colors"; | ||||||
|  | export * from "./constants"; | ||||||
|  | export * from "./font-metadata"; | ||||||
|  | export * from "./queue"; | ||||||
|  | export * from "./keys"; | ||||||
|  | export * from "./points"; | ||||||
|  | export * from "./promise-pool"; | ||||||
|  | export * from "./random"; | ||||||
|  | export * from "./url"; | ||||||
|  | export * from "./utils"; | ||||||
|  | export * from "./emitter"; | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { isDarwin } from "./constants"; | import { isDarwin } from "./constants"; | ||||||
|  | 
 | ||||||
| import type { ValueOf } from "./utility-types"; | import type { ValueOf } from "./utility-types"; | ||||||
| 
 | 
 | ||||||
| export const CODES = { | export const CODES = { | ||||||
| @@ -4,6 +4,8 @@ import { | |||||||
|   type LocalPoint, |   type LocalPoint, | ||||||
| } from "@excalidraw/math"; | } from "@excalidraw/math"; | ||||||
| 
 | 
 | ||||||
|  | import type { NullableGridSize } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
| export const getSizeFromPoints = ( | export const getSizeFromPoints = ( | ||||||
|   points: readonly (GlobalPoint | LocalPoint)[], |   points: readonly (GlobalPoint | LocalPoint)[], | ||||||
| ) => { | ) => { | ||||||
| @@ -61,3 +63,18 @@ export const rescalePoints = <Point extends GlobalPoint | LocalPoint>( | |||||||
| 
 | 
 | ||||||
|   return nextPoints; |   return nextPoints; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | // TODO: Rounding this point causes some shake when free drawing
 | ||||||
|  | export const getGridPoint = ( | ||||||
|  |   x: number, | ||||||
|  |   y: number, | ||||||
|  |   gridSize: NullableGridSize, | ||||||
|  | ): [number, number] => { | ||||||
|  |   if (gridSize) { | ||||||
|  |     return [ | ||||||
|  |       Math.round(x / gridSize) * gridSize, | ||||||
|  |       Math.round(y / gridSize) * gridSize, | ||||||
|  |     ]; | ||||||
|  |   } | ||||||
|  |   return [x, y]; | ||||||
|  | }; | ||||||
							
								
								
									
										50
									
								
								packages/common/src/promise-pool.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								packages/common/src/promise-pool.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | import Pool from "es6-promise-pool"; | ||||||
|  |  | ||||||
|  | // extending the missing types | ||||||
|  | // relying on the [Index, T] to keep a correct order | ||||||
|  | type TPromisePool<T, Index = number> = Pool<[Index, T][]> & { | ||||||
|  |   addEventListener: ( | ||||||
|  |     type: "fulfilled", | ||||||
|  |     listener: (event: { data: { result: [Index, T] } }) => void, | ||||||
|  |   ) => (event: { data: { result: [Index, T] } }) => void; | ||||||
|  |   removeEventListener: ( | ||||||
|  |     type: "fulfilled", | ||||||
|  |     listener: (event: { data: { result: [Index, T] } }) => void, | ||||||
|  |   ) => void; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export class PromisePool<T> { | ||||||
|  |   private readonly pool: TPromisePool<T>; | ||||||
|  |   private readonly entries: Record<number, T> = {}; | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     source: IterableIterator<Promise<void | readonly [number, T]>>, | ||||||
|  |     concurrency: number, | ||||||
|  |   ) { | ||||||
|  |     this.pool = new Pool( | ||||||
|  |       source as unknown as () => void | PromiseLike<[number, T][]>, | ||||||
|  |       concurrency, | ||||||
|  |     ) as TPromisePool<T>; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public all() { | ||||||
|  |     const listener = (event: { data: { result: void | [number, T] } }) => { | ||||||
|  |       if (event.data.result) { | ||||||
|  |         // by default pool does not return the results, so we are gathering them manually | ||||||
|  |         // with the correct call order (represented by the index in the tuple) | ||||||
|  |         const [index, value] = event.data.result; | ||||||
|  |         this.entries[index] = value; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     this.pool.addEventListener("fulfilled", listener); | ||||||
|  |  | ||||||
|  |     return this.pool.start().then(() => { | ||||||
|  |       setTimeout(() => { | ||||||
|  |         this.pool.removeEventListener("fulfilled", listener); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       return Object.values(this.entries); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,6 +1,8 @@ | |||||||
|  | import { promiseTry, resolvablePromise } from "."; | ||||||
|  | 
 | ||||||
|  | import type { ResolvablePromise } from "."; | ||||||
|  | 
 | ||||||
| import type { MaybePromise } from "./utility-types"; | import type { MaybePromise } from "./utility-types"; | ||||||
| import type { ResolvablePromise } from "./utils"; |  | ||||||
| import { promiseTry, resolvablePromise } from "./utils"; |  | ||||||
| 
 | 
 | ||||||
| type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>; | type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>; | ||||||
| 
 | 
 | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { Random } from "roughjs/bin/math"; |  | ||||||
| import { nanoid } from "nanoid"; | import { nanoid } from "nanoid"; | ||||||
|  | import { Random } from "roughjs/bin/math"; | ||||||
|  | 
 | ||||||
| import { isTestEnv } from "./utils"; | import { isTestEnv } from "./utils"; | ||||||
| 
 | 
 | ||||||
| let random = new Random(Date.now()); | let random = new Random(Date.now()); | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { sanitizeUrl } from "@braintree/sanitize-url"; | import { sanitizeUrl } from "@braintree/sanitize-url"; | ||||||
| import { escapeDoubleQuotes } from "../utils"; | 
 | ||||||
|  | import { escapeDoubleQuotes } from "./utils"; | ||||||
| 
 | 
 | ||||||
| export const normalizeLink = (link: string) => { | export const normalizeLink = (link: string) => { | ||||||
|   link = link.trim(); |   link = link.trim(); | ||||||
| @@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>; | |||||||
| 
 | 
 | ||||||
| // get union of all keys from the union of types
 | // get union of all keys from the union of types
 | ||||||
| export type AllPossibleKeys<T> = T extends any ? keyof T : never; | export type AllPossibleKeys<T> = T extends any ? keyof T : never; | ||||||
|  | 
 | ||||||
|  | /** Strip all the methods or functions from a type */ | ||||||
|  | export type DTO<T> = { | ||||||
|  |   [K in keyof T as T[K] extends Function ? never : K]: T[K]; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V> | ||||||
|  |   ? [K, V] | ||||||
|  |   : never; | ||||||
							
								
								
									
										82
									
								
								packages/common/src/utils.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								packages/common/src/utils.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | import { | ||||||
|  |   isTransparent, | ||||||
|  |   mapFind, | ||||||
|  |   reduceToCommonValue, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  |  | ||||||
|  | describe("@excalidraw/common/utils", () => { | ||||||
|  |   describe("isTransparent()", () => { | ||||||
|  |     it("should return true when color is rgb transparent", () => { | ||||||
|  |       expect(isTransparent("#ff00")).toEqual(true); | ||||||
|  |       expect(isTransparent("#fff00000")).toEqual(true); | ||||||
|  |       expect(isTransparent("transparent")).toEqual(true); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("should return false when color is not transparent", () => { | ||||||
|  |       expect(isTransparent("#ced4da")).toEqual(false); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe("reduceToCommonValue()", () => { | ||||||
|  |     it("should return the common value when all values are the same", () => { | ||||||
|  |       expect(reduceToCommonValue([1, 1])).toEqual(1); | ||||||
|  |       expect(reduceToCommonValue([0, 0])).toEqual(0); | ||||||
|  |       expect(reduceToCommonValue(["a", "a"])).toEqual("a"); | ||||||
|  |       expect(reduceToCommonValue(new Set([1]))).toEqual(1); | ||||||
|  |       expect(reduceToCommonValue([""])).toEqual(""); | ||||||
|  |       expect(reduceToCommonValue([0])).toEqual(0); | ||||||
|  |  | ||||||
|  |       const o = {}; | ||||||
|  |       expect(reduceToCommonValue([o, o])).toEqual(o); | ||||||
|  |  | ||||||
|  |       expect( | ||||||
|  |         reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a), | ||||||
|  |       ).toEqual(1); | ||||||
|  |       expect( | ||||||
|  |         reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a), | ||||||
|  |       ).toEqual(1); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("should return `null` when values are different", () => { | ||||||
|  |       expect(reduceToCommonValue([1, 2, 3])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual( | ||||||
|  |         null, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("should return `null` when some values are nullable", () => { | ||||||
|  |       expect(reduceToCommonValue([1, null, 1])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([null, 1])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([1, undefined])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([undefined, 1])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([null])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([undefined])).toEqual(null); | ||||||
|  |       expect(reduceToCommonValue([])).toEqual(null); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe("mapFind()", () => { | ||||||
|  |     it("should return the first mapped non-null element", () => { | ||||||
|  |       { | ||||||
|  |         let counter = 0; | ||||||
|  |  | ||||||
|  |         const result = mapFind(["a", "b", "c"], (value) => { | ||||||
|  |           counter++; | ||||||
|  |           return value === "b" ? 42 : null; | ||||||
|  |         }); | ||||||
|  |         expect(result).toEqual(42); | ||||||
|  |         expect(counter).toBe(2); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       expect(mapFind([1, 2], (value) => value * 0)).toBe(0); | ||||||
|  |       expect(mapFind([1, 2], () => false)).toBe(false); | ||||||
|  |       expect(mapFind([1, 2], () => "")).toBe(""); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("should return undefined if no mapped element is found", () => { | ||||||
|  |       expect(mapFind([1, 2], () => undefined)).toBe(undefined); | ||||||
|  |       expect(mapFind([1, 2], () => null)).toBe(undefined); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -1,28 +1,34 @@ | |||||||
| import Pool from "es6-promise-pool"; | import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; | ||||||
| import { average } from "@excalidraw/math"; | 
 | ||||||
| import { COLOR_PALETTE } from "./colors"; |  | ||||||
| import type { EVENT } from "./constants"; |  | ||||||
| import { |  | ||||||
|   DEFAULT_VERSION, |  | ||||||
|   FONT_FAMILY, |  | ||||||
|   getFontFamilyFallbacks, |  | ||||||
|   isDarwin, |  | ||||||
|   WINDOWS_EMOJI_FALLBACK_FONT, |  | ||||||
| } from "./constants"; |  | ||||||
| import type { | import type { | ||||||
|   ExcalidrawBindableElement, |   ExcalidrawBindableElement, | ||||||
|   FontFamilyValues, |   FontFamilyValues, | ||||||
|   FontString, |   FontString, | ||||||
| } from "./element/types"; |   ExcalidrawElement, | ||||||
|  | } from "@excalidraw/element/types"; | ||||||
|  | 
 | ||||||
| import type { | import type { | ||||||
|   ActiveTool, |   ActiveTool, | ||||||
|   AppState, |   AppState, | ||||||
|   ToolType, |   ToolType, | ||||||
|   UnsubscribeCallback, |   UnsubscribeCallback, | ||||||
|   Zoom, |   Zoom, | ||||||
| } from "./types"; | } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
|  | import { COLOR_PALETTE } from "./colors"; | ||||||
|  | import { | ||||||
|  |   DEFAULT_VERSION, | ||||||
|  |   ENV, | ||||||
|  |   FONT_FAMILY, | ||||||
|  |   getFontFamilyFallbacks, | ||||||
|  |   isDarwin, | ||||||
|  |   WINDOWS_EMOJI_FALLBACK_FONT, | ||||||
|  | } from "./constants"; | ||||||
|  | 
 | ||||||
| import type { MaybePromise, ResolutionType } from "./utility-types"; | import type { MaybePromise, ResolutionType } from "./utility-types"; | ||||||
| 
 | 
 | ||||||
|  | import type { EVENT } from "./constants"; | ||||||
|  | 
 | ||||||
| let mockDateTime: string | null = null; | let mockDateTime: string | null = null; | ||||||
| 
 | 
 | ||||||
| export const setDateTimeForTests = (dateTime: string) => { | export const setDateTimeForTests = (dateTime: string) => { | ||||||
| @@ -167,7 +173,7 @@ export const throttleRAF = <T extends any[]>( | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const ret = (...args: T) => { |   const ret = (...args: T) => { | ||||||
|     if (import.meta.env.MODE === "test") { |     if (isTestEnv()) { | ||||||
|       fn(...args); |       fn(...args); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| @@ -380,7 +386,7 @@ export const updateActiveTool = ( | |||||||
|         type: ToolType; |         type: ToolType; | ||||||
|       } |       } | ||||||
|     | { type: "custom"; customType: string } |     | { type: "custom"; customType: string } | ||||||
|   ) & { locked?: boolean }) & { |   ) & { locked?: boolean; fromSelection?: boolean }) & { | ||||||
|     lastActiveToolBeforeEraser?: ActiveTool | null; |     lastActiveToolBeforeEraser?: ActiveTool | null; | ||||||
|   }, |   }, | ||||||
| ): AppState["activeTool"] => { | ): AppState["activeTool"] => { | ||||||
| @@ -402,6 +408,7 @@ export const updateActiveTool = ( | |||||||
|     type: data.type, |     type: data.type, | ||||||
|     customType: null, |     customType: null, | ||||||
|     locked: data.locked ?? appState.activeTool.locked, |     locked: data.locked ?? appState.activeTool.locked, | ||||||
|  |     fromSelection: data.fromSelection ?? false, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| @@ -537,6 +544,20 @@ export const findLastIndex = <T>( | |||||||
|   return -1; |   return -1; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | /** returns the first non-null mapped value */ | ||||||
|  | export const mapFind = <T, K>( | ||||||
|  |   collection: readonly T[], | ||||||
|  |   iteratee: (value: T, index: number) => K | undefined | null, | ||||||
|  | ): K | undefined => { | ||||||
|  |   for (let idx = 0; idx < collection.length; idx++) { | ||||||
|  |     const result = iteratee(collection[idx], idx); | ||||||
|  |     if (result != null) { | ||||||
|  |       return result; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return undefined; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export const isTransparent = (color: string) => { | export const isTransparent = (color: string) => { | ||||||
|   const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; |   const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; | ||||||
|   const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; |   const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; | ||||||
| @@ -673,7 +694,7 @@ export const arrayToMap = <T extends { id: string } | string>( | |||||||
|   return items.reduce((acc: Map<string, T>, element) => { |   return items.reduce((acc: Map<string, T>, element) => { | ||||||
|     acc.set(typeof element === "string" ? element : element.id, element); |     acc.set(typeof element === "string" ? element : element.id, element); | ||||||
|     return acc; |     return acc; | ||||||
|   }, new Map()); |   }, new Map() as Map<string, T>); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const arrayToMapWithIndex = <T extends { id: string }>( | export const arrayToMapWithIndex = <T extends { id: string }>( | ||||||
| @@ -728,9 +749,30 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] => | |||||||
|     return acc; |     return acc; | ||||||
|   }, [] as Node<T>[]); |   }, [] as Node<T>[]); | ||||||
| 
 | 
 | ||||||
| export const isTestEnv = () => import.meta.env.MODE === "test"; | /** | ||||||
|  |  * Converts a readonly array or map into an iterable. | ||||||
|  |  * Useful for avoiding entry allocations when iterating object / map on each iteration. | ||||||
|  |  */ | ||||||
|  | export const toIterable = <T>( | ||||||
|  |   values: readonly T[] | ReadonlyMap<string, T>, | ||||||
|  | ): Iterable<T> => { | ||||||
|  |   return Array.isArray(values) ? values : values.values(); | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| export const isDevEnv = () => import.meta.env.MODE === "development"; | /** | ||||||
|  |  * Converts a readonly array or map into an array. | ||||||
|  |  */ | ||||||
|  | export const toArray = <T>( | ||||||
|  |   values: readonly T[] | ReadonlyMap<string, T>, | ||||||
|  | ): T[] => { | ||||||
|  |   return Array.isArray(values) ? values : Array.from(toIterable(values)); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const isTestEnv = () => import.meta.env.MODE === ENV.TEST; | ||||||
|  | 
 | ||||||
|  | export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; | ||||||
|  | 
 | ||||||
|  | export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION; | ||||||
| 
 | 
 | ||||||
| export const isServerEnv = () => | export const isServerEnv = () => | ||||||
|   typeof process !== "undefined" && !!process?.env?.NODE_ENV; |   typeof process !== "undefined" && !!process?.env?.NODE_ENV; | ||||||
| @@ -1184,54 +1226,6 @@ export const safelyParseJSON = (json: string): Record<string, any> | null => { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| // extending the missing types
 |  | ||||||
| // relying on the [Index, T] to keep a correct order
 |  | ||||||
| type TPromisePool<T, Index = number> = Pool<[Index, T][]> & { |  | ||||||
|   addEventListener: ( |  | ||||||
|     type: "fulfilled", |  | ||||||
|     listener: (event: { data: { result: [Index, T] } }) => void, |  | ||||||
|   ) => (event: { data: { result: [Index, T] } }) => void; |  | ||||||
|   removeEventListener: ( |  | ||||||
|     type: "fulfilled", |  | ||||||
|     listener: (event: { data: { result: [Index, T] } }) => void, |  | ||||||
|   ) => void; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export class PromisePool<T> { |  | ||||||
|   private readonly pool: TPromisePool<T>; |  | ||||||
|   private readonly entries: Record<number, T> = {}; |  | ||||||
| 
 |  | ||||||
|   constructor( |  | ||||||
|     source: IterableIterator<Promise<void | readonly [number, T]>>, |  | ||||||
|     concurrency: number, |  | ||||||
|   ) { |  | ||||||
|     this.pool = new Pool( |  | ||||||
|       source as unknown as () => void | PromiseLike<[number, T][]>, |  | ||||||
|       concurrency, |  | ||||||
|     ) as TPromisePool<T>; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   public all() { |  | ||||||
|     const listener = (event: { data: { result: void | [number, T] } }) => { |  | ||||||
|       if (event.data.result) { |  | ||||||
|         // by default pool does not return the results, so we are gathering them manually
 |  | ||||||
|         // with the correct call order (represented by the index in the tuple)
 |  | ||||||
|         const [index, value] = event.data.result; |  | ||||||
|         this.entries[index] = value; |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     this.pool.addEventListener("fulfilled", listener); |  | ||||||
| 
 |  | ||||||
|     return this.pool.start().then(() => { |  | ||||||
|       setTimeout(() => { |  | ||||||
|         this.pool.removeEventListener("fulfilled", listener); |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       return Object.values(this.entries); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * use when you need to render unsafe string as HTML attribute, but MAKE SURE |  * use when you need to render unsafe string as HTML attribute, but MAKE SURE | ||||||
| @@ -1243,3 +1237,60 @@ export const escapeDoubleQuotes = (str: string) => { | |||||||
| 
 | 
 | ||||||
| export const castArray = <T>(value: T | T[]): T[] => | export const castArray = <T>(value: T | T[]): T[] => | ||||||
|   Array.isArray(value) ? value : [value]; |   Array.isArray(value) ? value : [value]; | ||||||
|  | 
 | ||||||
|  | export const elementCenterPoint = ( | ||||||
|  |   element: ExcalidrawElement, | ||||||
|  |   xOffset: number = 0, | ||||||
|  |   yOffset: number = 0, | ||||||
|  | ) => { | ||||||
|  |   const { x, y, width, height } = element; | ||||||
|  | 
 | ||||||
|  |   const centerXPoint = x + width / 2 + xOffset; | ||||||
|  | 
 | ||||||
|  |   const centerYPoint = y + height / 2 + yOffset; | ||||||
|  | 
 | ||||||
|  |   return pointFrom<GlobalPoint>(centerXPoint, centerYPoint); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** hack for Array.isArray type guard not working with readonly value[] */ | ||||||
|  | export const isReadonlyArray = (value?: any): value is readonly any[] => { | ||||||
|  |   return Array.isArray(value); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const sizeOf = ( | ||||||
|  |   value: | ||||||
|  |     | readonly unknown[] | ||||||
|  |     | Readonly<Map<string, unknown>> | ||||||
|  |     | Readonly<Record<string, unknown>> | ||||||
|  |     | ReadonlySet<unknown>, | ||||||
|  | ): number => { | ||||||
|  |   return isReadonlyArray(value) | ||||||
|  |     ? value.length | ||||||
|  |     : value instanceof Map || value instanceof Set | ||||||
|  |     ? value.size | ||||||
|  |     : Object.keys(value).length; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const reduceToCommonValue = <T, R = T>( | ||||||
|  |   collection: readonly T[] | ReadonlySet<T>, | ||||||
|  |   getValue?: (item: T) => R, | ||||||
|  | ): R | null => { | ||||||
|  |   if (sizeOf(collection) === 0) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const valueExtractor = getValue || ((item: T) => item as unknown as R); | ||||||
|  | 
 | ||||||
|  |   let commonValue: R | null = null; | ||||||
|  | 
 | ||||||
|  |   for (const item of collection) { | ||||||
|  |     const value = valueExtractor(item); | ||||||
|  |     if ((commonValue === null || commonValue === value) && value != null) { | ||||||
|  |       commonValue = value; | ||||||
|  |     } else { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return commonValue; | ||||||
|  | }; | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { KEYS, matchKey } from "./keys"; | import { KEYS, matchKey } from "../src/keys"; | ||||||
| 
 | 
 | ||||||
| describe("key matcher", async () => { | describe("key matcher", async () => { | ||||||
|   it("should not match unexpected key", async () => { |   it("should not match unexpected key", async () => { | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Queue } from "./queue"; | import { Queue } from "../src/queue"; | ||||||
| 
 | 
 | ||||||
| describe("Queue", () => { | describe("Queue", () => { | ||||||
|   const calls: any[] = []; |   const calls: any[] = []; | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { normalizeLink } from "./url"; | import { normalizeLink } from "../src/url"; | ||||||
| 
 | 
 | ||||||
| describe("normalizeLink", () => { | describe("normalizeLink", () => { | ||||||
|   // NOTE not an extensive XSS test suite, just to check if we're not
 |   // NOTE not an extensive XSS test suite, just to check if we're not
 | ||||||
							
								
								
									
										8
									
								
								packages/common/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								packages/common/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | { | ||||||
|  |   "extends": "../tsconfig.base.json", | ||||||
|  |   "compilerOptions": { | ||||||
|  |     "outDir": "./dist/types" | ||||||
|  |   }, | ||||||
|  |   "include": ["src/**/*", "global.d.ts"], | ||||||
|  |   "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"] | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								packages/element/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/element/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | { | ||||||
|  |   "extends": ["../eslintrc.base.json"] | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								packages/element/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/element/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | # @excalidraw/element | ||||||
|  |  | ||||||
|  | ## Install | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | npm install @excalidraw/element | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | If you prefer Yarn over npm, use this command to install the Excalidraw utils package: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | yarn add @excalidraw/element | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | With PNPM, similarly install the package with this command: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | pnpm add @excalidraw/element | ||||||
|  | ``` | ||||||
							
								
								
									
										3
									
								
								packages/element/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/element/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | /// <reference types="vite/client" /> | ||||||
|  | import "@excalidraw/excalidraw/global"; | ||||||
|  | import "@excalidraw/excalidraw/css"; | ||||||
							
								
								
									
										56
									
								
								packages/element/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								packages/element/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | { | ||||||
|  |   "name": "@excalidraw/element", | ||||||
|  |   "version": "0.1.0", | ||||||
|  |   "type": "module", | ||||||
|  |   "types": "./dist/types/element/src/index.d.ts", | ||||||
|  |   "main": "./dist/prod/index.js", | ||||||
|  |   "module": "./dist/prod/index.js", | ||||||
|  |   "exports": { | ||||||
|  |     ".": { | ||||||
|  |       "types": "./dist/types/element/src/index.d.ts", | ||||||
|  |       "development": "./dist/dev/index.js", | ||||||
|  |       "production": "./dist/prod/index.js", | ||||||
|  |       "default": "./dist/prod/index.js" | ||||||
|  |     }, | ||||||
|  |     "./*": { | ||||||
|  |       "types": "./dist/types/element/src/*.d.ts" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "files": [ | ||||||
|  |     "dist/*" | ||||||
|  |   ], | ||||||
|  |   "description": "Excalidraw elements-related logic", | ||||||
|  |   "publishConfig": { | ||||||
|  |     "access": "public" | ||||||
|  |   }, | ||||||
|  |   "license": "MIT", | ||||||
|  |   "keywords": [ | ||||||
|  |     "excalidraw", | ||||||
|  |     "excalidraw-utils" | ||||||
|  |   ], | ||||||
|  |   "browserslist": { | ||||||
|  |     "production": [ | ||||||
|  |       ">0.2%", | ||||||
|  |       "not dead", | ||||||
|  |       "not ie <= 11", | ||||||
|  |       "not op_mini all", | ||||||
|  |       "not safari < 12", | ||||||
|  |       "not kaios <= 2.5", | ||||||
|  |       "not edge < 79", | ||||||
|  |       "not chrome < 70", | ||||||
|  |       "not and_uc < 13", | ||||||
|  |       "not samsung < 10" | ||||||
|  |     ], | ||||||
|  |     "development": [ | ||||||
|  |       "last 1 chrome version", | ||||||
|  |       "last 1 firefox version", | ||||||
|  |       "last 1 safari version" | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "bugs": "https://github.com/excalidraw/excalidraw/issues", | ||||||
|  |   "repository": "https://github.com/excalidraw/excalidraw", | ||||||
|  |   "scripts": { | ||||||
|  |     "gen:types": "rimraf types && tsc", | ||||||
|  |     "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,4 +1,27 @@ | |||||||
| import throttle from "lodash.throttle"; | import throttle from "lodash.throttle"; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   randomInteger, | ||||||
|  |   arrayToMap, | ||||||
|  |   toBrandedType, | ||||||
|  |   isDevEnv, | ||||||
|  |   isTestEnv, | ||||||
|  |   toArray, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  | import { isNonDeletedElement } from "@excalidraw/element"; | ||||||
|  | import { isFrameLikeElement } from "@excalidraw/element"; | ||||||
|  | import { getElementsInGroup } from "@excalidraw/element"; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   syncInvalidIndices, | ||||||
|  |   syncMovedIndices, | ||||||
|  |   validateFractionalIndices, | ||||||
|  | } from "@excalidraw/element"; | ||||||
|  | 
 | ||||||
|  | import { getSelectedElements } from "@excalidraw/element"; | ||||||
|  | 
 | ||||||
|  | import { mutateElement, type ElementUpdate } from "@excalidraw/element"; | ||||||
|  | 
 | ||||||
| import type { | import type { | ||||||
|   ExcalidrawElement, |   ExcalidrawElement, | ||||||
|   NonDeletedExcalidrawElement, |   NonDeletedExcalidrawElement, | ||||||
| @@ -9,26 +32,15 @@ import type { | |||||||
|   NonDeletedSceneElementsMap, |   NonDeletedSceneElementsMap, | ||||||
|   OrderedExcalidrawElement, |   OrderedExcalidrawElement, | ||||||
|   Ordered, |   Ordered, | ||||||
| } from "../element/types"; | } from "@excalidraw/element/types"; | ||||||
| import { isNonDeletedElement } from "../element"; |  | ||||||
| import type { LinearElementEditor } from "../element/linearElementEditor"; |  | ||||||
| import { isFrameLikeElement } from "../element/typeChecks"; |  | ||||||
| import { getSelectedElements } from "./selection"; |  | ||||||
| import type { AppState } from "../types"; |  | ||||||
| import type { Assert, SameType } from "../utility-types"; |  | ||||||
| import { randomInteger } from "../random"; |  | ||||||
| import { |  | ||||||
|   syncInvalidIndices, |  | ||||||
|   syncMovedIndices, |  | ||||||
|   validateFractionalIndices, |  | ||||||
| } from "../fractionalIndex"; |  | ||||||
| import { arrayToMap } from "../utils"; |  | ||||||
| import { toBrandedType } from "../utils"; |  | ||||||
| import { ENV } from "../constants"; |  | ||||||
| import { getElementsInGroup } from "../groups"; |  | ||||||
| 
 | 
 | ||||||
| type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; | import type { | ||||||
| type ElementKey = ExcalidrawElement | ElementIdKey; |   Assert, | ||||||
|  |   Mutable, | ||||||
|  |   SameType, | ||||||
|  | } from "@excalidraw/common/utility-types"; | ||||||
|  | 
 | ||||||
|  | import type { AppState } from "../../excalidraw/types"; | ||||||
| 
 | 
 | ||||||
| type SceneStateCallback = () => void; | type SceneStateCallback = () => void; | ||||||
| type SceneStateCallbackRemover = () => void; | type SceneStateCallbackRemover = () => void; | ||||||
| @@ -54,14 +66,10 @@ const getNonDeletedElements = <T extends ExcalidrawElement>( | |||||||
| 
 | 
 | ||||||
| const validateIndicesThrottled = throttle( | const validateIndicesThrottled = throttle( | ||||||
|   (elements: readonly ExcalidrawElement[]) => { |   (elements: readonly ExcalidrawElement[]) => { | ||||||
|     if ( |     if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) { | ||||||
|       import.meta.env.DEV || |  | ||||||
|       import.meta.env.MODE === ENV.TEST || |  | ||||||
|       window?.DEBUG_FRACTIONAL_INDICES |  | ||||||
|     ) { |  | ||||||
|       validateFractionalIndices(elements, { |       validateFractionalIndices(elements, { | ||||||
|         // throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
 |         // throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
 | ||||||
|         shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST, |         shouldThrow: isDevEnv() || isTestEnv(), | ||||||
|         includeBoundTextValidation: true, |         includeBoundTextValidation: true, | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| @@ -97,44 +105,7 @@ const hashSelectionOpts = ( | |||||||
| // in our codebase
 | // in our codebase
 | ||||||
| export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; | export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; | ||||||
| 
 | 
 | ||||||
| const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => { | export class Scene { | ||||||
|   if (typeof elementKey === "string") { |  | ||||||
|     return true; |  | ||||||
|   } |  | ||||||
|   return false; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| class Scene { |  | ||||||
|   // ---------------------------------------------------------------------------
 |  | ||||||
|   // static methods/props
 |  | ||||||
|   // ---------------------------------------------------------------------------
 |  | ||||||
| 
 |  | ||||||
|   private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>(); |  | ||||||
|   private static sceneMapById = new Map<string, Scene>(); |  | ||||||
| 
 |  | ||||||
|   static mapElementToScene(elementKey: ElementKey, scene: Scene) { |  | ||||||
|     if (isIdKey(elementKey)) { |  | ||||||
|       // for cases where we don't have access to the element object
 |  | ||||||
|       // (e.g. restore serialized appState with id references)
 |  | ||||||
|       this.sceneMapById.set(elementKey, scene); |  | ||||||
|     } else { |  | ||||||
|       this.sceneMapByElement.set(elementKey, scene); |  | ||||||
|       // if mapping element objects, also cache the id string when later
 |  | ||||||
|       // looking up by id alone
 |  | ||||||
|       this.sceneMapById.set(elementKey.id, scene); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * @deprecated pass down `app.scene` and use it directly |  | ||||||
|    */ |  | ||||||
|   static getScene(elementKey: ElementKey): Scene | null { |  | ||||||
|     if (isIdKey(elementKey)) { |  | ||||||
|       return this.sceneMapById.get(elementKey) || null; |  | ||||||
|     } |  | ||||||
|     return this.sceneMapByElement.get(elementKey) || null; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // ---------------------------------------------------------------------------
 |   // ---------------------------------------------------------------------------
 | ||||||
|   // instance methods/props
 |   // instance methods/props
 | ||||||
|   // ---------------------------------------------------------------------------
 |   // ---------------------------------------------------------------------------
 | ||||||
| @@ -193,6 +164,12 @@ class Scene { | |||||||
|     return this.frames; |     return this.frames; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   constructor(elements: ElementsMapOrArray | null = null) { | ||||||
|  |     if (elements) { | ||||||
|  |       this.replaceAllElements(elements); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getSelectedElements(opts: { |   getSelectedElements(opts: { | ||||||
|     // NOTE can be ommitted by making Scene constructor require App instance
 |     // NOTE can be ommitted by making Scene constructor require App instance
 | ||||||
|     selectedElementIds: AppState["selectedElementIds"]; |     selectedElementIds: AppState["selectedElementIds"]; | ||||||
| @@ -287,11 +264,8 @@ class Scene { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   replaceAllElements(nextElements: ElementsMapOrArray) { |   replaceAllElements(nextElements: ElementsMapOrArray) { | ||||||
|     const _nextElements = |     // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
 | ||||||
|       // ts doesn't like `Array.isArray` of `instanceof Map`
 |     const _nextElements = toArray(nextElements); | ||||||
|       nextElements instanceof Array |  | ||||||
|         ? nextElements |  | ||||||
|         : Array.from(nextElements.values()); |  | ||||||
|     const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; |     const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; | ||||||
| 
 | 
 | ||||||
|     validateIndicesThrottled(_nextElements); |     validateIndicesThrottled(_nextElements); | ||||||
| @@ -303,7 +277,6 @@ class Scene { | |||||||
|         nextFrameLikes.push(element); |         nextFrameLikes.push(element); | ||||||
|       } |       } | ||||||
|       this.elementsMap.set(element.id, element); |       this.elementsMap.set(element.id, element); | ||||||
|       Scene.mapElementToScene(element, this); |  | ||||||
|     }); |     }); | ||||||
|     const nonDeletedElements = getNonDeletedElements(this.elements); |     const nonDeletedElements = getNonDeletedElements(this.elements); | ||||||
|     this.nonDeletedElements = nonDeletedElements.elements; |     this.nonDeletedElements = nonDeletedElements.elements; | ||||||
| @@ -348,12 +321,6 @@ class Scene { | |||||||
|     this.selectedElementsCache.elements = null; |     this.selectedElementsCache.elements = null; | ||||||
|     this.selectedElementsCache.cache.clear(); |     this.selectedElementsCache.cache.clear(); | ||||||
| 
 | 
 | ||||||
|     Scene.sceneMapById.forEach((scene, elementKey) => { |  | ||||||
|       if (scene === this) { |  | ||||||
|         Scene.sceneMapById.delete(elementKey); |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     // done not for memory leaks, but to guard against possible late fires
 |     // done not for memory leaks, but to guard against possible late fires
 | ||||||
|     // (I guess?)
 |     // (I guess?)
 | ||||||
|     this.callbacks.clear(); |     this.callbacks.clear(); | ||||||
| @@ -450,6 +417,40 @@ class Scene { | |||||||
|     // then, check if the id is a group
 |     // then, check if the id is a group
 | ||||||
|     return getElementsInGroup(elementsMap, id); |     return getElementsInGroup(elementsMap, id); | ||||||
|   }; |   }; | ||||||
|  | 
 | ||||||
|  |   // Mutate an element with passed updates and trigger the component to update. Make sure you
 | ||||||
|  |   // are calling it either from a React event handler or within unstable_batchedUpdates().
 | ||||||
|  |   mutateElement<TElement extends Mutable<ExcalidrawElement>>( | ||||||
|  |     element: TElement, | ||||||
|  |     updates: ElementUpdate<TElement>, | ||||||
|  |     options: { | ||||||
|  |       informMutation: boolean; | ||||||
|  |       isDragging: boolean; | ||||||
|  |     } = { | ||||||
|  |       informMutation: true, | ||||||
|  |       isDragging: false, | ||||||
|  |     }, | ||||||
|  |   ) { | ||||||
|  |     const elementsMap = this.getNonDeletedElementsMap(); | ||||||
|  | 
 | ||||||
|  |     const { version: prevVersion } = element; | ||||||
|  |     const { version: nextVersion } = mutateElement( | ||||||
|  |       element, | ||||||
|  |       elementsMap, | ||||||
|  |       updates, | ||||||
|  |       options, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |       // skip if the element is not in the scene (i.e. selection)
 | ||||||
|  |       this.elementsMap.has(element.id) && | ||||||
|  |       // skip if the element's version hasn't changed, as mutateElement returned the same element
 | ||||||
|  |       prevVersion !== nextVersion && | ||||||
|  |       options.informMutation | ||||||
|  |     ) { | ||||||
|  |       this.triggerUpdate(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| export default Scene; |     return element; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,31 +1,38 @@ | |||||||
| import type { Point as RoughPoint } from "roughjs/bin/geometry"; |  | ||||||
| import type { Drawable, Options } from "roughjs/bin/core"; |  | ||||||
| import type { RoughGenerator } from "roughjs/bin/generator"; |  | ||||||
| import { getDiamondPoints, getArrowheadPoints } from "../element"; |  | ||||||
| import type { ElementShapes } from "./types"; |  | ||||||
| import type { |  | ||||||
|   ExcalidrawElement, |  | ||||||
|   NonDeletedExcalidrawElement, |  | ||||||
|   ExcalidrawSelectionElement, |  | ||||||
|   ExcalidrawLinearElement, |  | ||||||
|   Arrowhead, |  | ||||||
| } from "../element/types"; |  | ||||||
| import { generateFreeDrawShape } from "../renderer/renderElement"; |  | ||||||
| import { isTransparent, assertNever } from "../utils"; |  | ||||||
| import { simplify } from "points-on-curve"; | import { simplify } from "points-on-curve"; | ||||||
| import { ROUGHNESS } from "../constants"; | 
 | ||||||
|  | import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; | ||||||
|  | import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; | ||||||
|  | 
 | ||||||
|  | import type { Mutable } from "@excalidraw/common/utility-types"; | ||||||
|  | 
 | ||||||
|  | import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; | ||||||
|  | import type { ElementShapes } from "@excalidraw/excalidraw/scene/types"; | ||||||
|  | 
 | ||||||
| import { | import { | ||||||
|   isElbowArrow, |   isElbowArrow, | ||||||
|   isEmbeddableElement, |   isEmbeddableElement, | ||||||
|   isIframeElement, |   isIframeElement, | ||||||
|   isIframeLikeElement, |   isIframeLikeElement, | ||||||
|   isLinearElement, |   isLinearElement, | ||||||
| } from "../element/typeChecks"; | } from "./typeChecks"; | ||||||
|  | import { getCornerRadius, isPathALoop } from "./shapes"; | ||||||
|  | import { headingForPointIsHorizontal } from "./heading"; | ||||||
|  | 
 | ||||||
| import { canChangeRoundness } from "./comparisons"; | import { canChangeRoundness } from "./comparisons"; | ||||||
| import type { EmbedsValidationStatus } from "../types"; | import { generateFreeDrawShape } from "./renderElement"; | ||||||
| import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; | import { getArrowheadPoints, getDiamondPoints } from "./bounds"; | ||||||
| import { getCornerRadius, isPathALoop } from "../shapes"; | 
 | ||||||
| import { headingForPointIsHorizontal } from "../element/heading"; | import type { | ||||||
|  |   ExcalidrawElement, | ||||||
|  |   NonDeletedExcalidrawElement, | ||||||
|  |   ExcalidrawSelectionElement, | ||||||
|  |   ExcalidrawLinearElement, | ||||||
|  |   Arrowhead, | ||||||
|  | } from "./types"; | ||||||
|  | 
 | ||||||
|  | import type { Drawable, Options } from "roughjs/bin/core"; | ||||||
|  | import type { RoughGenerator } from "roughjs/bin/generator"; | ||||||
|  | import type { Point as RoughPoint } from "roughjs/bin/geometry"; | ||||||
| 
 | 
 | ||||||
| const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; | const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; | ||||||
| 
 | 
 | ||||||
| @@ -508,7 +515,10 @@ export const _generateElementShape = ( | |||||||
| 
 | 
 | ||||||
|       if (isPathALoop(element.points)) { |       if (isPathALoop(element.points)) { | ||||||
|         // generate rough polygon to fill freedraw shape
 |         // generate rough polygon to fill freedraw shape
 | ||||||
|         const simplifiedPoints = simplify(element.points, 0.75); |         const simplifiedPoints = simplify( | ||||||
|  |           element.points as Mutable<LocalPoint[]>, | ||||||
|  |           0.75, | ||||||
|  |         ); | ||||||
|         shape = generator.curve(simplifiedPoints as [number, number][], { |         shape = generator.curve(simplifiedPoints as [number, number][], { | ||||||
|           ...generateRoughOptions(element), |           ...generateRoughOptions(element), | ||||||
|           stroke: "none", |           stroke: "none", | ||||||
| @@ -1,14 +1,23 @@ | |||||||
| import type { Drawable } from "roughjs/bin/core"; |  | ||||||
| import { RoughGenerator } from "roughjs/bin/generator"; | import { RoughGenerator } from "roughjs/bin/generator"; | ||||||
|  | 
 | ||||||
|  | import { COLOR_PALETTE } from "@excalidraw/common"; | ||||||
|  | 
 | ||||||
| import type { | import type { | ||||||
|   ExcalidrawElement, |   AppState, | ||||||
|   ExcalidrawSelectionElement, |   EmbedsValidationStatus, | ||||||
| } from "../element/types"; | } from "@excalidraw/excalidraw/types"; | ||||||
| import { elementWithCanvasCache } from "../renderer/renderElement"; | import type { | ||||||
|  |   ElementShape, | ||||||
|  |   ElementShapes, | ||||||
|  | } from "@excalidraw/excalidraw/scene/types"; | ||||||
|  | 
 | ||||||
| import { _generateElementShape } from "./Shape"; | import { _generateElementShape } from "./Shape"; | ||||||
| import type { ElementShape, ElementShapes } from "./types"; | 
 | ||||||
| import { COLOR_PALETTE } from "../colors"; | import { elementWithCanvasCache } from "./renderElement"; | ||||||
| import type { AppState, EmbedsValidationStatus } from "../types"; | 
 | ||||||
|  | import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types"; | ||||||
|  | 
 | ||||||
|  | import type { Drawable } from "roughjs/bin/core"; | ||||||
| 
 | 
 | ||||||
| export class ShapeCache { | export class ShapeCache { | ||||||
|   private static rg = new RoughGenerator(); |   private static rg = new RoughGenerator(); | ||||||
| @@ -1,10 +1,11 @@ | |||||||
| import type { ElementsMap, ExcalidrawElement } from "./element/types"; | import { updateBoundElements } from "./binding"; | ||||||
| import { mutateElement } from "./element/mutateElement"; | import { getCommonBoundingBox } from "./bounds"; | ||||||
| import type { BoundingBox } from "./element/bounds"; |  | ||||||
| import { getCommonBoundingBox } from "./element/bounds"; |  | ||||||
| import { getMaximumGroups } from "./groups"; | import { getMaximumGroups } from "./groups"; | ||||||
| import { updateBoundElements } from "./element/binding"; | 
 | ||||||
| import type Scene from "./scene/Scene"; | import type { Scene } from "./Scene"; | ||||||
|  | 
 | ||||||
|  | import type { BoundingBox } from "./bounds"; | ||||||
|  | import type { ExcalidrawElement } from "./types"; | ||||||
| 
 | 
 | ||||||
| export interface Alignment { | export interface Alignment { | ||||||
|   position: "start" | "center" | "end"; |   position: "start" | "center" | "end"; | ||||||
| @@ -13,10 +14,10 @@ export interface Alignment { | |||||||
| 
 | 
 | ||||||
| export const alignElements = ( | export const alignElements = ( | ||||||
|   selectedElements: ExcalidrawElement[], |   selectedElements: ExcalidrawElement[], | ||||||
|   elementsMap: ElementsMap, |  | ||||||
|   alignment: Alignment, |   alignment: Alignment, | ||||||
|   scene: Scene, |   scene: Scene, | ||||||
| ): ExcalidrawElement[] => { | ): ExcalidrawElement[] => { | ||||||
|  |   const elementsMap = scene.getNonDeletedElementsMap(); | ||||||
|   const groups: ExcalidrawElement[][] = getMaximumGroups( |   const groups: ExcalidrawElement[][] = getMaximumGroups( | ||||||
|     selectedElements, |     selectedElements, | ||||||
|     elementsMap, |     elementsMap, | ||||||
| @@ -31,12 +32,13 @@ export const alignElements = ( | |||||||
|     ); |     ); | ||||||
|     return group.map((element) => { |     return group.map((element) => { | ||||||
|       // update element
 |       // update element
 | ||||||
|       const updatedEle = mutateElement(element, { |       const updatedEle = scene.mutateElement(element, { | ||||||
|         x: element.x + translation.x, |         x: element.x + translation.x, | ||||||
|         y: element.y + translation.y, |         y: element.y + translation.y, | ||||||
|       }); |       }); | ||||||
|  | 
 | ||||||
|       // update bound elements
 |       // update bound elements
 | ||||||
|       updateBoundElements(element, scene.getNonDeletedElementsMap(), { |       updateBoundElements(element, scene, { | ||||||
|         simultaneouslyUpdated: group, |         simultaneouslyUpdated: group, | ||||||
|       }); |       }); | ||||||
|       return updatedEle; |       return updatedEle; | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,17 +1,42 @@ | |||||||
| import type { |  | ||||||
|   ExcalidrawElement, |  | ||||||
|   ExcalidrawLinearElement, |  | ||||||
|   Arrowhead, |  | ||||||
|   ExcalidrawFreeDrawElement, |  | ||||||
|   NonDeleted, |  | ||||||
|   ExcalidrawTextElementWithContainer, |  | ||||||
|   ElementsMap, |  | ||||||
| } from "./types"; |  | ||||||
| import rough from "roughjs/bin/rough"; | import rough from "roughjs/bin/rough"; | ||||||
| import type { Point as RoughPoint } from "roughjs/bin/geometry"; | 
 | ||||||
| import type { Drawable, Op } from "roughjs/bin/core"; | import { | ||||||
| import type { AppState } from "../types"; |   arrayToMap, | ||||||
| import { generateRoughOptions } from "../scene/Shape"; |   invariant, | ||||||
|  |   rescalePoints, | ||||||
|  |   sizeOf, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   degreesToRadians, | ||||||
|  |   lineSegment, | ||||||
|  |   pointDistance, | ||||||
|  |   pointFrom, | ||||||
|  |   pointFromArray, | ||||||
|  |   pointRotateRads, | ||||||
|  | } from "@excalidraw/math"; | ||||||
|  | 
 | ||||||
|  | import { getCurvePathOps } from "@excalidraw/utils/shape"; | ||||||
|  | 
 | ||||||
|  | import { pointsOnBezierCurves } from "points-on-curve"; | ||||||
|  | 
 | ||||||
|  | import type { | ||||||
|  |   Curve, | ||||||
|  |   Degrees, | ||||||
|  |   GlobalPoint, | ||||||
|  |   LineSegment, | ||||||
|  |   LocalPoint, | ||||||
|  |   Radians, | ||||||
|  | } from "@excalidraw/math"; | ||||||
|  | 
 | ||||||
|  | import type { AppState } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
|  | import type { Mutable } from "@excalidraw/common/utility-types"; | ||||||
|  | 
 | ||||||
|  | import { generateRoughOptions } from "./Shape"; | ||||||
|  | import { ShapeCache } from "./ShapeCache"; | ||||||
|  | import { LinearElementEditor } from "./linearElementEditor"; | ||||||
|  | import { getBoundTextElement, getContainerElement } from "./textElement"; | ||||||
| import { | import { | ||||||
|   isArrowElement, |   isArrowElement, | ||||||
|   isBoundToContainer, |   isBoundToContainer, | ||||||
| @@ -19,28 +44,28 @@ import { | |||||||
|   isLinearElement, |   isLinearElement, | ||||||
|   isTextElement, |   isTextElement, | ||||||
| } from "./typeChecks"; | } from "./typeChecks"; | ||||||
| import { rescalePoints } from "../points"; | 
 | ||||||
| import { getBoundTextElement, getContainerElement } from "./textElement"; | import { getElementShape } from "./shapes"; | ||||||
| import { LinearElementEditor } from "./linearElementEditor"; | 
 | ||||||
| import { ShapeCache } from "../scene/ShapeCache"; |  | ||||||
| import { arrayToMap, invariant } from "../utils"; |  | ||||||
| import type { |  | ||||||
|   Degrees, |  | ||||||
|   GlobalPoint, |  | ||||||
|   LineSegment, |  | ||||||
|   LocalPoint, |  | ||||||
|   Radians, |  | ||||||
| } from "@excalidraw/math"; |  | ||||||
| import { | import { | ||||||
|   degreesToRadians, |   deconstructDiamondElement, | ||||||
|   lineSegment, |   deconstructRectanguloidElement, | ||||||
|   pointFrom, | } from "./utils"; | ||||||
|   pointDistance, | 
 | ||||||
|   pointFromArray, | import type { Drawable, Op } from "roughjs/bin/core"; | ||||||
|   pointRotateRads, | import type { Point as RoughPoint } from "roughjs/bin/geometry"; | ||||||
| } from "@excalidraw/math"; | import type { | ||||||
| import type { Mutable } from "../utility-types"; |   Arrowhead, | ||||||
| import { getCurvePathOps } from "@excalidraw/utils/geometry/shape"; |   ElementsMap, | ||||||
|  |   ElementsMapOrArray, | ||||||
|  |   ExcalidrawElement, | ||||||
|  |   ExcalidrawEllipseElement, | ||||||
|  |   ExcalidrawFreeDrawElement, | ||||||
|  |   ExcalidrawLinearElement, | ||||||
|  |   ExcalidrawRectanguloidElement, | ||||||
|  |   ExcalidrawTextElementWithContainer, | ||||||
|  |   NonDeleted, | ||||||
|  | } from "./types"; | ||||||
| 
 | 
 | ||||||
| export type RectangleBox = { | export type RectangleBox = { | ||||||
|   x: number; |   x: number; | ||||||
| @@ -247,50 +272,82 @@ export const getElementAbsoluteCoords = ( | |||||||
|  * that can be used for visual collision detection (useful for frames) |  * that can be used for visual collision detection (useful for frames) | ||||||
|  * as opposed to bounding box collision detection |  * as opposed to bounding box collision detection | ||||||
|  */ |  */ | ||||||
|  | /** | ||||||
|  |  * Given an element, return the line segments that make up the element. | ||||||
|  |  * | ||||||
|  |  * Uses helpers from /math | ||||||
|  |  */ | ||||||
| export const getElementLineSegments = ( | export const getElementLineSegments = ( | ||||||
|   element: ExcalidrawElement, |   element: ExcalidrawElement, | ||||||
|   elementsMap: ElementsMap, |   elementsMap: ElementsMap, | ||||||
| ): LineSegment<GlobalPoint>[] => { | ): LineSegment<GlobalPoint>[] => { | ||||||
|  |   const shape = getElementShape(element, elementsMap); | ||||||
|   const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( |   const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( | ||||||
|     element, |     element, | ||||||
|     elementsMap, |     elementsMap, | ||||||
|   ); |   ); | ||||||
|  |   const center = pointFrom<GlobalPoint>(cx, cy); | ||||||
| 
 | 
 | ||||||
|   const center: GlobalPoint = pointFrom(cx, cy); |   if (shape.type === "polycurve") { | ||||||
| 
 |     const curves = shape.data; | ||||||
|   if (isLinearElement(element) || isFreeDrawElement(element)) { |     const points = curves | ||||||
|     const segments: LineSegment<GlobalPoint>[] = []; |       .map((curve) => pointsOnBezierCurves(curve, 10)) | ||||||
| 
 |       .flat(); | ||||||
|     let i = 0; |     let i = 0; | ||||||
| 
 |     const segments: LineSegment<GlobalPoint>[] = []; | ||||||
|     while (i < element.points.length - 1) { |     while (i < points.length - 1) { | ||||||
|       segments.push( |       segments.push( | ||||||
|         lineSegment( |         lineSegment( | ||||||
|           pointRotateRads( |           pointFrom(points[i][0], points[i][1]), | ||||||
|             pointFrom( |           pointFrom(points[i + 1][0], points[i + 1][1]), | ||||||
|               element.points[i][0] + element.x, |  | ||||||
|               element.points[i][1] + element.y, |  | ||||||
|             ), |  | ||||||
|             center, |  | ||||||
|             element.angle, |  | ||||||
|           ), |  | ||||||
|           pointRotateRads( |  | ||||||
|             pointFrom( |  | ||||||
|               element.points[i + 1][0] + element.x, |  | ||||||
|               element.points[i + 1][1] + element.y, |  | ||||||
|             ), |  | ||||||
|             center, |  | ||||||
|             element.angle, |  | ||||||
|           ), |  | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|       i++; |       i++; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return segments; |     return segments; | ||||||
|  |   } else if (shape.type === "polyline") { | ||||||
|  |     return shape.data as LineSegment<GlobalPoint>[]; | ||||||
|  |   } else if (_isRectanguloidElement(element)) { | ||||||
|  |     const [sides, corners] = deconstructRectanguloidElement(element); | ||||||
|  |     const cornerSegments: LineSegment<GlobalPoint>[] = corners | ||||||
|  |       .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) | ||||||
|  |       .flat(); | ||||||
|  |     const rotatedSides = getRotatedSides(sides, center, element.angle); | ||||||
|  |     return [...rotatedSides, ...cornerSegments]; | ||||||
|  |   } else if (element.type === "diamond") { | ||||||
|  |     const [sides, corners] = deconstructDiamondElement(element); | ||||||
|  |     const cornerSegments = corners | ||||||
|  |       .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) | ||||||
|  |       .flat(); | ||||||
|  |     const rotatedSides = getRotatedSides(sides, center, element.angle); | ||||||
|  | 
 | ||||||
|  |     return [...rotatedSides, ...cornerSegments]; | ||||||
|  |   } else if (shape.type === "polygon") { | ||||||
|  |     if (isTextElement(element)) { | ||||||
|  |       const container = getContainerElement(element, elementsMap); | ||||||
|  |       if (container && isLinearElement(container)) { | ||||||
|  |         const segments: LineSegment<GlobalPoint>[] = [ | ||||||
|  |           lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)), | ||||||
|  |           lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)), | ||||||
|  |           lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)), | ||||||
|  |           lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)), | ||||||
|  |         ]; | ||||||
|  |         return segments; | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|   const [nw, ne, sw, se, n, s, w, e] = ( |     const points = shape.data as GlobalPoint[]; | ||||||
|  |     const segments: LineSegment<GlobalPoint>[] = []; | ||||||
|  |     for (let i = 0; i < points.length - 1; i++) { | ||||||
|  |       segments.push(lineSegment(points[i], points[i + 1])); | ||||||
|  |     } | ||||||
|  |     return segments; | ||||||
|  |   } else if (shape.type === "ellipse") { | ||||||
|  |     return getSegmentsOnEllipse(element as ExcalidrawEllipseElement); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const [nw, ne, sw, se, , , w, e] = ( | ||||||
|     [ |     [ | ||||||
|       [x1, y1], |       [x1, y1], | ||||||
|       [x2, y1], |       [x2, y1], | ||||||
| @@ -303,28 +360,6 @@ export const getElementLineSegments = ( | |||||||
|     ] as GlobalPoint[] |     ] as GlobalPoint[] | ||||||
|   ).map((point) => pointRotateRads(point, center, element.angle)); |   ).map((point) => pointRotateRads(point, center, element.angle)); | ||||||
| 
 | 
 | ||||||
|   if (element.type === "diamond") { |  | ||||||
|     return [ |  | ||||||
|       lineSegment(n, w), |  | ||||||
|       lineSegment(n, e), |  | ||||||
|       lineSegment(s, w), |  | ||||||
|       lineSegment(s, e), |  | ||||||
|     ]; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (element.type === "ellipse") { |  | ||||||
|     return [ |  | ||||||
|       lineSegment(n, w), |  | ||||||
|       lineSegment(n, e), |  | ||||||
|       lineSegment(s, w), |  | ||||||
|       lineSegment(s, e), |  | ||||||
|       lineSegment(n, w), |  | ||||||
|       lineSegment(n, e), |  | ||||||
|       lineSegment(s, w), |  | ||||||
|       lineSegment(s, e), |  | ||||||
|     ]; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return [ |   return [ | ||||||
|     lineSegment(nw, ne), |     lineSegment(nw, ne), | ||||||
|     lineSegment(sw, se), |     lineSegment(sw, se), | ||||||
| @@ -337,6 +372,94 @@ export const getElementLineSegments = ( | |||||||
|   ]; |   ]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const _isRectanguloidElement = ( | ||||||
|  |   element: ExcalidrawElement, | ||||||
|  | ): element is ExcalidrawRectanguloidElement => { | ||||||
|  |   return ( | ||||||
|  |     element != null && | ||||||
|  |     (element.type === "rectangle" || | ||||||
|  |       element.type === "image" || | ||||||
|  |       element.type === "iframe" || | ||||||
|  |       element.type === "embeddable" || | ||||||
|  |       element.type === "frame" || | ||||||
|  |       element.type === "magicframe" || | ||||||
|  |       (element.type === "text" && !element.containerId)) | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getRotatedSides = ( | ||||||
|  |   sides: LineSegment<GlobalPoint>[], | ||||||
|  |   center: GlobalPoint, | ||||||
|  |   angle: Radians, | ||||||
|  | ) => { | ||||||
|  |   return sides.map((side) => { | ||||||
|  |     return lineSegment( | ||||||
|  |       pointRotateRads<GlobalPoint>(side[0], center, angle), | ||||||
|  |       pointRotateRads<GlobalPoint>(side[1], center, angle), | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getSegmentsOnCurve = ( | ||||||
|  |   curve: Curve<GlobalPoint>, | ||||||
|  |   center: GlobalPoint, | ||||||
|  |   angle: Radians, | ||||||
|  | ): LineSegment<GlobalPoint>[] => { | ||||||
|  |   const points = pointsOnBezierCurves(curve, 10); | ||||||
|  |   let i = 0; | ||||||
|  |   const segments: LineSegment<GlobalPoint>[] = []; | ||||||
|  |   while (i < points.length - 1) { | ||||||
|  |     segments.push( | ||||||
|  |       lineSegment( | ||||||
|  |         pointRotateRads<GlobalPoint>( | ||||||
|  |           pointFrom(points[i][0], points[i][1]), | ||||||
|  |           center, | ||||||
|  |           angle, | ||||||
|  |         ), | ||||||
|  |         pointRotateRads<GlobalPoint>( | ||||||
|  |           pointFrom(points[i + 1][0], points[i + 1][1]), | ||||||
|  |           center, | ||||||
|  |           angle, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |     i++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return segments; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getSegmentsOnEllipse = ( | ||||||
|  |   ellipse: ExcalidrawEllipseElement, | ||||||
|  | ): LineSegment<GlobalPoint>[] => { | ||||||
|  |   const center = pointFrom<GlobalPoint>( | ||||||
|  |     ellipse.x + ellipse.width / 2, | ||||||
|  |     ellipse.y + ellipse.height / 2, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   const a = ellipse.width / 2; | ||||||
|  |   const b = ellipse.height / 2; | ||||||
|  | 
 | ||||||
|  |   const segments: LineSegment<GlobalPoint>[] = []; | ||||||
|  |   const points: GlobalPoint[] = []; | ||||||
|  |   const n = 90; | ||||||
|  |   const deltaT = (Math.PI * 2) / n; | ||||||
|  | 
 | ||||||
|  |   for (let i = 0; i < n; i++) { | ||||||
|  |     const t = i * deltaT; | ||||||
|  |     const x = center[0] + a * Math.cos(t); | ||||||
|  |     const y = center[1] + b * Math.sin(t); | ||||||
|  |     points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   for (let i = 0; i < points.length - 1; i++) { | ||||||
|  |     segments.push(lineSegment(points[i], points[i + 1])); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   segments.push(lineSegment(points[points.length - 1], points[0])); | ||||||
|  |   return segments; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Scene -> Scene coords, but in x1,x2,y1,y2 format. |  * Scene -> Scene coords, but in x1,x2,y1,y2 format. | ||||||
|  * |  * | ||||||
| @@ -821,10 +944,10 @@ export const getElementBounds = ( | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const getCommonBounds = ( | export const getCommonBounds = ( | ||||||
|   elements: readonly ExcalidrawElement[], |   elements: ElementsMapOrArray, | ||||||
|   elementsMap?: ElementsMap, |   elementsMap?: ElementsMap, | ||||||
| ): Bounds => { | ): Bounds => { | ||||||
|   if (!elements.length) { |   if (!sizeOf(elements)) { | ||||||
|     return [0, 0, 0, 0]; |     return [0, 0, 0, 0]; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @@ -1,31 +1,4 @@ | |||||||
| import type { | import { isTransparent, elementCenterPoint } from "@excalidraw/common"; | ||||||
|   ElementsMap, |  | ||||||
|   ExcalidrawDiamondElement, |  | ||||||
|   ExcalidrawElement, |  | ||||||
|   ExcalidrawEllipseElement, |  | ||||||
|   ExcalidrawRectangleElement, |  | ||||||
|   ExcalidrawRectanguloidElement, |  | ||||||
| } from "./types"; |  | ||||||
| import { getElementBounds } from "./bounds"; |  | ||||||
| import type { FrameNameBounds } from "../types"; |  | ||||||
| import type { GeometricShape } from "@excalidraw/utils/geometry/shape"; |  | ||||||
| import { getPolygonShape } from "@excalidraw/utils/geometry/shape"; |  | ||||||
| import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; |  | ||||||
| import { isTransparent } from "../utils"; |  | ||||||
| import { |  | ||||||
|   hasBoundTextElement, |  | ||||||
|   isIframeLikeElement, |  | ||||||
|   isImageElement, |  | ||||||
|   isTextElement, |  | ||||||
| } from "./typeChecks"; |  | ||||||
| import { getBoundTextShape, isPathALoop } from "../shapes"; |  | ||||||
| import type { |  | ||||||
|   GlobalPoint, |  | ||||||
|   LineSegment, |  | ||||||
|   LocalPoint, |  | ||||||
|   Polygon, |  | ||||||
|   Radians, |  | ||||||
| } from "@excalidraw/math"; |  | ||||||
| import { | import { | ||||||
|   curveIntersectLineSegment, |   curveIntersectLineSegment, | ||||||
|   isPointWithinBounds, |   isPointWithinBounds, | ||||||
| @@ -36,15 +9,47 @@ import { | |||||||
|   pointRotateRads, |   pointRotateRads, | ||||||
|   pointsEqual, |   pointsEqual, | ||||||
| } from "@excalidraw/math"; | } from "@excalidraw/math"; | ||||||
|  | 
 | ||||||
| import { | import { | ||||||
|   ellipse, |   ellipse, | ||||||
|   ellipseLineIntersectionPoints, |   ellipseLineIntersectionPoints, | ||||||
| } from "@excalidraw/math/ellipse"; | } from "@excalidraw/math/ellipse"; | ||||||
|  | 
 | ||||||
|  | import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; | ||||||
|  | import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape"; | ||||||
|  | 
 | ||||||
|  | import type { | ||||||
|  |   GlobalPoint, | ||||||
|  |   LineSegment, | ||||||
|  |   LocalPoint, | ||||||
|  |   Polygon, | ||||||
|  |   Radians, | ||||||
|  | } from "@excalidraw/math"; | ||||||
|  | 
 | ||||||
|  | import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
|  | import { getBoundTextShape, isPathALoop } from "./shapes"; | ||||||
|  | import { getElementBounds } from "./bounds"; | ||||||
|  | import { | ||||||
|  |   hasBoundTextElement, | ||||||
|  |   isIframeLikeElement, | ||||||
|  |   isImageElement, | ||||||
|  |   isTextElement, | ||||||
|  | } from "./typeChecks"; | ||||||
| import { | import { | ||||||
|   deconstructDiamondElement, |   deconstructDiamondElement, | ||||||
|   deconstructRectanguloidElement, |   deconstructRectanguloidElement, | ||||||
| } from "./utils"; | } from "./utils"; | ||||||
| 
 | 
 | ||||||
|  | import type { | ||||||
|  |   ElementsMap, | ||||||
|  |   ExcalidrawDiamondElement, | ||||||
|  |   ExcalidrawElement, | ||||||
|  |   ExcalidrawEllipseElement, | ||||||
|  |   ExcalidrawRectangleElement, | ||||||
|  |   ExcalidrawRectanguloidElement, | ||||||
|  | } from "./types"; | ||||||
|  | 
 | ||||||
| export const shouldTestInside = (element: ExcalidrawElement) => { | export const shouldTestInside = (element: ExcalidrawElement) => { | ||||||
|   if (element.type === "arrow") { |   if (element.type === "arrow") { | ||||||
|     return false; |     return false; | ||||||
| @@ -184,10 +189,7 @@ const intersectRectanguloidWithLineSegment = ( | |||||||
|   l: LineSegment<GlobalPoint>, |   l: LineSegment<GlobalPoint>, | ||||||
|   offset: number = 0, |   offset: number = 0, | ||||||
| ): GlobalPoint[] => { | ): GlobalPoint[] => { | ||||||
|   const center = pointFrom<GlobalPoint>( |   const center = elementCenterPoint(element); | ||||||
|     element.x + element.width / 2, |  | ||||||
|     element.y + element.height / 2, |  | ||||||
|   ); |  | ||||||
|   // To emulate a rotated rectangle we rotate the point in the inverse angle
 |   // To emulate a rotated rectangle we rotate the point in the inverse angle
 | ||||||
|   // instead. It's all the same distance-wise.
 |   // instead. It's all the same distance-wise.
 | ||||||
|   const rotatedA = pointRotateRads<GlobalPoint>( |   const rotatedA = pointRotateRads<GlobalPoint>( | ||||||
| @@ -205,10 +207,9 @@ const intersectRectanguloidWithLineSegment = ( | |||||||
|   const [sides, corners] = deconstructRectanguloidElement(element, offset); |   const [sides, corners] = deconstructRectanguloidElement(element, offset); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     [ |  | ||||||
|     // Test intersection against the sides, keep only the valid
 |     // Test intersection against the sides, keep only the valid
 | ||||||
|     // intersection points and rotate them back to scene space
 |     // intersection points and rotate them back to scene space
 | ||||||
|       ...sides |     sides | ||||||
|       .map((s) => |       .map((s) => | ||||||
|         lineSegmentIntersectionPoints( |         lineSegmentIntersectionPoints( | ||||||
|           lineSegment<GlobalPoint>(rotatedA, rotatedB), |           lineSegment<GlobalPoint>(rotatedA, rotatedB), | ||||||
| @@ -216,17 +217,18 @@ const intersectRectanguloidWithLineSegment = ( | |||||||
|         ), |         ), | ||||||
|       ) |       ) | ||||||
|       .filter((x) => x != null) |       .filter((x) => x != null) | ||||||
|         .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)), |       .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)) | ||||||
|       // Test intersection against the corners which are cubic bezier curves,
 |       // Test intersection against the corners which are cubic bezier curves,
 | ||||||
|       // keep only the valid intersection points and rotate them back to scene
 |       // keep only the valid intersection points and rotate them back to scene
 | ||||||
|       // space
 |       // space
 | ||||||
|       ...corners |       .concat( | ||||||
|  |         corners | ||||||
|           .flatMap((t) => |           .flatMap((t) => | ||||||
|             curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), |             curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), | ||||||
|           ) |           ) | ||||||
|           .filter((i) => i != null) |           .filter((i) => i != null) | ||||||
|           .map((j) => pointRotateRads(j, center, element.angle)), |           .map((j) => pointRotateRads(j, center, element.angle)), | ||||||
|     ] |       ) | ||||||
|       // Remove duplicates
 |       // Remove duplicates
 | ||||||
|       .filter( |       .filter( | ||||||
|         (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, |         (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, | ||||||
| @@ -246,10 +248,7 @@ const intersectDiamondWithLineSegment = ( | |||||||
|   l: LineSegment<GlobalPoint>, |   l: LineSegment<GlobalPoint>, | ||||||
|   offset: number = 0, |   offset: number = 0, | ||||||
| ): GlobalPoint[] => { | ): GlobalPoint[] => { | ||||||
|   const center = pointFrom<GlobalPoint>( |   const center = elementCenterPoint(element); | ||||||
|     element.x + element.width / 2, |  | ||||||
|     element.y + element.height / 2, |  | ||||||
|   ); |  | ||||||
| 
 | 
 | ||||||
|   // Rotate the point to the inverse direction to simulate the rotated diamond
 |   // Rotate the point to the inverse direction to simulate the rotated diamond
 | ||||||
|   // points. It's all the same distance-wise.
 |   // points. It's all the same distance-wise.
 | ||||||
| @@ -259,8 +258,7 @@ const intersectDiamondWithLineSegment = ( | |||||||
|   const [sides, curves] = deconstructDiamondElement(element, offset); |   const [sides, curves] = deconstructDiamondElement(element, offset); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     [ |     sides | ||||||
|       ...sides |  | ||||||
|       .map((s) => |       .map((s) => | ||||||
|         lineSegmentIntersectionPoints( |         lineSegmentIntersectionPoints( | ||||||
|           lineSegment<GlobalPoint>(rotatedA, rotatedB), |           lineSegment<GlobalPoint>(rotatedA, rotatedB), | ||||||
| @@ -269,15 +267,16 @@ const intersectDiamondWithLineSegment = ( | |||||||
|       ) |       ) | ||||||
|       .filter((p): p is GlobalPoint => p != null) |       .filter((p): p is GlobalPoint => p != null) | ||||||
|       // Rotate back intersection points
 |       // Rotate back intersection points
 | ||||||
|         .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)), |       .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)) | ||||||
|       ...curves |       .concat( | ||||||
|  |         curves | ||||||
|           .flatMap((p) => |           .flatMap((p) => | ||||||
|             curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), |             curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), | ||||||
|           ) |           ) | ||||||
|           .filter((p) => p != null) |           .filter((p) => p != null) | ||||||
|           // Rotate back intersection points
 |           // Rotate back intersection points
 | ||||||
|           .map((p) => pointRotateRads(p, center, element.angle)), |           .map((p) => pointRotateRads(p, center, element.angle)), | ||||||
|     ] |       ) | ||||||
|       // Remove duplicates
 |       // Remove duplicates
 | ||||||
|       .filter( |       .filter( | ||||||
|         (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, |         (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, | ||||||
| @@ -297,10 +296,7 @@ const intersectEllipseWithLineSegment = ( | |||||||
|   l: LineSegment<GlobalPoint>, |   l: LineSegment<GlobalPoint>, | ||||||
|   offset: number = 0, |   offset: number = 0, | ||||||
| ): GlobalPoint[] => { | ): GlobalPoint[] => { | ||||||
|   const center = pointFrom<GlobalPoint>( |   const center = elementCenterPoint(element); | ||||||
|     element.x + element.width / 2, |  | ||||||
|     element.y + element.height / 2, |  | ||||||
|   ); |  | ||||||
| 
 | 
 | ||||||
|   const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); |   const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); | ||||||
|   const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); |   const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import type { ElementOrToolType } from "../types"; | import type { ElementOrToolType } from "@excalidraw/excalidraw/types"; | ||||||
| 
 | 
 | ||||||
| export const hasBackground = (type: ElementOrToolType) => | export const hasBackground = (type: ElementOrToolType) => | ||||||
|   type === "rectangle" || |   type === "rectangle" || | ||||||
| @@ -1,4 +1,3 @@ | |||||||
| import { type Point } from "points-on-curve"; |  | ||||||
| import { | import { | ||||||
|   type Radians, |   type Radians, | ||||||
|   pointFrom, |   pointFrom, | ||||||
| @@ -13,6 +12,15 @@ import { | |||||||
|   clamp, |   clamp, | ||||||
|   isCloseTo, |   isCloseTo, | ||||||
| } from "@excalidraw/math"; | } from "@excalidraw/math"; | ||||||
|  | import { type Point } from "points-on-curve"; | ||||||
|  | 
 | ||||||
|  | import { elementCenterPoint } from "@excalidraw/common"; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   getElementAbsoluteCoords, | ||||||
|  |   getResizedElementAbsoluteCoords, | ||||||
|  | } from "./bounds"; | ||||||
|  | 
 | ||||||
| import type { TransformHandleType } from "./transformHandles"; | import type { TransformHandleType } from "./transformHandles"; | ||||||
| import type { | import type { | ||||||
|   ElementsMap, |   ElementsMap, | ||||||
| @@ -21,10 +29,6 @@ import type { | |||||||
|   ImageCrop, |   ImageCrop, | ||||||
|   NonDeleted, |   NonDeleted, | ||||||
| } from "./types"; | } from "./types"; | ||||||
| import { |  | ||||||
|   getElementAbsoluteCoords, |  | ||||||
|   getResizedElementAbsoluteCoords, |  | ||||||
| } from "./bounds"; |  | ||||||
| 
 | 
 | ||||||
| export const MINIMAL_CROP_SIZE = 10; | export const MINIMAL_CROP_SIZE = 10; | ||||||
| 
 | 
 | ||||||
| @@ -59,7 +63,7 @@ export const cropElement = ( | |||||||
| 
 | 
 | ||||||
|   const rotatedPointer = pointRotateRads( |   const rotatedPointer = pointRotateRads( | ||||||
|     pointFrom(pointerX, pointerY), |     pointFrom(pointerX, pointerY), | ||||||
|     pointFrom(element.x + element.width / 2, element.y + element.height / 2), |     elementCenterPoint(element), | ||||||
|     -element.angle as Radians, |     -element.angle as Radians, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
| @@ -1,25 +1,12 @@ | |||||||
| import { ENV } from "./constants"; |  | ||||||
| import type { BindableProp, BindingProp } from "./element/binding"; |  | ||||||
| import { | import { | ||||||
|   BoundElement, |   arrayToMap, | ||||||
|   BindableElement, |   arrayToObject, | ||||||
|   bindingProperties, |   assertNever, | ||||||
|   updateBoundElements, |   isDevEnv, | ||||||
| } from "./element/binding"; |   isShallowEqual, | ||||||
| import { LinearElementEditor } from "./element/linearElementEditor"; |   isTestEnv, | ||||||
| import type { ElementUpdate } from "./element/mutateElement"; | } from "@excalidraw/common"; | ||||||
| import { mutateElement, newElementWith } from "./element/mutateElement"; | 
 | ||||||
| import { |  | ||||||
|   getBoundTextElementId, |  | ||||||
|   redrawTextBoundingBox, |  | ||||||
| } from "./element/textElement"; |  | ||||||
| import { |  | ||||||
|   hasBoundTextElement, |  | ||||||
|   isBindableElement, |  | ||||||
|   isBoundToContainer, |  | ||||||
|   isImageElement, |  | ||||||
|   isTextElement, |  | ||||||
| } from "./element/typeChecks"; |  | ||||||
| import type { | import type { | ||||||
|   ExcalidrawElement, |   ExcalidrawElement, | ||||||
|   ExcalidrawImageElement, |   ExcalidrawImageElement, | ||||||
| @@ -29,24 +16,44 @@ import type { | |||||||
|   Ordered, |   Ordered, | ||||||
|   OrderedExcalidrawElement, |   OrderedExcalidrawElement, | ||||||
|   SceneElementsMap, |   SceneElementsMap, | ||||||
| } from "./element/types"; | } from "@excalidraw/element/types"; | ||||||
| import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; | 
 | ||||||
| import { getNonDeletedGroupIds } from "./groups"; | import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; | ||||||
| import { getObservedAppState } from "./store"; | 
 | ||||||
| import type { | import type { | ||||||
|   AppState, |   AppState, | ||||||
|   ObservedAppState, |   ObservedAppState, | ||||||
|   ObservedElementsAppState, |   ObservedElementsAppState, | ||||||
|   ObservedStandaloneAppState, |   ObservedStandaloneAppState, | ||||||
| } from "./types"; | } from "@excalidraw/excalidraw/types"; | ||||||
| import type { SubtypeOf, ValueOf } from "./utility-types"; | 
 | ||||||
|  | import { getObservedAppState } from "./store"; | ||||||
|  | 
 | ||||||
| import { | import { | ||||||
|   arrayToMap, |   BoundElement, | ||||||
|   arrayToObject, |   BindableElement, | ||||||
|   assertNever, |   bindingProperties, | ||||||
|   isShallowEqual, |   updateBoundElements, | ||||||
|   toBrandedType, | } from "./binding"; | ||||||
| } from "./utils"; | import { LinearElementEditor } from "./linearElementEditor"; | ||||||
|  | import { mutateElement, newElementWith } from "./mutateElement"; | ||||||
|  | import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement"; | ||||||
|  | import { | ||||||
|  |   hasBoundTextElement, | ||||||
|  |   isBindableElement, | ||||||
|  |   isBoundToContainer, | ||||||
|  |   isTextElement, | ||||||
|  | } from "./typeChecks"; | ||||||
|  | 
 | ||||||
|  | import { getNonDeletedGroupIds } from "./groups"; | ||||||
|  | 
 | ||||||
|  | import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; | ||||||
|  | 
 | ||||||
|  | import { Scene } from "./Scene"; | ||||||
|  | 
 | ||||||
|  | import type { BindableProp, BindingProp } from "./binding"; | ||||||
|  | 
 | ||||||
|  | import type { ElementUpdate } from "./mutateElement"; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Represents the difference between two objects of the same type. |  * Represents the difference between two objects of the same type. | ||||||
| @@ -57,7 +64,7 @@ import { | |||||||
|  * |  * | ||||||
|  * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. |  * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. | ||||||
|  */ |  */ | ||||||
| class Delta<T> { | export class Delta<T> { | ||||||
|   private constructor( |   private constructor( | ||||||
|     public readonly deleted: Partial<T>, |     public readonly deleted: Partial<T>, | ||||||
|     public readonly inserted: Partial<T>, |     public readonly inserted: Partial<T>, | ||||||
| @@ -180,10 +187,12 @@ class Delta<T> { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if ( |     const isDeletedObject = | ||||||
|       typeof deleted[property] === "object" || |       deleted[property] !== null && typeof deleted[property] === "object"; | ||||||
|       typeof inserted[property] === "object" |     const isInsertedObject = | ||||||
|     ) { |       inserted[property] !== null && typeof inserted[property] === "object"; | ||||||
|  | 
 | ||||||
|  |     if (isDeletedObject || isInsertedObject) { | ||||||
|       type RecordLike = Record<string, V | undefined>; |       type RecordLike = Record<string, V | undefined>; | ||||||
| 
 | 
 | ||||||
|       const deletedObject: RecordLike = deleted[property] ?? {}; |       const deletedObject: RecordLike = deleted[property] ?? {}; | ||||||
| @@ -215,6 +224,9 @@ class Delta<T> { | |||||||
|         Reflect.deleteProperty(deleted, property); |         Reflect.deleteProperty(deleted, property); | ||||||
|         Reflect.deleteProperty(inserted, property); |         Reflect.deleteProperty(inserted, property); | ||||||
|       } |       } | ||||||
|  |     } else if (deleted[property] === inserted[property]) { | ||||||
|  |       Reflect.deleteProperty(deleted, property); | ||||||
|  |       Reflect.deleteProperty(inserted, property); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @@ -309,7 +321,7 @@ class Delta<T> { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Returns all the object1 keys that have distinct values. |    * Returns sorted object1 keys that have distinct values. | ||||||
|    */ |    */ | ||||||
|   public static getLeftDifferences<T extends {}>( |   public static getLeftDifferences<T extends {}>( | ||||||
|     object1: T, |     object1: T, | ||||||
| @@ -318,11 +330,11 @@ class Delta<T> { | |||||||
|   ) { |   ) { | ||||||
|     return Array.from( |     return Array.from( | ||||||
|       this.distinctKeysIterator("left", object1, object2, skipShallowCompare), |       this.distinctKeysIterator("left", object1, object2, skipShallowCompare), | ||||||
|     ); |     ).sort(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Returns all the object2 keys that have distinct values. |    * Returns sorted object2 keys that have distinct values. | ||||||
|    */ |    */ | ||||||
|   public static getRightDifferences<T extends {}>( |   public static getRightDifferences<T extends {}>( | ||||||
|     object1: T, |     object1: T, | ||||||
| @@ -331,7 +343,7 @@ class Delta<T> { | |||||||
|   ) { |   ) { | ||||||
|     return Array.from( |     return Array.from( | ||||||
|       this.distinctKeysIterator("right", object1, object2, skipShallowCompare), |       this.distinctKeysIterator("right", object1, object2, skipShallowCompare), | ||||||
|     ); |     ).sort(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @@ -392,51 +404,57 @@ class Delta<T> { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Encapsulates the modifications captured as `Delta`/s. |  * Encapsulates a set of application-level `Delta`s. | ||||||
|  */ |  */ | ||||||
| interface Change<T> { | export interface DeltaContainer<T> { | ||||||
|   /** |   /** | ||||||
|    * Inverses the `Delta`s inside while creating a new `Change`. |    * Inverses the `Delta`s while creating a new `DeltaContainer` instance. | ||||||
|    */ |    */ | ||||||
|   inverse(): Change<T>; |   inverse(): DeltaContainer<T>; | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Applies the `Change` to the previous object. |    * Applies the `Delta`s to the previous object. | ||||||
|    * |    * | ||||||
|    * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. |    * @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change. | ||||||
|    */ |    */ | ||||||
|   applyTo(previous: T, ...options: unknown[]): [T, boolean]; |   applyTo(previous: T, ...options: unknown[]): [T, boolean]; | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Checks whether there are actually `Delta`s. |    * Checks whether all `Delta`s are empty. | ||||||
|    */ |    */ | ||||||
|   isEmpty(): boolean; |   isEmpty(): boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export class AppStateChange implements Change<AppState> { | export class AppStateDelta implements DeltaContainer<AppState> { | ||||||
|   private constructor(private readonly delta: Delta<ObservedAppState>) {} |   private constructor(public readonly delta: Delta<ObservedAppState>) {} | ||||||
| 
 | 
 | ||||||
|   public static calculate<T extends ObservedAppState>( |   public static calculate<T extends ObservedAppState>( | ||||||
|     prevAppState: T, |     prevAppState: T, | ||||||
|     nextAppState: T, |     nextAppState: T, | ||||||
|   ): AppStateChange { |   ): AppStateDelta { | ||||||
|     const delta = Delta.calculate( |     const delta = Delta.calculate( | ||||||
|       prevAppState, |       prevAppState, | ||||||
|       nextAppState, |       nextAppState, | ||||||
|       undefined, |       // making the order of keys in deltas stable for hashing purposes
 | ||||||
|       AppStateChange.postProcess, |       AppStateDelta.orderAppStateKeys, | ||||||
|  |       AppStateDelta.postProcess, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     return new AppStateChange(delta); |     return new AppStateDelta(delta); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta { | ||||||
|  |     const { delta } = appStateDeltaDTO; | ||||||
|  |     return new AppStateDelta(delta); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public static empty() { |   public static empty() { | ||||||
|     return new AppStateChange(Delta.create({}, {})); |     return new AppStateDelta(Delta.create({}, {})); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public inverse(): AppStateChange { |   public inverse(): AppStateDelta { | ||||||
|     const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); |     const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); | ||||||
|     return new AppStateChange(inversedDelta); |     return new AppStateDelta(inversedDelta); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public applyTo( |   public applyTo( | ||||||
| @@ -475,6 +493,7 @@ export class AppStateChange implements Change<AppState> { | |||||||
|               nextElements.get( |               nextElements.get( | ||||||
|                 selectedLinearElementId, |                 selectedLinearElementId, | ||||||
|               ) as NonDeleted<ExcalidrawLinearElement>, |               ) as NonDeleted<ExcalidrawLinearElement>, | ||||||
|  |               nextElements, | ||||||
|             ) |             ) | ||||||
|           : null; |           : null; | ||||||
| 
 | 
 | ||||||
| @@ -484,6 +503,7 @@ export class AppStateChange implements Change<AppState> { | |||||||
|               nextElements.get( |               nextElements.get( | ||||||
|                 editingLinearElementId, |                 editingLinearElementId, | ||||||
|               ) as NonDeleted<ExcalidrawLinearElement>, |               ) as NonDeleted<ExcalidrawLinearElement>, | ||||||
|  |               nextElements, | ||||||
|             ) |             ) | ||||||
|           : null; |           : null; | ||||||
| 
 | 
 | ||||||
| @@ -513,7 +533,7 @@ export class AppStateChange implements Change<AppState> { | |||||||
|       // shouldn't really happen, but just in case
 |       // shouldn't really happen, but just in case
 | ||||||
|       console.error(`Couldn't apply appstate change`, e); |       console.error(`Couldn't apply appstate change`, e); | ||||||
| 
 | 
 | ||||||
|       if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { |       if (isTestEnv() || isDevEnv()) { | ||||||
|         throw e; |         throw e; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @@ -525,40 +545,6 @@ export class AppStateChange implements Change<AppState> { | |||||||
|     return Delta.isEmpty(this.delta); |     return Delta.isEmpty(this.delta); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |  | ||||||
|    * It is necessary to post process the partials in case of reference values, |  | ||||||
|    * for which we need to calculate the real diff between `deleted` and `inserted`. |  | ||||||
|    */ |  | ||||||
|   private static postProcess<T extends ObservedAppState>( |  | ||||||
|     deleted: Partial<T>, |  | ||||||
|     inserted: Partial<T>, |  | ||||||
|   ): [Partial<T>, Partial<T>] { |  | ||||||
|     try { |  | ||||||
|       Delta.diffObjects( |  | ||||||
|         deleted, |  | ||||||
|         inserted, |  | ||||||
|         "selectedElementIds", |  | ||||||
|         // ts language server has a bit trouble resolving this, so we are giving it a little push
 |  | ||||||
|         (_) => true as ValueOf<T["selectedElementIds"]>, |  | ||||||
|       ); |  | ||||||
|       Delta.diffObjects( |  | ||||||
|         deleted, |  | ||||||
|         inserted, |  | ||||||
|         "selectedGroupIds", |  | ||||||
|         (prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>, |  | ||||||
|       ); |  | ||||||
|     } catch (e) { |  | ||||||
|       // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
 |  | ||||||
|       console.error(`Couldn't postprocess appstate change deltas.`); |  | ||||||
| 
 |  | ||||||
|       if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { |  | ||||||
|         throw e; |  | ||||||
|       } |  | ||||||
|     } finally { |  | ||||||
|       return [deleted, inserted]; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |   /** | ||||||
|    * Mutates `nextAppState` be filtering out state related to deleted elements. |    * Mutates `nextAppState` be filtering out state related to deleted elements. | ||||||
|    * |    * | ||||||
| @@ -575,13 +561,13 @@ export class AppStateChange implements Change<AppState> { | |||||||
|     const nextObservedAppState = getObservedAppState(nextAppState); |     const nextObservedAppState = getObservedAppState(nextAppState); | ||||||
| 
 | 
 | ||||||
|     const containsStandaloneDifference = Delta.isRightDifferent( |     const containsStandaloneDifference = Delta.isRightDifferent( | ||||||
|       AppStateChange.stripElementsProps(prevObservedAppState), |       AppStateDelta.stripElementsProps(prevObservedAppState), | ||||||
|       AppStateChange.stripElementsProps(nextObservedAppState), |       AppStateDelta.stripElementsProps(nextObservedAppState), | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     const containsElementsDifference = Delta.isRightDifferent( |     const containsElementsDifference = Delta.isRightDifferent( | ||||||
|       AppStateChange.stripStandaloneProps(prevObservedAppState), |       AppStateDelta.stripStandaloneProps(prevObservedAppState), | ||||||
|       AppStateChange.stripStandaloneProps(nextObservedAppState), |       AppStateDelta.stripStandaloneProps(nextObservedAppState), | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     if (!containsStandaloneDifference && !containsElementsDifference) { |     if (!containsStandaloneDifference && !containsElementsDifference) { | ||||||
| @@ -596,8 +582,8 @@ export class AppStateChange implements Change<AppState> { | |||||||
|     if (containsElementsDifference) { |     if (containsElementsDifference) { | ||||||
|       // filter invisible changes on each iteration
 |       // filter invisible changes on each iteration
 | ||||||
|       const changedElementsProps = Delta.getRightDifferences( |       const changedElementsProps = Delta.getRightDifferences( | ||||||
|         AppStateChange.stripStandaloneProps(prevObservedAppState), |         AppStateDelta.stripStandaloneProps(prevObservedAppState), | ||||||
|         AppStateChange.stripStandaloneProps(nextObservedAppState), |         AppStateDelta.stripStandaloneProps(nextObservedAppState), | ||||||
|       ) as Array<keyof ObservedElementsAppState>; |       ) as Array<keyof ObservedElementsAppState>; | ||||||
| 
 | 
 | ||||||
|       let nonDeletedGroupIds = new Set<string>(); |       let nonDeletedGroupIds = new Set<string>(); | ||||||
| @@ -614,7 +600,7 @@ export class AppStateChange implements Change<AppState> { | |||||||
|       for (const key of changedElementsProps) { |       for (const key of changedElementsProps) { | ||||||
|         switch (key) { |         switch (key) { | ||||||
|           case "selectedElementIds": |           case "selectedElementIds": | ||||||
|             nextAppState[key] = AppStateChange.filterSelectedElements( |             nextAppState[key] = AppStateDelta.filterSelectedElements( | ||||||
|               nextAppState[key], |               nextAppState[key], | ||||||
|               nextElements, |               nextElements, | ||||||
|               visibleDifferenceFlag, |               visibleDifferenceFlag, | ||||||
| @@ -622,7 +608,7 @@ export class AppStateChange implements Change<AppState> { | |||||||
| 
 | 
 | ||||||
|             break; |             break; | ||||||
|           case "selectedGroupIds": |           case "selectedGroupIds": | ||||||
|             nextAppState[key] = AppStateChange.filterSelectedGroups( |             nextAppState[key] = AppStateDelta.filterSelectedGroups( | ||||||
|               nextAppState[key], |               nextAppState[key], | ||||||
|               nonDeletedGroupIds, |               nonDeletedGroupIds, | ||||||
|               visibleDifferenceFlag, |               visibleDifferenceFlag, | ||||||
| @@ -658,7 +644,7 @@ export class AppStateChange implements Change<AppState> { | |||||||
|             break; |             break; | ||||||
|           case "selectedLinearElementId": |           case "selectedLinearElementId": | ||||||
|           case "editingLinearElementId": |           case "editingLinearElementId": | ||||||
|             const appStateKey = AppStateChange.convertToAppStateKey(key); |             const appStateKey = AppStateDelta.convertToAppStateKey(key); | ||||||
|             const linearElement = nextAppState[appStateKey]; |             const linearElement = nextAppState[appStateKey]; | ||||||
| 
 | 
 | ||||||
|             if (!linearElement) { |             if (!linearElement) { | ||||||
| @@ -677,6 +663,24 @@ export class AppStateChange implements Change<AppState> { | |||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             break; |             break; | ||||||
|  |           case "lockedMultiSelections": { | ||||||
|  |             const prevLockedUnits = prevAppState[key] || {}; | ||||||
|  |             const nextLockedUnits = nextAppState[key] || {}; | ||||||
|  | 
 | ||||||
|  |             if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { | ||||||
|  |               visibleDifferenceFlag.value = true; | ||||||
|  |             } | ||||||
|  |             break; | ||||||
|  |           } | ||||||
|  |           case "activeLockedId": { | ||||||
|  |             const prevHitLockedId = prevAppState[key] || null; | ||||||
|  |             const nextHitLockedId = nextAppState[key] || null; | ||||||
|  | 
 | ||||||
|  |             if (prevHitLockedId !== nextHitLockedId) { | ||||||
|  |               visibleDifferenceFlag.value = true; | ||||||
|  |             } | ||||||
|  |             break; | ||||||
|  |           } | ||||||
|           default: { |           default: { | ||||||
|             assertNever( |             assertNever( | ||||||
|               key, |               key, | ||||||
| @@ -772,6 +776,8 @@ export class AppStateChange implements Change<AppState> { | |||||||
|       editingLinearElementId, |       editingLinearElementId, | ||||||
|       selectedLinearElementId, |       selectedLinearElementId, | ||||||
|       croppingElementId, |       croppingElementId, | ||||||
|  |       lockedMultiSelections, | ||||||
|  |       activeLockedId, | ||||||
|       ...standaloneProps |       ...standaloneProps | ||||||
|     } = delta as ObservedAppState; |     } = delta as ObservedAppState; | ||||||
| 
 | 
 | ||||||
| @@ -793,6 +799,63 @@ export class AppStateChange implements Change<AppState> { | |||||||
|       ObservedElementsAppState |       ObservedElementsAppState | ||||||
|     >; |     >; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * It is necessary to post process the partials in case of reference values, | ||||||
|  |    * for which we need to calculate the real diff between `deleted` and `inserted`. | ||||||
|  |    */ | ||||||
|  |   private static postProcess<T extends ObservedAppState>( | ||||||
|  |     deleted: Partial<T>, | ||||||
|  |     inserted: Partial<T>, | ||||||
|  |   ): [Partial<T>, Partial<T>] { | ||||||
|  |     try { | ||||||
|  |       Delta.diffObjects( | ||||||
|  |         deleted, | ||||||
|  |         inserted, | ||||||
|  |         "selectedElementIds", | ||||||
|  |         // ts language server has a bit trouble resolving this, so we are giving it a little push
 | ||||||
|  |         (_) => true as ValueOf<T["selectedElementIds"]>, | ||||||
|  |       ); | ||||||
|  |       Delta.diffObjects( | ||||||
|  |         deleted, | ||||||
|  |         inserted, | ||||||
|  |         "selectedGroupIds", | ||||||
|  |         (prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>, | ||||||
|  |       ); | ||||||
|  |       Delta.diffObjects( | ||||||
|  |         deleted, | ||||||
|  |         inserted, | ||||||
|  |         "lockedMultiSelections", | ||||||
|  |         (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>, | ||||||
|  |       ); | ||||||
|  |       Delta.diffObjects( | ||||||
|  |         deleted, | ||||||
|  |         inserted, | ||||||
|  |         "activeLockedId", | ||||||
|  |         (prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>, | ||||||
|  |       ); | ||||||
|  |     } catch (e) { | ||||||
|  |       // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
 | ||||||
|  |       console.error(`Couldn't postprocess appstate change deltas.`); | ||||||
|  | 
 | ||||||
|  |       if (isTestEnv() || isDevEnv()) { | ||||||
|  |         throw e; | ||||||
|  |       } | ||||||
|  |     } finally { | ||||||
|  |       return [deleted, inserted]; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static orderAppStateKeys(partial: Partial<ObservedAppState>) { | ||||||
|  |     const orderedPartial: { [key: string]: unknown } = {}; | ||||||
|  | 
 | ||||||
|  |     for (const key of Object.keys(partial).sort()) { | ||||||
|  |       // relying on insertion order
 | ||||||
|  |       orderedPartial[key] = partial[key as keyof ObservedAppState]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return orderedPartial as Partial<ObservedAppState>; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< | type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< | ||||||
| @@ -804,50 +867,63 @@ type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< | |||||||
|  * Elements change is a low level primitive to capture a change between two sets of elements. |  * Elements change is a low level primitive to capture a change between two sets of elements. | ||||||
|  * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. |  * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. | ||||||
|  */ |  */ | ||||||
| export class ElementsChange implements Change<SceneElementsMap> { | export class ElementsDelta implements DeltaContainer<SceneElementsMap> { | ||||||
|   private constructor( |   private constructor( | ||||||
|     private readonly added: Map<string, Delta<ElementPartial>>, |     public readonly added: Record<string, Delta<ElementPartial>>, | ||||||
|     private readonly removed: Map<string, Delta<ElementPartial>>, |     public readonly removed: Record<string, Delta<ElementPartial>>, | ||||||
|     private readonly updated: Map<string, Delta<ElementPartial>>, |     public readonly updated: Record<string, Delta<ElementPartial>>, | ||||||
|   ) {} |   ) {} | ||||||
| 
 | 
 | ||||||
|   public static create( |   public static create( | ||||||
|     added: Map<string, Delta<ElementPartial>>, |     added: Record<string, Delta<ElementPartial>>, | ||||||
|     removed: Map<string, Delta<ElementPartial>>, |     removed: Record<string, Delta<ElementPartial>>, | ||||||
|     updated: Map<string, Delta<ElementPartial>>, |     updated: Record<string, Delta<ElementPartial>>, | ||||||
|     options = { shouldRedistribute: false }, |     options: { | ||||||
|  |       shouldRedistribute: boolean; | ||||||
|  |     } = { | ||||||
|  |       shouldRedistribute: false, | ||||||
|  |     }, | ||||||
|   ) { |   ) { | ||||||
|     let change: ElementsChange; |     let delta: ElementsDelta; | ||||||
| 
 | 
 | ||||||
|     if (options.shouldRedistribute) { |     if (options.shouldRedistribute) { | ||||||
|       const nextAdded = new Map<string, Delta<ElementPartial>>(); |       const nextAdded: Record<string, Delta<ElementPartial>> = {}; | ||||||
|       const nextRemoved = new Map<string, Delta<ElementPartial>>(); |       const nextRemoved: Record<string, Delta<ElementPartial>> = {}; | ||||||
|       const nextUpdated = new Map<string, Delta<ElementPartial>>(); |       const nextUpdated: Record<string, Delta<ElementPartial>> = {}; | ||||||
| 
 | 
 | ||||||
|       const deltas = [...added, ...removed, ...updated]; |       const deltas = [ | ||||||
|  |         ...Object.entries(added), | ||||||
|  |         ...Object.entries(removed), | ||||||
|  |         ...Object.entries(updated), | ||||||
|  |       ]; | ||||||
| 
 | 
 | ||||||
|       for (const [id, delta] of deltas) { |       for (const [id, delta] of deltas) { | ||||||
|         if (this.satisfiesAddition(delta)) { |         if (this.satisfiesAddition(delta)) { | ||||||
|           nextAdded.set(id, delta); |           nextAdded[id] = delta; | ||||||
|         } else if (this.satisfiesRemoval(delta)) { |         } else if (this.satisfiesRemoval(delta)) { | ||||||
|           nextRemoved.set(id, delta); |           nextRemoved[id] = delta; | ||||||
|         } else { |         } else { | ||||||
|           nextUpdated.set(id, delta); |           nextUpdated[id] = delta; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); |       delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated); | ||||||
|     } else { |     } else { | ||||||
|       change = new ElementsChange(added, removed, updated); |       delta = new ElementsDelta(added, removed, updated); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { |     if (isTestEnv() || isDevEnv()) { | ||||||
|       ElementsChange.validate(change, "added", this.satisfiesAddition); |       ElementsDelta.validate(delta, "added", this.satisfiesAddition); | ||||||
|       ElementsChange.validate(change, "removed", this.satisfiesRemoval); |       ElementsDelta.validate(delta, "removed", this.satisfiesRemoval); | ||||||
|       ElementsChange.validate(change, "updated", this.satisfiesUpdate); |       ElementsDelta.validate(delta, "updated", this.satisfiesUpdate); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return change; |     return delta; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta { | ||||||
|  |     const { added, removed, updated } = elementsDeltaDTO; | ||||||
|  |     return ElementsDelta.create(added, removed, updated); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private static satisfiesAddition = ({ |   private static satisfiesAddition = ({ | ||||||
| @@ -869,17 +945,17 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|   }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted; |   }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted; | ||||||
| 
 | 
 | ||||||
|   private static validate( |   private static validate( | ||||||
|     change: ElementsChange, |     elementsDelta: ElementsDelta, | ||||||
|     type: "added" | "removed" | "updated", |     type: "added" | "removed" | "updated", | ||||||
|     satifies: (delta: Delta<ElementPartial>) => boolean, |     satifies: (delta: Delta<ElementPartial>) => boolean, | ||||||
|   ) { |   ) { | ||||||
|     for (const [id, delta] of change[type].entries()) { |     for (const [id, delta] of Object.entries(elementsDelta[type])) { | ||||||
|       if (!satifies(delta)) { |       if (!satifies(delta)) { | ||||||
|         console.error( |         console.error( | ||||||
|           `Broken invariant for "${type}" delta, element "${id}", delta:`, |           `Broken invariant for "${type}" delta, element "${id}", delta:`, | ||||||
|           delta, |           delta, | ||||||
|         ); |         ); | ||||||
|         throw new Error(`ElementsChange invariant broken for element "${id}".`); |         throw new Error(`ElementsDelta invariant broken for element "${id}".`); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -890,19 +966,19 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|    * @param prevElements - Map representing the previous state of elements. |    * @param prevElements - Map representing the previous state of elements. | ||||||
|    * @param nextElements - Map representing the next state of elements. |    * @param nextElements - Map representing the next state of elements. | ||||||
|    * |    * | ||||||
|    * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. |    * @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements. | ||||||
|    */ |    */ | ||||||
|   public static calculate<T extends OrderedExcalidrawElement>( |   public static calculate<T extends OrderedExcalidrawElement>( | ||||||
|     prevElements: Map<string, T>, |     prevElements: Map<string, T>, | ||||||
|     nextElements: Map<string, T>, |     nextElements: Map<string, T>, | ||||||
|   ): ElementsChange { |   ): ElementsDelta { | ||||||
|     if (prevElements === nextElements) { |     if (prevElements === nextElements) { | ||||||
|       return ElementsChange.empty(); |       return ElementsDelta.empty(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const added = new Map<string, Delta<ElementPartial>>(); |     const added: Record<string, Delta<ElementPartial>> = {}; | ||||||
|     const removed = new Map<string, Delta<ElementPartial>>(); |     const removed: Record<string, Delta<ElementPartial>> = {}; | ||||||
|     const updated = new Map<string, Delta<ElementPartial>>(); |     const updated: Record<string, Delta<ElementPartial>> = {}; | ||||||
| 
 | 
 | ||||||
|     // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
 |     // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
 | ||||||
|     for (const prevElement of prevElements.values()) { |     for (const prevElement of prevElements.values()) { | ||||||
| @@ -915,10 +991,10 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         const delta = Delta.create( |         const delta = Delta.create( | ||||||
|           deleted, |           deleted, | ||||||
|           inserted, |           inserted, | ||||||
|           ElementsChange.stripIrrelevantProps, |           ElementsDelta.stripIrrelevantProps, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         removed.set(prevElement.id, delta); |         removed[prevElement.id] = delta; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @@ -935,10 +1011,10 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         const delta = Delta.create( |         const delta = Delta.create( | ||||||
|           deleted, |           deleted, | ||||||
|           inserted, |           inserted, | ||||||
|           ElementsChange.stripIrrelevantProps, |           ElementsDelta.stripIrrelevantProps, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         added.set(nextElement.id, delta); |         added[nextElement.id] = delta; | ||||||
| 
 | 
 | ||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| @@ -947,8 +1023,8 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         const delta = Delta.calculate<ElementPartial>( |         const delta = Delta.calculate<ElementPartial>( | ||||||
|           prevElement, |           prevElement, | ||||||
|           nextElement, |           nextElement, | ||||||
|           ElementsChange.stripIrrelevantProps, |           ElementsDelta.stripIrrelevantProps, | ||||||
|           ElementsChange.postProcess, |           ElementsDelta.postProcess, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         if ( |         if ( | ||||||
| @@ -959,9 +1035,9 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         ) { |         ) { | ||||||
|           // notice that other props could have been updated as well
 |           // notice that other props could have been updated as well
 | ||||||
|           if (prevElement.isDeleted && !nextElement.isDeleted) { |           if (prevElement.isDeleted && !nextElement.isDeleted) { | ||||||
|             added.set(nextElement.id, delta); |             added[nextElement.id] = delta; | ||||||
|           } else { |           } else { | ||||||
|             removed.set(nextElement.id, delta); |             removed[nextElement.id] = delta; | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           continue; |           continue; | ||||||
| @@ -969,24 +1045,24 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
| 
 | 
 | ||||||
|         // making sure there are at least some changes
 |         // making sure there are at least some changes
 | ||||||
|         if (!Delta.isEmpty(delta)) { |         if (!Delta.isEmpty(delta)) { | ||||||
|           updated.set(nextElement.id, delta); |           updated[nextElement.id] = delta; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return ElementsChange.create(added, removed, updated); |     return ElementsDelta.create(added, removed, updated); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public static empty() { |   public static empty() { | ||||||
|     return ElementsChange.create(new Map(), new Map(), new Map()); |     return ElementsDelta.create({}, {}, {}); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public inverse(): ElementsChange { |   public inverse(): ElementsDelta { | ||||||
|     const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => { |     const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { | ||||||
|       const inversedDeltas = new Map<string, Delta<ElementPartial>>(); |       const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; | ||||||
| 
 | 
 | ||||||
|       for (const [id, delta] of deltas.entries()) { |       for (const [id, delta] of Object.entries(deltas)) { | ||||||
|         inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); |         inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       return inversedDeltas; |       return inversedDeltas; | ||||||
| @@ -997,14 +1073,14 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|     const updated = inverseInternal(this.updated); |     const updated = inverseInternal(this.updated); | ||||||
| 
 | 
 | ||||||
|     // notice we inverse removed with added not to break the invariants
 |     // notice we inverse removed with added not to break the invariants
 | ||||||
|     return ElementsChange.create(removed, added, updated); |     return ElementsDelta.create(removed, added, updated); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public isEmpty(): boolean { |   public isEmpty(): boolean { | ||||||
|     return ( |     return ( | ||||||
|       this.added.size === 0 && |       Object.keys(this.added).length === 0 && | ||||||
|       this.removed.size === 0 && |       Object.keys(this.removed).length === 0 && | ||||||
|       this.updated.size === 0 |       Object.keys(this.updated).length === 0 | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @@ -1015,7 +1091,10 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|    * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated |    * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated | ||||||
|    * @returns new instance with modified delta/s |    * @returns new instance with modified delta/s | ||||||
|    */ |    */ | ||||||
|   public applyLatestChanges(elements: SceneElementsMap): ElementsChange { |   public applyLatestChanges( | ||||||
|  |     elements: SceneElementsMap, | ||||||
|  |     modifierOptions: "deleted" | "inserted", | ||||||
|  |   ): ElementsDelta { | ||||||
|     const modifier = |     const modifier = | ||||||
|       (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { |       (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { | ||||||
|         const latestPartial: { [key: string]: unknown } = {}; |         const latestPartial: { [key: string]: unknown } = {}; | ||||||
| @@ -1036,11 +1115,11 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|       }; |       }; | ||||||
| 
 | 
 | ||||||
|     const applyLatestChangesInternal = ( |     const applyLatestChangesInternal = ( | ||||||
|       deltas: Map<string, Delta<ElementPartial>>, |       deltas: Record<string, Delta<ElementPartial>>, | ||||||
|     ) => { |     ) => { | ||||||
|       const modifiedDeltas = new Map<string, Delta<ElementPartial>>(); |       const modifiedDeltas: Record<string, Delta<ElementPartial>> = {}; | ||||||
| 
 | 
 | ||||||
|       for (const [id, delta] of deltas.entries()) { |       for (const [id, delta] of Object.entries(deltas)) { | ||||||
|         const existingElement = elements.get(id); |         const existingElement = elements.get(id); | ||||||
| 
 | 
 | ||||||
|         if (existingElement) { |         if (existingElement) { | ||||||
| @@ -1048,12 +1127,12 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|             delta.deleted, |             delta.deleted, | ||||||
|             delta.inserted, |             delta.inserted, | ||||||
|             modifier(existingElement), |             modifier(existingElement), | ||||||
|             "inserted", |             modifierOptions, | ||||||
|           ); |           ); | ||||||
| 
 | 
 | ||||||
|           modifiedDeltas.set(id, modifiedDelta); |           modifiedDeltas[id] = modifiedDelta; | ||||||
|         } else { |         } else { | ||||||
|           modifiedDeltas.set(id, delta); |           modifiedDeltas[id] = delta; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @@ -1064,16 +1143,16 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|     const removed = applyLatestChangesInternal(this.removed); |     const removed = applyLatestChangesInternal(this.removed); | ||||||
|     const updated = applyLatestChangesInternal(this.updated); |     const updated = applyLatestChangesInternal(this.updated); | ||||||
| 
 | 
 | ||||||
|     return ElementsChange.create(added, removed, updated, { |     return ElementsDelta.create(added, removed, updated, { | ||||||
|       shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
 |       shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
 | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public applyTo( |   public applyTo( | ||||||
|     elements: SceneElementsMap, |     elements: SceneElementsMap, | ||||||
|     snapshot: Map<string, OrderedExcalidrawElement>, |     elementsSnapshot: Map<string, OrderedExcalidrawElement>, | ||||||
|   ): [SceneElementsMap, boolean] { |   ): [SceneElementsMap, boolean] { | ||||||
|     let nextElements = toBrandedType<SceneElementsMap>(new Map(elements)); |     let nextElements = new Map(elements) as SceneElementsMap; | ||||||
|     let changedElements: Map<string, OrderedExcalidrawElement>; |     let changedElements: Map<string, OrderedExcalidrawElement>; | ||||||
| 
 | 
 | ||||||
|     const flags = { |     const flags = { | ||||||
| @@ -1083,15 +1162,15 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
| 
 | 
 | ||||||
|     // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
 |     // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
 | ||||||
|     try { |     try { | ||||||
|       const applyDeltas = ElementsChange.createApplier( |       const applyDeltas = ElementsDelta.createApplier( | ||||||
|         nextElements, |         nextElements, | ||||||
|         snapshot, |         elementsSnapshot, | ||||||
|         flags, |         flags, | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       const addedElements = applyDeltas(this.added); |       const addedElements = applyDeltas("added", this.added); | ||||||
|       const removedElements = applyDeltas(this.removed); |       const removedElements = applyDeltas("removed", this.removed); | ||||||
|       const updatedElements = applyDeltas(this.updated); |       const updatedElements = applyDeltas("updated", this.updated); | ||||||
| 
 | 
 | ||||||
|       const affectedElements = this.resolveConflicts(elements, nextElements); |       const affectedElements = this.resolveConflicts(elements, nextElements); | ||||||
| 
 | 
 | ||||||
| @@ -1103,9 +1182,9 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         ...affectedElements, |         ...affectedElements, | ||||||
|       ]); |       ]); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       console.error(`Couldn't apply elements change`, e); |       console.error(`Couldn't apply elements delta`, e); | ||||||
| 
 | 
 | ||||||
|       if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { |       if (isTestEnv() || isDevEnv()) { | ||||||
|         throw e; |         throw e; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @@ -1117,26 +1196,29 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     try { |     try { | ||||||
|       // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
 |  | ||||||
|       ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); |  | ||||||
| 
 |  | ||||||
|       // the following reorder performs also mutations, but only on new instances of changed elements
 |       // 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)
 |       // (unless something goes really bad and it fallbacks to fixing all invalid indices)
 | ||||||
|       nextElements = ElementsChange.reorderElements( |       nextElements = ElementsDelta.reorderElements( | ||||||
|         nextElements, |         nextElements, | ||||||
|         changedElements, |         changedElements, | ||||||
|         flags, |         flags, | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|  |       // we don't have an up-to-date scene, as we can be just in the middle of applying history entry
 | ||||||
|  |       // we also don't have a scene on the server
 | ||||||
|  |       // so we are creating a temp scene just to query and mutate elements
 | ||||||
|  |       const tempScene = new Scene(nextElements); | ||||||
|  | 
 | ||||||
|  |       ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements); | ||||||
|       // Need ordered nextElements to avoid z-index binding issues
 |       // Need ordered nextElements to avoid z-index binding issues
 | ||||||
|       ElementsChange.redrawBoundArrows(nextElements, changedElements); |       ElementsDelta.redrawBoundArrows(tempScene, changedElements); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       console.error( |       console.error( | ||||||
|         `Couldn't mutate elements after applying elements change`, |         `Couldn't mutate elements after applying elements change`, | ||||||
|         e, |         e, | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { |       if (isTestEnv() || isDevEnv()) { | ||||||
|         throw e; |         throw e; | ||||||
|       } |       } | ||||||
|     } finally { |     } finally { | ||||||
| @@ -1144,26 +1226,31 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private static createApplier = ( |   private static createApplier = | ||||||
|  |     ( | ||||||
|       nextElements: SceneElementsMap, |       nextElements: SceneElementsMap, | ||||||
|       snapshot: Map<string, OrderedExcalidrawElement>, |       snapshot: Map<string, OrderedExcalidrawElement>, | ||||||
|       flags: { |       flags: { | ||||||
|         containsVisibleDifference: boolean; |         containsVisibleDifference: boolean; | ||||||
|         containsZindexDifference: boolean; |         containsZindexDifference: boolean; | ||||||
|       }, |       }, | ||||||
|  |     ) => | ||||||
|  |     ( | ||||||
|  |       type: "added" | "removed" | "updated", | ||||||
|  |       deltas: Record<string, Delta<ElementPartial>>, | ||||||
|     ) => { |     ) => { | ||||||
|     const getElement = ElementsChange.createGetter( |       const getElement = ElementsDelta.createGetter( | ||||||
|  |         type, | ||||||
|         nextElements, |         nextElements, | ||||||
|         snapshot, |         snapshot, | ||||||
|         flags, |         flags, | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|     return (deltas: Map<string, Delta<ElementPartial>>) => |       return Object.entries(deltas).reduce((acc, [id, delta]) => { | ||||||
|       Array.from(deltas.entries()).reduce((acc, [id, delta]) => { |  | ||||||
|         const element = getElement(id, delta.inserted); |         const element = getElement(id, delta.inserted); | ||||||
| 
 | 
 | ||||||
|         if (element) { |         if (element) { | ||||||
|           const newElement = ElementsChange.applyDelta(element, delta, flags); |           const newElement = ElementsDelta.applyDelta(element, delta, flags); | ||||||
|           nextElements.set(newElement.id, newElement); |           nextElements.set(newElement.id, newElement); | ||||||
|           acc.set(newElement.id, newElement); |           acc.set(newElement.id, newElement); | ||||||
|         } |         } | ||||||
| @@ -1174,6 +1261,7 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
| 
 | 
 | ||||||
|   private static createGetter = |   private static createGetter = | ||||||
|     ( |     ( | ||||||
|  |       type: "added" | "removed" | "updated", | ||||||
|       elements: SceneElementsMap, |       elements: SceneElementsMap, | ||||||
|       snapshot: Map<string, OrderedExcalidrawElement>, |       snapshot: Map<string, OrderedExcalidrawElement>, | ||||||
|       flags: { |       flags: { | ||||||
| @@ -1199,6 +1287,14 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|           ) { |           ) { | ||||||
|             flags.containsVisibleDifference = true; |             flags.containsVisibleDifference = true; | ||||||
|           } |           } | ||||||
|  |         } else { | ||||||
|  |           // not in elements, not in snapshot? element might have been added remotely!
 | ||||||
|  |           element = newElementWith( | ||||||
|  |             { id, version: 1 } as OrderedExcalidrawElement, | ||||||
|  |             { | ||||||
|  |               ...partial, | ||||||
|  |             }, | ||||||
|  |           ); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @@ -1235,7 +1331,8 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (isImageElement(element)) { |     // TODO: this looks wrong, shouldn't be here
 | ||||||
|  |     if (element.type === "image") { | ||||||
|       const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>; |       const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>; | ||||||
|       // we want to override `crop` only if modified so that we don't reset
 |       // we want to override `crop` only if modified so that we don't reset
 | ||||||
|       // when undoing/redoing unrelated change
 |       // when undoing/redoing unrelated change
 | ||||||
| @@ -1248,10 +1345,12 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!flags.containsVisibleDifference) { |     if (!flags.containsVisibleDifference) { | ||||||
|       // strip away fractional as even if it would be different, it doesn't have to result in visible change
 |       // strip away fractional index, as even if it would be different, it doesn't have to result in visible change
 | ||||||
|       const { index, ...rest } = directlyApplicablePartial; |       const { index, ...rest } = directlyApplicablePartial; | ||||||
|       const containsVisibleDifference = |       const containsVisibleDifference = ElementsDelta.checkForVisibleDifference( | ||||||
|         ElementsChange.checkForVisibleDifference(element, rest); |         element, | ||||||
|  |         rest, | ||||||
|  |       ); | ||||||
| 
 | 
 | ||||||
|       flags.containsVisibleDifference = containsVisibleDifference; |       flags.containsVisibleDifference = containsVisibleDifference; | ||||||
|     } |     } | ||||||
| @@ -1294,6 +1393,8 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|    * Resolves conflicts for all previously added, removed and updated elements. |    * Resolves conflicts for all previously added, removed and updated elements. | ||||||
|    * Updates the previous deltas with all the changes after conflict resolution. |    * Updates the previous deltas with all the changes after conflict resolution. | ||||||
|    * |    * | ||||||
|  |    * // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
 | ||||||
|  |    * | ||||||
|    * @returns all elements affected by the conflict resolution |    * @returns all elements affected by the conflict resolution | ||||||
|    */ |    */ | ||||||
|   private resolveConflicts( |   private resolveConflicts( | ||||||
| @@ -1322,6 +1423,7 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|       } else { |       } else { | ||||||
|         affectedElement = mutateElement( |         affectedElement = mutateElement( | ||||||
|           nextElement, |           nextElement, | ||||||
|  |           nextElements, | ||||||
|           updates as ElementUpdate<OrderedExcalidrawElement>, |           updates as ElementUpdate<OrderedExcalidrawElement>, | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
| @@ -1331,17 +1433,18 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
 |     // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
 | ||||||
|     for (const [id] of this.removed) { |     for (const id of Object.keys(this.removed)) { | ||||||
|       ElementsChange.unbindAffected(prevElements, nextElements, id, updater); |       ElementsDelta.unbindAffected(prevElements, nextElements, id, updater); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
 |     // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
 | ||||||
|     for (const [id] of this.added) { |     for (const id of Object.keys(this.added)) { | ||||||
|       ElementsChange.rebindAffected(prevElements, nextElements, id, updater); |       ElementsDelta.rebindAffected(prevElements, nextElements, id, updater); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // updated delta is affecting the binding only in case it contains changed binding or bindable property
 |     // updated delta is affecting the binding only in case it contains changed binding or bindable property
 | ||||||
|     for (const [id] of Array.from(this.updated).filter(([_, delta]) => |     for (const [id] of Array.from(Object.entries(this.updated)).filter( | ||||||
|  |       ([_, delta]) => | ||||||
|         Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => |         Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => | ||||||
|           bindingProperties.has(prop as BindingProp | BindableProp), |           bindingProperties.has(prop as BindingProp | BindableProp), | ||||||
|         ), |         ), | ||||||
| @@ -1352,7 +1455,7 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       ElementsChange.rebindAffected(prevElements, nextElements, id, updater); |       ElementsDelta.rebindAffected(prevElements, nextElements, id, updater); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // filter only previous elements, which were now affected
 |     // filter only previous elements, which were now affected
 | ||||||
| @@ -1362,21 +1465,21 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
| 
 | 
 | ||||||
|     // calculate complete deltas for affected elements, and assign them back to all the deltas
 |     // calculate complete deltas for affected elements, and assign them back to all the deltas
 | ||||||
|     // technically we could do better here if perf. would become an issue
 |     // technically we could do better here if perf. would become an issue
 | ||||||
|     const { added, removed, updated } = ElementsChange.calculate( |     const { added, removed, updated } = ElementsDelta.calculate( | ||||||
|       prevAffectedElements, |       prevAffectedElements, | ||||||
|       nextAffectedElements, |       nextAffectedElements, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     for (const [id, delta] of added) { |     for (const [id, delta] of Object.entries(added)) { | ||||||
|       this.added.set(id, delta); |       this.added[id] = delta; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     for (const [id, delta] of removed) { |     for (const [id, delta] of Object.entries(removed)) { | ||||||
|       this.removed.set(id, delta); |       this.removed[id] = delta; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     for (const [id, delta] of updated) { |     for (const [id, delta] of Object.entries(updated)) { | ||||||
|       this.updated.set(id, delta); |       this.updated[id] = delta; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return nextAffectedElements; |     return nextAffectedElements; | ||||||
| @@ -1441,9 +1544,10 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private static redrawTextBoundingBoxes( |   private static redrawTextBoundingBoxes( | ||||||
|     elements: SceneElementsMap, |     scene: Scene, | ||||||
|     changed: Map<string, OrderedExcalidrawElement>, |     changed: Map<string, OrderedExcalidrawElement>, | ||||||
|   ) { |   ) { | ||||||
|  |     const elements = scene.getNonDeletedElementsMap(); | ||||||
|     const boxesToRedraw = new Map< |     const boxesToRedraw = new Map< | ||||||
|       string, |       string, | ||||||
|       { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } |       { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } | ||||||
| @@ -1483,17 +1587,17 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       redrawTextBoundingBox(boundText, container, elements, false); |       redrawTextBoundingBox(boundText, container, scene); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private static redrawBoundArrows( |   private static redrawBoundArrows( | ||||||
|     elements: SceneElementsMap, |     scene: Scene, | ||||||
|     changed: Map<string, OrderedExcalidrawElement>, |     changed: Map<string, OrderedExcalidrawElement>, | ||||||
|   ) { |   ) { | ||||||
|     for (const element of changed.values()) { |     for (const element of changed.values()) { | ||||||
|       if (!element.isDeleted && isBindableElement(element)) { |       if (!element.isDeleted && isBindableElement(element)) { | ||||||
|         updateBoundElements(element, elements, { |         updateBoundElements(element, scene, { | ||||||
|           changedElements: changed, |           changedElements: changed, | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
| @@ -1548,9 +1652,9 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|       Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); |       Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
 |       // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
 | ||||||
|       console.error(`Couldn't postprocess elements change deltas.`); |       console.error(`Couldn't postprocess elements delta.`); | ||||||
| 
 | 
 | ||||||
|       if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { |       if (isTestEnv() || isDevEnv()) { | ||||||
|         throw e; |         throw e; | ||||||
|       } |       } | ||||||
|     } finally { |     } finally { | ||||||
| @@ -1561,8 +1665,7 @@ export class ElementsChange implements Change<SceneElementsMap> { | |||||||
|   private static stripIrrelevantProps( |   private static stripIrrelevantProps( | ||||||
|     partial: Partial<OrderedExcalidrawElement>, |     partial: Partial<OrderedExcalidrawElement>, | ||||||
|   ): ElementPartial { |   ): ElementPartial { | ||||||
|     const { id, updated, version, versionNonce, seed, ...strippedPartial } = |     const { id, updated, version, versionNonce, ...strippedPartial } = partial; | ||||||
|       partial; |  | ||||||
| 
 | 
 | ||||||
|     return strippedPartial; |     return strippedPartial; | ||||||
|   } |   } | ||||||
| @@ -1,21 +1,26 @@ | |||||||
| import type { GlobalPoint, Radians } from "@excalidraw/math"; |  | ||||||
| import { | import { | ||||||
|   curvePointDistance, |   curvePointDistance, | ||||||
|   distanceToLineSegment, |   distanceToLineSegment, | ||||||
|   pointFrom, |  | ||||||
|   pointRotateRads, |   pointRotateRads, | ||||||
| } from "@excalidraw/math"; | } from "@excalidraw/math"; | ||||||
|  | 
 | ||||||
| import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; | import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; | ||||||
|  | 
 | ||||||
|  | import { elementCenterPoint } from "@excalidraw/common"; | ||||||
|  | 
 | ||||||
|  | import type { GlobalPoint, Radians } from "@excalidraw/math"; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   deconstructDiamondElement, | ||||||
|  |   deconstructRectanguloidElement, | ||||||
|  | } from "./utils"; | ||||||
|  | 
 | ||||||
| import type { | import type { | ||||||
|   ExcalidrawBindableElement, |   ExcalidrawBindableElement, | ||||||
|   ExcalidrawDiamondElement, |   ExcalidrawDiamondElement, | ||||||
|   ExcalidrawEllipseElement, |   ExcalidrawEllipseElement, | ||||||
|   ExcalidrawRectanguloidElement, |   ExcalidrawRectanguloidElement, | ||||||
| } from "./types"; | } from "./types"; | ||||||
| import { |  | ||||||
|   deconstructDiamondElement, |  | ||||||
|   deconstructRectanguloidElement, |  | ||||||
| } from "./utils"; |  | ||||||
| 
 | 
 | ||||||
| export const distanceToBindableElement = ( | export const distanceToBindableElement = ( | ||||||
|   element: ExcalidrawBindableElement, |   element: ExcalidrawBindableElement, | ||||||
| @@ -49,10 +54,7 @@ const distanceToRectanguloidElement = ( | |||||||
|   element: ExcalidrawRectanguloidElement, |   element: ExcalidrawRectanguloidElement, | ||||||
|   p: GlobalPoint, |   p: GlobalPoint, | ||||||
| ) => { | ) => { | ||||||
|   const center = pointFrom<GlobalPoint>( |   const center = elementCenterPoint(element); | ||||||
|     element.x + element.width / 2, |  | ||||||
|     element.y + element.height / 2, |  | ||||||
|   ); |  | ||||||
|   // To emulate a rotated rectangle we rotate the point in the inverse angle
 |   // To emulate a rotated rectangle we rotate the point in the inverse angle
 | ||||||
|   // instead. It's all the same distance-wise.
 |   // instead. It's all the same distance-wise.
 | ||||||
|   const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); |   const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); | ||||||
| @@ -80,10 +82,7 @@ const distanceToDiamondElement = ( | |||||||
|   element: ExcalidrawDiamondElement, |   element: ExcalidrawDiamondElement, | ||||||
|   p: GlobalPoint, |   p: GlobalPoint, | ||||||
| ): number => { | ): number => { | ||||||
|   const center = pointFrom<GlobalPoint>( |   const center = elementCenterPoint(element); | ||||||
|     element.x + element.width / 2, |  | ||||||
|     element.y + element.height / 2, |  | ||||||
|   ); |  | ||||||
| 
 | 
 | ||||||
|   // Rotate the point to the inverse direction to simulate the rotated diamond
 |   // Rotate the point to the inverse direction to simulate the rotated diamond
 | ||||||
|   // points. It's all the same distance-wise.
 |   // points. It's all the same distance-wise.
 | ||||||
| @@ -111,10 +110,7 @@ const distanceToEllipseElement = ( | |||||||
|   element: ExcalidrawEllipseElement, |   element: ExcalidrawEllipseElement, | ||||||
|   p: GlobalPoint, |   p: GlobalPoint, | ||||||
| ): number => { | ): number => { | ||||||
|   const center = pointFrom( |   const center = elementCenterPoint(element); | ||||||
|     element.x + element.width / 2, |  | ||||||
|     element.y + element.height / 2, |  | ||||||
|   ); |  | ||||||
|   return ellipseDistanceFromPoint( |   return ellipseDistanceFromPoint( | ||||||
|     // Instead of rotating the ellipse, rotate the point to the inverse angle
 |     // Instead of rotating the ellipse, rotate the point to the inverse angle
 | ||||||
|     pointRotateRads(p, center, -element.angle as Radians), |     pointRotateRads(p, center, -element.angle as Radians), | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| import { newElementWith } from "./element/mutateElement"; | import { getCommonBoundingBox } from "./bounds"; | ||||||
|  | import { newElementWith } from "./mutateElement"; | ||||||
|  | 
 | ||||||
| import { getMaximumGroups } from "./groups"; | import { getMaximumGroups } from "./groups"; | ||||||
| import { getCommonBoundingBox } from "./element/bounds"; | 
 | ||||||
| import type { ElementsMap, ExcalidrawElement } from "./element/types"; | import type { ElementsMap, ExcalidrawElement } from "./types"; | ||||||
| 
 | 
 | ||||||
| export interface Distribution { | export interface Distribution { | ||||||
|   space: "between"; |   space: "between"; | ||||||
| @@ -1,17 +1,23 @@ | |||||||
| import { updateBoundElements } from "./binding"; | import { | ||||||
| import type { Bounds } from "./bounds"; |   TEXT_AUTOWRAP_THRESHOLD, | ||||||
| import { getCommonBounds } from "./bounds"; |   getGridPoint, | ||||||
| import { mutateElement } from "./mutateElement"; |   getFontString, | ||||||
| import { getPerfectElementSize } from "./sizeHelpers"; | } from "@excalidraw/common"; | ||||||
| import type { NonDeletedExcalidrawElement } from "./types"; | 
 | ||||||
| import type { | import type { | ||||||
|   AppState, |   AppState, | ||||||
|   NormalizedZoomValue, |   NormalizedZoomValue, | ||||||
|   NullableGridSize, |   NullableGridSize, | ||||||
|   PointerDownState, |   PointerDownState, | ||||||
| } from "../types"; | } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
|  | import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; | ||||||
|  | 
 | ||||||
|  | import { updateBoundElements } from "./binding"; | ||||||
|  | import { getCommonBounds } from "./bounds"; | ||||||
|  | import { getPerfectElementSize } from "./sizeHelpers"; | ||||||
| import { getBoundTextElement } from "./textElement"; | import { getBoundTextElement } from "./textElement"; | ||||||
| import type Scene from "../scene/Scene"; | import { getMinTextElementWidth } from "./textMeasurements"; | ||||||
| import { | import { | ||||||
|   isArrowElement, |   isArrowElement, | ||||||
|   isElbowArrow, |   isElbowArrow, | ||||||
| @@ -19,10 +25,11 @@ import { | |||||||
|   isImageElement, |   isImageElement, | ||||||
|   isTextElement, |   isTextElement, | ||||||
| } from "./typeChecks"; | } from "./typeChecks"; | ||||||
| import { getFontString } from "../utils"; | 
 | ||||||
| import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; | import type { Scene } from "./Scene"; | ||||||
| import { getGridPoint } from "../snapping"; | 
 | ||||||
| import { getMinTextElementWidth } from "./textMeasurements"; | import type { Bounds } from "./bounds"; | ||||||
|  | import type { ExcalidrawElement } from "./types"; | ||||||
| 
 | 
 | ||||||
| export const dragSelectedElements = ( | export const dragSelectedElements = ( | ||||||
|   pointerDownState: PointerDownState, |   pointerDownState: PointerDownState, | ||||||
| @@ -76,20 +83,27 @@ export const dragSelectedElements = ( | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const commonBounds = getCommonBounds( |   const origElements: ExcalidrawElement[] = []; | ||||||
|     Array.from(elementsToUpdate).map( | 
 | ||||||
|       (el) => pointerDownState.originalElements.get(el.id) ?? el, |   for (const element of elementsToUpdate) { | ||||||
|     ), |     const origElement = pointerDownState.originalElements.get(element.id); | ||||||
|   ); |     // if original element is not set (e.g. when you duplicate during a drag
 | ||||||
|  |     // operation), exit to avoid undefined behavior
 | ||||||
|  |     if (!origElement) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     origElements.push(origElement); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   const adjustedOffset = calculateOffset( |   const adjustedOffset = calculateOffset( | ||||||
|     commonBounds, |     getCommonBounds(origElements), | ||||||
|     offset, |     offset, | ||||||
|     snapOffset, |     snapOffset, | ||||||
|     gridSize, |     gridSize, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   elementsToUpdate.forEach((element) => { |   elementsToUpdate.forEach((element) => { | ||||||
|     updateElementCoords(pointerDownState, element, adjustedOffset); |     updateElementCoords(pointerDownState, element, scene, adjustedOffset); | ||||||
|     if (!isArrowElement(element)) { |     if (!isArrowElement(element)) { | ||||||
|       // skip arrow labels since we calculate its position during render
 |       // skip arrow labels since we calculate its position during render
 | ||||||
|       const textElement = getBoundTextElement( |       const textElement = getBoundTextElement( | ||||||
| @@ -97,9 +111,14 @@ export const dragSelectedElements = ( | |||||||
|         scene.getNonDeletedElementsMap(), |         scene.getNonDeletedElementsMap(), | ||||||
|       ); |       ); | ||||||
|       if (textElement) { |       if (textElement) { | ||||||
|         updateElementCoords(pointerDownState, textElement, adjustedOffset); |         updateElementCoords( | ||||||
|  |           pointerDownState, | ||||||
|  |           textElement, | ||||||
|  |           scene, | ||||||
|  |           adjustedOffset, | ||||||
|  |         ); | ||||||
|       } |       } | ||||||
|       updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { |       updateBoundElements(element, scene, { | ||||||
|         simultaneouslyUpdated: Array.from(elementsToUpdate), |         simultaneouslyUpdated: Array.from(elementsToUpdate), | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| @@ -140,6 +159,7 @@ const calculateOffset = ( | |||||||
| const updateElementCoords = ( | const updateElementCoords = ( | ||||||
|   pointerDownState: PointerDownState, |   pointerDownState: PointerDownState, | ||||||
|   element: NonDeletedExcalidrawElement, |   element: NonDeletedExcalidrawElement, | ||||||
|  |   scene: Scene, | ||||||
|   dragOffset: { x: number; y: number }, |   dragOffset: { x: number; y: number }, | ||||||
| ) => { | ) => { | ||||||
|   const originalElement = |   const originalElement = | ||||||
| @@ -148,7 +168,7 @@ const updateElementCoords = ( | |||||||
|   const nextX = originalElement.x + dragOffset.x; |   const nextX = originalElement.x + dragOffset.x; | ||||||
|   const nextY = originalElement.y + dragOffset.y; |   const nextY = originalElement.y + dragOffset.y; | ||||||
| 
 | 
 | ||||||
|   mutateElement(element, { |   scene.mutateElement(element, { | ||||||
|     x: nextX, |     x: nextX, | ||||||
|     y: nextY, |     y: nextY, | ||||||
|   }); |   }); | ||||||
| @@ -175,6 +195,7 @@ export const dragNewElement = ({ | |||||||
|   shouldMaintainAspectRatio, |   shouldMaintainAspectRatio, | ||||||
|   shouldResizeFromCenter, |   shouldResizeFromCenter, | ||||||
|   zoom, |   zoom, | ||||||
|  |   scene, | ||||||
|   widthAspectRatio = null, |   widthAspectRatio = null, | ||||||
|   originOffset = null, |   originOffset = null, | ||||||
|   informMutation = true, |   informMutation = true, | ||||||
| @@ -190,6 +211,7 @@ export const dragNewElement = ({ | |||||||
|   shouldMaintainAspectRatio: boolean; |   shouldMaintainAspectRatio: boolean; | ||||||
|   shouldResizeFromCenter: boolean; |   shouldResizeFromCenter: boolean; | ||||||
|   zoom: NormalizedZoomValue; |   zoom: NormalizedZoomValue; | ||||||
|  |   scene: Scene; | ||||||
|   /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is |   /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is | ||||||
|       true */ |       true */ | ||||||
|   widthAspectRatio?: number | null; |   widthAspectRatio?: number | null; | ||||||
| @@ -270,7 +292,7 @@ export const dragNewElement = ({ | |||||||
|       }; |       }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     mutateElement( |     scene.mutateElement( | ||||||
|       newElement, |       newElement, | ||||||
|       { |       { | ||||||
|         x: newX + (originOffset?.x ?? 0), |         x: newX + (originOffset?.x ?? 0), | ||||||
| @@ -280,7 +302,7 @@ export const dragNewElement = ({ | |||||||
|         ...textAutoResize, |         ...textAutoResize, | ||||||
|         ...imageInitialDimension, |         ...imageInitialDimension, | ||||||
|       }, |       }, | ||||||
|       informMutation, |       { informMutation, isDragging: false }, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
							
								
								
									
										487
									
								
								packages/element/src/duplicate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										487
									
								
								packages/element/src/duplicate.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,487 @@ | |||||||
|  | import { | ||||||
|  |   ORIG_ID, | ||||||
|  |   randomId, | ||||||
|  |   randomInteger, | ||||||
|  |   arrayToMap, | ||||||
|  |   castArray, | ||||||
|  |   findLastIndex, | ||||||
|  |   getUpdatedTimestamp, | ||||||
|  |   isTestEnv, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  |  | ||||||
|  | import type { Mutable } from "@excalidraw/common/utility-types"; | ||||||
|  |  | ||||||
|  | import type { AppState } from "@excalidraw/excalidraw/types"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   getElementsInGroup, | ||||||
|  |   getNewGroupIdsForDuplication, | ||||||
|  |   getSelectedGroupForElement, | ||||||
|  | } from "./groups"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   bindElementsToFramesAfterDuplication, | ||||||
|  |   getFrameChildren, | ||||||
|  | } from "./frame"; | ||||||
|  |  | ||||||
|  | import { normalizeElementOrder } from "./sortElements"; | ||||||
|  |  | ||||||
|  | import { bumpVersion } from "./mutateElement"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   hasBoundTextElement, | ||||||
|  |   isBoundToContainer, | ||||||
|  |   isFrameLikeElement, | ||||||
|  | } from "./typeChecks"; | ||||||
|  |  | ||||||
|  | import { getBoundTextElement, getContainerElement } from "./textElement"; | ||||||
|  |  | ||||||
|  | import { fixDuplicatedBindingsAfterDuplication } from "./binding"; | ||||||
|  |  | ||||||
|  | import type { | ||||||
|  |   ElementsMap, | ||||||
|  |   ExcalidrawElement, | ||||||
|  |   GroupId, | ||||||
|  |   NonDeletedSceneElementsMap, | ||||||
|  | } from "./types"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Duplicate an element, often used in the alt-drag operation. | ||||||
|  |  * Note that this method has gotten a bit complicated since the | ||||||
|  |  * introduction of gruoping/ungrouping elements. | ||||||
|  |  * @param editingGroupId The current group being edited. The new | ||||||
|  |  *                       element will inherit this group and its | ||||||
|  |  *                       parents. | ||||||
|  |  * @param groupIdMapForOperation A Map that maps old group IDs to | ||||||
|  |  *                               duplicated ones. If you are duplicating | ||||||
|  |  *                               multiple elements at once, share this map | ||||||
|  |  *                               amongst all of them | ||||||
|  |  * @param element Element to duplicate | ||||||
|  |  */ | ||||||
|  | export const duplicateElement = <TElement extends ExcalidrawElement>( | ||||||
|  |   editingGroupId: AppState["editingGroupId"], | ||||||
|  |   groupIdMapForOperation: Map<GroupId, GroupId>, | ||||||
|  |   element: TElement, | ||||||
|  |   randomizeSeed?: boolean, | ||||||
|  | ): Readonly<TElement> => { | ||||||
|  |   const copy = deepCopyElement(element); | ||||||
|  |  | ||||||
|  |   if (isTestEnv()) { | ||||||
|  |     __test__defineOrigId(copy, element.id); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   copy.id = randomId(); | ||||||
|  |   copy.updated = getUpdatedTimestamp(); | ||||||
|  |   if (randomizeSeed) { | ||||||
|  |     copy.seed = randomInteger(); | ||||||
|  |     bumpVersion(copy); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   copy.groupIds = getNewGroupIdsForDuplication( | ||||||
|  |     copy.groupIds, | ||||||
|  |     editingGroupId, | ||||||
|  |     (groupId) => { | ||||||
|  |       if (!groupIdMapForOperation.has(groupId)) { | ||||||
|  |         groupIdMapForOperation.set(groupId, randomId()); | ||||||
|  |       } | ||||||
|  |       return groupIdMapForOperation.get(groupId)!; | ||||||
|  |     }, | ||||||
|  |   ); | ||||||
|  |   return copy; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const duplicateElements = ( | ||||||
|  |   opts: { | ||||||
|  |     elements: readonly ExcalidrawElement[]; | ||||||
|  |     randomizeSeed?: boolean; | ||||||
|  |     overrides?: (data: { | ||||||
|  |       duplicateElement: ExcalidrawElement; | ||||||
|  |       origElement: ExcalidrawElement; | ||||||
|  |       origIdToDuplicateId: Map< | ||||||
|  |         ExcalidrawElement["id"], | ||||||
|  |         ExcalidrawElement["id"] | ||||||
|  |       >; | ||||||
|  |     }) => Partial<ExcalidrawElement>; | ||||||
|  |   } & ( | ||||||
|  |     | { | ||||||
|  |         /** | ||||||
|  |          * Duplicates all elements in array. | ||||||
|  |          * | ||||||
|  |          * Use this when programmaticaly duplicating elements, without direct | ||||||
|  |          * user interaction. | ||||||
|  |          */ | ||||||
|  |         type: "everything"; | ||||||
|  |       } | ||||||
|  |     | { | ||||||
|  |         /** | ||||||
|  |          * Duplicates specified elements and inserts them back into the array | ||||||
|  |          * in specified order. | ||||||
|  |          * | ||||||
|  |          * Use this when duplicating Scene elements, during user interaction | ||||||
|  |          * such as alt-drag or on duplicate action. | ||||||
|  |          */ | ||||||
|  |         type: "in-place"; | ||||||
|  |         idsOfElementsToDuplicate: Map< | ||||||
|  |           ExcalidrawElement["id"], | ||||||
|  |           ExcalidrawElement | ||||||
|  |         >; | ||||||
|  |         appState: { | ||||||
|  |           editingGroupId: AppState["editingGroupId"]; | ||||||
|  |           selectedGroupIds: AppState["selectedGroupIds"]; | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |   ), | ||||||
|  | ) => { | ||||||
|  |   let { elements } = opts; | ||||||
|  |  | ||||||
|  |   const appState = | ||||||
|  |     "appState" in opts | ||||||
|  |       ? opts.appState | ||||||
|  |       : ({ | ||||||
|  |           editingGroupId: null, | ||||||
|  |           selectedGroupIds: {}, | ||||||
|  |         } as const); | ||||||
|  |  | ||||||
|  |   // Ids of elements that have already been processed so we don't push them | ||||||
|  |   // into the array twice if we end up backtracking when retrieving | ||||||
|  |   // discontiguous group of elements (can happen due to a bug, or in edge | ||||||
|  |   // cases such as a group containing deleted elements which were not selected). | ||||||
|  |   // | ||||||
|  |   // This is not enough to prevent duplicates, so we do a second loop afterwards | ||||||
|  |   // to remove them. | ||||||
|  |   // | ||||||
|  |   // For convenience we mark even the newly created ones even though we don't | ||||||
|  |   // loop over them. | ||||||
|  |   const processedIds = new Map<ExcalidrawElement["id"], true>(); | ||||||
|  |   const groupIdMap = new Map(); | ||||||
|  |   const duplicatedElements: ExcalidrawElement[] = []; | ||||||
|  |   const origElements: ExcalidrawElement[] = []; | ||||||
|  |   const origIdToDuplicateId = new Map< | ||||||
|  |     ExcalidrawElement["id"], | ||||||
|  |     ExcalidrawElement["id"] | ||||||
|  |   >(); | ||||||
|  |   const duplicateIdToOrigElement = new Map< | ||||||
|  |     ExcalidrawElement["id"], | ||||||
|  |     ExcalidrawElement | ||||||
|  |   >(); | ||||||
|  |   const duplicateElementsMap = new Map<string, ExcalidrawElement>(); | ||||||
|  |   const elementsMap = arrayToMap(elements) as ElementsMap; | ||||||
|  |   const _idsOfElementsToDuplicate = | ||||||
|  |     opts.type === "in-place" | ||||||
|  |       ? opts.idsOfElementsToDuplicate | ||||||
|  |       : new Map(elements.map((el) => [el.id, el])); | ||||||
|  |  | ||||||
|  |   // For sanity | ||||||
|  |   if (opts.type === "in-place") { | ||||||
|  |     for (const groupId of Object.keys(opts.appState.selectedGroupIds)) { | ||||||
|  |       elements | ||||||
|  |         .filter((el) => el.groupIds?.includes(groupId)) | ||||||
|  |         .forEach((el) => _idsOfElementsToDuplicate.set(el.id, el)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   elements = normalizeElementOrder(elements); | ||||||
|  |  | ||||||
|  |   const elementsWithDuplicates: ExcalidrawElement[] = elements.slice(); | ||||||
|  |  | ||||||
|  |   // helper functions | ||||||
|  |   // ------------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  |   // Used for the heavy lifing of copying a single element, a group of elements | ||||||
|  |   // an element with bound text etc. | ||||||
|  |   const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>( | ||||||
|  |     element: T, | ||||||
|  |   ): T extends ExcalidrawElement[] | ||||||
|  |     ? ExcalidrawElement[] | ||||||
|  |     : ExcalidrawElement | null => { | ||||||
|  |     const elements = castArray(element); | ||||||
|  |  | ||||||
|  |     const _newElements = elements.reduce( | ||||||
|  |       (acc: ExcalidrawElement[], element) => { | ||||||
|  |         if (processedIds.has(element.id)) { | ||||||
|  |           return acc; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         processedIds.set(element.id, true); | ||||||
|  |  | ||||||
|  |         const newElement = duplicateElement( | ||||||
|  |           appState.editingGroupId, | ||||||
|  |           groupIdMap, | ||||||
|  |           element, | ||||||
|  |           opts.randomizeSeed, | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         processedIds.set(newElement.id, true); | ||||||
|  |  | ||||||
|  |         duplicateElementsMap.set(newElement.id, newElement); | ||||||
|  |         origIdToDuplicateId.set(element.id, newElement.id); | ||||||
|  |         duplicateIdToOrigElement.set(newElement.id, element); | ||||||
|  |  | ||||||
|  |         origElements.push(element); | ||||||
|  |         duplicatedElements.push(newElement); | ||||||
|  |  | ||||||
|  |         acc.push(newElement); | ||||||
|  |         return acc; | ||||||
|  |       }, | ||||||
|  |       [], | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       Array.isArray(element) ? _newElements : _newElements[0] || null | ||||||
|  |     ) as T extends ExcalidrawElement[] | ||||||
|  |       ? ExcalidrawElement[] | ||||||
|  |       : ExcalidrawElement | null; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Helper to position cloned elements in the Z-order the product needs it | ||||||
|  |   const insertBeforeOrAfterIndex = ( | ||||||
|  |     index: number, | ||||||
|  |     elements: ExcalidrawElement | null | ExcalidrawElement[], | ||||||
|  |   ) => { | ||||||
|  |     if (!elements) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (index > elementsWithDuplicates.length - 1) { | ||||||
|  |       elementsWithDuplicates.push(...castArray(elements)); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const frameIdsToDuplicate = new Set( | ||||||
|  |     elements | ||||||
|  |       .filter( | ||||||
|  |         (el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el), | ||||||
|  |       ) | ||||||
|  |       .map((el) => el.id), | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   for (const element of elements) { | ||||||
|  |     if (processedIds.has(element.id)) { | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!_idsOfElementsToDuplicate.has(element.id)) { | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // groups | ||||||
|  |     // ------------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  |     const groupId = getSelectedGroupForElement(appState, element); | ||||||
|  |     if (groupId) { | ||||||
|  |       const groupElements = getElementsInGroup(elements, groupId).flatMap( | ||||||
|  |         (element) => | ||||||
|  |           isFrameLikeElement(element) | ||||||
|  |             ? [...getFrameChildren(elements, element.id), element] | ||||||
|  |             : [element], | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { | ||||||
|  |         return el.groupIds?.includes(groupId); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements)); | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // frame duplication | ||||||
|  |     // ------------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  |     if (element.frameId && frameIdsToDuplicate.has(element.frameId)) { | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (isFrameLikeElement(element)) { | ||||||
|  |       const frameId = element.id; | ||||||
|  |  | ||||||
|  |       const frameChildren = getFrameChildren(elements, frameId); | ||||||
|  |  | ||||||
|  |       const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { | ||||||
|  |         return el.frameId === frameId || el.id === frameId; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       insertBeforeOrAfterIndex( | ||||||
|  |         targetIndex, | ||||||
|  |         copyElements([...frameChildren, element]), | ||||||
|  |       ); | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // text container | ||||||
|  |     // ------------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  |     if (hasBoundTextElement(element)) { | ||||||
|  |       const boundTextElement = getBoundTextElement(element, elementsMap); | ||||||
|  |  | ||||||
|  |       const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { | ||||||
|  |         return ( | ||||||
|  |           el.id === element.id || | ||||||
|  |           ("containerId" in el && el.containerId === element.id) | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       if (boundTextElement) { | ||||||
|  |         insertBeforeOrAfterIndex( | ||||||
|  |           targetIndex, | ||||||
|  |           copyElements([element, boundTextElement]), | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         insertBeforeOrAfterIndex(targetIndex, copyElements(element)); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (isBoundToContainer(element)) { | ||||||
|  |       const container = getContainerElement(element, elementsMap); | ||||||
|  |  | ||||||
|  |       const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { | ||||||
|  |         return el.id === element.id || el.id === container?.id; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       if (container) { | ||||||
|  |         insertBeforeOrAfterIndex( | ||||||
|  |           targetIndex, | ||||||
|  |           copyElements([container, element]), | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         insertBeforeOrAfterIndex(targetIndex, copyElements(element)); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // default duplication (regular elements) | ||||||
|  |     // ------------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  |     insertBeforeOrAfterIndex( | ||||||
|  |       findLastIndex(elementsWithDuplicates, (el) => el.id === element.id), | ||||||
|  |       copyElements(element), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // --------------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  |   fixDuplicatedBindingsAfterDuplication( | ||||||
|  |     duplicatedElements, | ||||||
|  |     origIdToDuplicateId, | ||||||
|  |     duplicateElementsMap as NonDeletedSceneElementsMap, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   bindElementsToFramesAfterDuplication( | ||||||
|  |     elementsWithDuplicates, | ||||||
|  |     origElements, | ||||||
|  |     origIdToDuplicateId, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (opts.overrides) { | ||||||
|  |     for (const duplicateElement of duplicatedElements) { | ||||||
|  |       const origElement = duplicateIdToOrigElement.get(duplicateElement.id); | ||||||
|  |       if (origElement) { | ||||||
|  |         Object.assign( | ||||||
|  |           duplicateElement, | ||||||
|  |           opts.overrides({ | ||||||
|  |             duplicateElement, | ||||||
|  |             origElement, | ||||||
|  |             origIdToDuplicateId, | ||||||
|  |           }), | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     duplicatedElements, | ||||||
|  |     duplicateElementsMap, | ||||||
|  |     elementsWithDuplicates, | ||||||
|  |     origIdToDuplicateId, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Simplified deep clone for the purpose of cloning ExcalidrawElement. | ||||||
|  | // | ||||||
|  | // Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, | ||||||
|  | // Typed arrays and other non-null objects. | ||||||
|  | // | ||||||
|  | // Adapted from https://github.com/lukeed/klona | ||||||
|  | // | ||||||
|  | // The reason for `deepCopyElement()` wrapper is type safety (only allow | ||||||
|  | // passing ExcalidrawElement as the top-level argument). | ||||||
|  | const _deepCopyElement = (val: any, depth: number = 0) => { | ||||||
|  |   // only clone non-primitives | ||||||
|  |   if (val == null || typeof val !== "object") { | ||||||
|  |     return val; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const objectType = Object.prototype.toString.call(val); | ||||||
|  |  | ||||||
|  |   if (objectType === "[object Object]") { | ||||||
|  |     const tmp = | ||||||
|  |       typeof val.constructor === "function" | ||||||
|  |         ? Object.create(Object.getPrototypeOf(val)) | ||||||
|  |         : {}; | ||||||
|  |     for (const key in val) { | ||||||
|  |       if (val.hasOwnProperty(key)) { | ||||||
|  |         // don't copy non-serializable objects like these caches. They'll be | ||||||
|  |         // populated when the element is rendered. | ||||||
|  |         if (depth === 0 && (key === "shape" || key === "canvas")) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |         tmp[key] = _deepCopyElement(val[key], depth + 1); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return tmp; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (Array.isArray(val)) { | ||||||
|  |     let k = val.length; | ||||||
|  |     const arr = new Array(k); | ||||||
|  |     while (k--) { | ||||||
|  |       arr[k] = _deepCopyElement(val[k], depth + 1); | ||||||
|  |     } | ||||||
|  |     return arr; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // we're not cloning non-array & non-plain-object objects because we | ||||||
|  |   // don't support them on excalidraw elements yet. If we do, we need to make | ||||||
|  |   // sure we start cloning them, so let's warn about it. | ||||||
|  |   if (import.meta.env.DEV) { | ||||||
|  |     if ( | ||||||
|  |       objectType !== "[object Object]" && | ||||||
|  |       objectType !== "[object Array]" && | ||||||
|  |       objectType.startsWith("[object ") | ||||||
|  |     ) { | ||||||
|  |       console.warn( | ||||||
|  |         `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return val; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or | ||||||
|  |  * any value. The purpose is to to break object references for immutability | ||||||
|  |  * reasons, whenever we want to keep the original element, but ensure it's not | ||||||
|  |  * mutated. | ||||||
|  |  * | ||||||
|  |  * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, | ||||||
|  |  * Typed arrays and other non-null objects. | ||||||
|  |  */ | ||||||
|  | export const deepCopyElement = <T extends ExcalidrawElement>( | ||||||
|  |   val: T, | ||||||
|  | ): Mutable<T> => { | ||||||
|  |   return _deepCopyElement(val); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const __test__defineOrigId = (clonedObj: object, origId: string) => { | ||||||
|  |   Object.defineProperty(clonedObj, ORIG_ID, { | ||||||
|  |     value: origId, | ||||||
|  |     writable: false, | ||||||
|  |     enumerable: false, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
| @@ -12,11 +12,18 @@ import { | |||||||
|   type GlobalPoint, |   type GlobalPoint, | ||||||
|   type LocalPoint, |   type LocalPoint, | ||||||
| } from "@excalidraw/math"; | } from "@excalidraw/math"; | ||||||
| import BinaryHeap from "../binaryheap"; | 
 | ||||||
| import { getSizeFromPoints } from "../points"; | import { | ||||||
| import { aabbForElement, pointInsideBounds } from "../shapes"; |   BinaryHeap, | ||||||
| import { invariant, isAnyTrue, tupleToCoors } from "../utils"; |   invariant, | ||||||
| import type { AppState } from "../types"; |   isAnyTrue, | ||||||
|  |   tupleToCoors, | ||||||
|  |   getSizeFromPoints, | ||||||
|  |   isDevEnv, | ||||||
|  | } from "@excalidraw/common"; | ||||||
|  | 
 | ||||||
|  | import type { AppState } from "@excalidraw/excalidraw/types"; | ||||||
|  | 
 | ||||||
| import { | import { | ||||||
|   bindPointToSnapToElementOutline, |   bindPointToSnapToElementOutline, | ||||||
|   FIXED_BINDING_DISTANCE, |   FIXED_BINDING_DISTANCE, | ||||||
| @@ -25,8 +32,7 @@ import { | |||||||
|   snapToMid, |   snapToMid, | ||||||
|   getHoveredElementForBinding, |   getHoveredElementForBinding, | ||||||
| } from "./binding"; | } from "./binding"; | ||||||
| import type { Bounds } from "./bounds"; | import { distanceToBindableElement } from "./distance"; | ||||||
| import type { Heading } from "./heading"; |  | ||||||
| import { | import { | ||||||
|   compareHeading, |   compareHeading, | ||||||
|   flipHeading, |   flipHeading, | ||||||
| @@ -44,8 +50,12 @@ import { isBindableElement } from "./typeChecks"; | |||||||
| import { | import { | ||||||
|   type ExcalidrawElbowArrowElement, |   type ExcalidrawElbowArrowElement, | ||||||
|   type NonDeletedSceneElementsMap, |   type NonDeletedSceneElementsMap, | ||||||
|   type SceneElementsMap, |  | ||||||
| } from "./types"; | } from "./types"; | ||||||
|  | 
 | ||||||
|  | import { aabbForElement, pointInsideBounds } from "./shapes"; | ||||||
|  | 
 | ||||||
|  | import type { Bounds } from "./bounds"; | ||||||
|  | import type { Heading } from "./heading"; | ||||||
| import type { | import type { | ||||||
|   Arrowhead, |   Arrowhead, | ||||||
|   ElementsMap, |   ElementsMap, | ||||||
| @@ -54,7 +64,6 @@ import type { | |||||||
|   FixedSegment, |   FixedSegment, | ||||||
|   NonDeletedExcalidrawElement, |   NonDeletedExcalidrawElement, | ||||||
| } from "./types"; | } from "./types"; | ||||||
| import { distanceToBindableElement } from "./distance"; |  | ||||||
| 
 | 
 | ||||||
| type GridAddress = [number, number] & { _brand: "gridaddress" }; | type GridAddress = [number, number] & { _brand: "gridaddress" }; | ||||||
| 
 | 
 | ||||||
| @@ -235,16 +244,6 @@ const handleSegmentRenormalization = ( | |||||||
|                 nextPoints.map((p) => |                 nextPoints.map((p) => | ||||||
|                   pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y), |                   pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y), | ||||||
|                 ), |                 ), | ||||||
|                 arrow.startBinding && |  | ||||||
|                   getBindableElementForId( |  | ||||||
|                     arrow.startBinding.elementId, |  | ||||||
|                     elementsMap, |  | ||||||
|                   ), |  | ||||||
|                 arrow.endBinding && |  | ||||||
|                   getBindableElementForId( |  | ||||||
|                     arrow.endBinding.elementId, |  | ||||||
|                     elementsMap, |  | ||||||
|                   ), |  | ||||||
|               ), |               ), | ||||||
|             ) ?? [], |             ) ?? [], | ||||||
|           ), |           ), | ||||||
| @@ -255,7 +254,7 @@ const handleSegmentRenormalization = ( | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     import.meta.env.DEV && |     isDevEnv() && | ||||||
|       invariant( |       invariant( | ||||||
|         validateElbowPoints(nextPoints), |         validateElbowPoints(nextPoints), | ||||||
|         "Invalid elbow points with fixed segments", |         "Invalid elbow points with fixed segments", | ||||||
| @@ -338,9 +337,6 @@ const handleSegmentRelease = ( | |||||||
|           y, |           y, | ||||||
|       ), |       ), | ||||||
|     ], |     ], | ||||||
|     startBinding && |  | ||||||
|       getBindableElementForId(startBinding.elementId, elementsMap), |  | ||||||
|     endBinding && getBindableElementForId(endBinding.elementId, elementsMap), |  | ||||||
|     { isDragging: false }, |     { isDragging: false }, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
| @@ -890,7 +886,7 @@ export const updateElbowArrowPoints = ( | |||||||
|   elementsMap: NonDeletedSceneElementsMap, |   elementsMap: NonDeletedSceneElementsMap, | ||||||
|   updates: { |   updates: { | ||||||
|     points?: readonly LocalPoint[]; |     points?: readonly LocalPoint[]; | ||||||
|     fixedSegments?: FixedSegment[] | null; |     fixedSegments?: readonly FixedSegment[] | null; | ||||||
|     startBinding?: FixedPointBinding | null; |     startBinding?: FixedPointBinding | null; | ||||||
|     endBinding?: FixedPointBinding | null; |     endBinding?: FixedPointBinding | null; | ||||||
|   }, |   }, | ||||||
| @@ -978,8 +974,29 @@ export const updateElbowArrowPoints = ( | |||||||
|         ), |         ), | ||||||
|       "Elbow arrow segments must be either horizontal or vertical", |       "Elbow arrow segments must be either horizontal or vertical", | ||||||
|     ); |     ); | ||||||
|  | 
 | ||||||
|  |     invariant( | ||||||
|  |       updates.fixedSegments?.find( | ||||||
|  |         (segment) => | ||||||
|  |           segment.index === 1 && | ||||||
|  |           pointsEqual(segment.start, (updates.points ?? arrow.points)[0]), | ||||||
|  |       ) == null && | ||||||
|  |         updates.fixedSegments?.find( | ||||||
|  |           (segment) => | ||||||
|  |             segment.index === (updates.points ?? arrow.points).length - 1 && | ||||||
|  |             pointsEqual( | ||||||
|  |               segment.end, | ||||||
|  |               (updates.points ?? arrow.points)[ | ||||||
|  |                 (updates.points ?? arrow.points).length - 1 | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |         ) == null, | ||||||
|  |       "The first and last segments cannot be fixed", | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; | ||||||
|  | 
 | ||||||
|   const updatedPoints: readonly LocalPoint[] = updates.points |   const updatedPoints: readonly LocalPoint[] = updates.points | ||||||
|     ? updates.points && updates.points.length === 2 |     ? updates.points && updates.points.length === 2 | ||||||
|       ? arrow.points.map((p, idx) => |       ? arrow.points.map((p, idx) => | ||||||
| @@ -992,26 +1009,36 @@ export const updateElbowArrowPoints = ( | |||||||
|       : updates.points.slice() |       : updates.points.slice() | ||||||
|     : arrow.points.slice(); |     : arrow.points.slice(); | ||||||
| 
 | 
 | ||||||
|   // 0. During all element replacement in the scene, we just need to renormalize
 |   // During all element replacement in the scene, we just need to renormalize
 | ||||||
|   // the arrow
 |   // the arrow
 | ||||||
|   // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
 |   // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
 | ||||||
|  |   const { | ||||||
|  |     startBinding: updatedStartBinding, | ||||||
|  |     endBinding: updatedEndBinding, | ||||||
|  |     ...restOfTheUpdates | ||||||
|  |   } = updates; | ||||||
|   const startBinding = |   const startBinding = | ||||||
|     typeof updates.startBinding !== "undefined" |     typeof updatedStartBinding !== "undefined" | ||||||
|       ? updates.startBinding |       ? updatedStartBinding | ||||||
|       : arrow.startBinding; |       : arrow.startBinding; | ||||||
|   const endBinding = |   const endBinding = | ||||||
|     typeof updates.endBinding !== "undefined" |     typeof updatedEndBinding !== "undefined" | ||||||
|       ? updates.endBinding |       ? updatedEndBinding | ||||||
|       : arrow.endBinding; |       : arrow.endBinding; | ||||||
|   const startElement = |   const startElement = | ||||||
|     startBinding && |     startBinding && | ||||||
|     getBindableElementForId(startBinding.elementId, elementsMap); |     getBindableElementForId(startBinding.elementId, elementsMap); | ||||||
|   const endElement = |   const endElement = | ||||||
|     endBinding && getBindableElementForId(endBinding.elementId, elementsMap); |     endBinding && getBindableElementForId(endBinding.elementId, elementsMap); | ||||||
|  |   const areUpdatedPointsValid = validateElbowPoints(updatedPoints); | ||||||
|  | 
 | ||||||
|   if ( |   if ( | ||||||
|     (elementsMap.size === 0 && validateElbowPoints(updatedPoints)) || |     (startBinding && !startElement && areUpdatedPointsValid) || | ||||||
|     startElement?.id !== startBinding?.elementId || |     (endBinding && !endElement && areUpdatedPointsValid) || | ||||||
|     endElement?.id !== endBinding?.elementId |     (elementsMap.size === 0 && areUpdatedPointsValid) || | ||||||
|  |     (Object.keys(restOfTheUpdates).length === 0 && | ||||||
|  |       (startElement?.id !== startBinding?.elementId || | ||||||
|  |         endElement?.id !== endBinding?.elementId)) | ||||||
|   ) { |   ) { | ||||||
|     return normalizeArrowElementUpdate( |     return normalizeArrowElementUpdate( | ||||||
|       updatedPoints.map((p) => |       updatedPoints.map((p) => | ||||||
| @@ -1043,12 +1070,22 @@ export const updateElbowArrowPoints = ( | |||||||
|     }, |     }, | ||||||
|     elementsMap, |     elementsMap, | ||||||
|     updatedPoints, |     updatedPoints, | ||||||
|     startElement, |  | ||||||
|     endElement, |  | ||||||
|     options, |     options, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; |   // 0. During all element replacement in the scene, we just need to renormalize
 | ||||||
|  |   // the arrow
 | ||||||
|  |   // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
 | ||||||
|  |   if (elementsMap.size === 0 && areUpdatedPointsValid) { | ||||||
|  |     return normalizeArrowElementUpdate( | ||||||
|  |       updatedPoints.map((p) => | ||||||
|  |         pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]), | ||||||
|  |       ), | ||||||
|  |       arrow.fixedSegments, | ||||||
|  |       arrow.startIsSpecial, | ||||||
|  |       arrow.endIsSpecial, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   ////
 |   ////
 | ||||||
|   // 1. Renormalize the arrow
 |   // 1. Renormalize the arrow
 | ||||||
| @@ -1071,7 +1108,8 @@ export const updateElbowArrowPoints = ( | |||||||
|         p, |         p, | ||||||
|         arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity), |         arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity), | ||||||
|       ), |       ), | ||||||
|     ) |     ) && | ||||||
|  |     areUpdatedPointsValid | ||||||
|   ) { |   ) { | ||||||
|     return {}; |     return {}; | ||||||
|   } |   } | ||||||
| @@ -1182,8 +1220,6 @@ const getElbowArrowData = ( | |||||||
|   }, |   }, | ||||||
|   elementsMap: NonDeletedSceneElementsMap, |   elementsMap: NonDeletedSceneElementsMap, | ||||||
|   nextPoints: readonly LocalPoint[], |   nextPoints: readonly LocalPoint[], | ||||||
|   startElement: ExcalidrawBindableElement | null, |  | ||||||
|   endElement: ExcalidrawBindableElement | null, |  | ||||||
|   options?: { |   options?: { | ||||||
|     isDragging?: boolean; |     isDragging?: boolean; | ||||||
|     zoom?: AppState["zoom"]; |     zoom?: AppState["zoom"]; | ||||||
| @@ -1198,8 +1234,8 @@ const getElbowArrowData = ( | |||||||
|     GlobalPoint |     GlobalPoint | ||||||
|   >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); |   >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); | ||||||
| 
 | 
 | ||||||
|   let hoveredStartElement = startElement; |   let hoveredStartElement = null; | ||||||
|   let hoveredEndElement = endElement; |   let hoveredEndElement = null; | ||||||
|   if (options?.isDragging) { |   if (options?.isDragging) { | ||||||
|     const elements = Array.from(elementsMap.values()); |     const elements = Array.from(elementsMap.values()); | ||||||
|     hoveredStartElement = |     hoveredStartElement = | ||||||
| @@ -1208,53 +1244,59 @@ const getElbowArrowData = ( | |||||||
|         elementsMap, |         elementsMap, | ||||||
|         elements, |         elements, | ||||||
|         options?.zoom, |         options?.zoom, | ||||||
|       ) || startElement; |       ) || null; | ||||||
|     hoveredEndElement = |     hoveredEndElement = | ||||||
|       getHoveredElement( |       getHoveredElement( | ||||||
|         origEndGlobalPoint, |         origEndGlobalPoint, | ||||||
|         elementsMap, |         elementsMap, | ||||||
|         elements, |         elements, | ||||||
|         options?.zoom, |         options?.zoom, | ||||||
|       ) || endElement; |       ) || null; | ||||||
|  |   } else { | ||||||
|  |     hoveredStartElement = arrow.startBinding | ||||||
|  |       ? getBindableElementForId(arrow.startBinding.elementId, elementsMap) || | ||||||
|  |         null | ||||||
|  |       : null; | ||||||
|  |     hoveredEndElement = arrow.endBinding | ||||||
|  |       ? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null | ||||||
|  |       : null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const startGlobalPoint = getGlobalPoint( |   const startGlobalPoint = getGlobalPoint( | ||||||
|     { |     { | ||||||
|       ...arrow, |       ...arrow, | ||||||
|  |       type: "arrow", | ||||||
|       elbowed: true, |       elbowed: true, | ||||||
|       points: nextPoints, |       points: nextPoints, | ||||||
|     } as ExcalidrawElbowArrowElement, |     } as ExcalidrawElbowArrowElement, | ||||||
|     "start", |     "start", | ||||||
|     arrow.startBinding?.fixedPoint, |     arrow.startBinding?.fixedPoint, | ||||||
|     origStartGlobalPoint, |     origStartGlobalPoint, | ||||||
|     startElement, |  | ||||||
|     hoveredStartElement, |     hoveredStartElement, | ||||||
|     options?.isDragging, |     options?.isDragging, | ||||||
|   ); |   ); | ||||||
|   const endGlobalPoint = getGlobalPoint( |   const endGlobalPoint = getGlobalPoint( | ||||||
|     { |     { | ||||||
|       ...arrow, |       ...arrow, | ||||||
|  |       type: "arrow", | ||||||
|       elbowed: true, |       elbowed: true, | ||||||
|       points: nextPoints, |       points: nextPoints, | ||||||
|     } as ExcalidrawElbowArrowElement, |     } as ExcalidrawElbowArrowElement, | ||||||
|     "end", |     "end", | ||||||
|     arrow.endBinding?.fixedPoint, |     arrow.endBinding?.fixedPoint, | ||||||
|     origEndGlobalPoint, |     origEndGlobalPoint, | ||||||
|     endElement, |  | ||||||
|     hoveredEndElement, |     hoveredEndElement, | ||||||
|     options?.isDragging, |     options?.isDragging, | ||||||
|   ); |   ); | ||||||
|   const startHeading = getBindPointHeading( |   const startHeading = getBindPointHeading( | ||||||
|     startGlobalPoint, |     startGlobalPoint, | ||||||
|     endGlobalPoint, |     endGlobalPoint, | ||||||
|     elementsMap, |  | ||||||
|     hoveredStartElement, |     hoveredStartElement, | ||||||
|     origStartGlobalPoint, |     origStartGlobalPoint, | ||||||
|   ); |   ); | ||||||
|   const endHeading = getBindPointHeading( |   const endHeading = getBindPointHeading( | ||||||
|     endGlobalPoint, |     endGlobalPoint, | ||||||
|     startGlobalPoint, |     startGlobalPoint, | ||||||
|     elementsMap, |  | ||||||
|     hoveredEndElement, |     hoveredEndElement, | ||||||
|     origEndGlobalPoint, |     origEndGlobalPoint, | ||||||
|   ); |   ); | ||||||
| @@ -2186,36 +2228,35 @@ const getGlobalPoint = ( | |||||||
|   startOrEnd: "start" | "end", |   startOrEnd: "start" | "end", | ||||||
|   fixedPointRatio: [number, number] | undefined | null, |   fixedPointRatio: [number, number] | undefined | null, | ||||||
|   initialPoint: GlobalPoint, |   initialPoint: GlobalPoint, | ||||||
|   boundElement?: ExcalidrawBindableElement | null, |   element?: ExcalidrawBindableElement | null, | ||||||
|   hoveredElement?: ExcalidrawBindableElement | null, |  | ||||||
|   isDragging?: boolean, |   isDragging?: boolean, | ||||||
| ): GlobalPoint => { | ): GlobalPoint => { | ||||||
|   if (isDragging) { |   if (isDragging) { | ||||||
|     if (hoveredElement) { |     if (element) { | ||||||
|       const snapPoint = bindPointToSnapToElementOutline( |       const snapPoint = bindPointToSnapToElementOutline( | ||||||
|         arrow, |         arrow, | ||||||
|         hoveredElement, |         element, | ||||||
|         startOrEnd, |         startOrEnd, | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       return snapToMid(hoveredElement, snapPoint); |       return snapToMid(element, snapPoint); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return initialPoint; |     return initialPoint; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (boundElement) { |   if (element) { | ||||||
|     const fixedGlobalPoint = getGlobalFixedPointForBindableElement( |     const fixedGlobalPoint = getGlobalFixedPointForBindableElement( | ||||||
|       fixedPointRatio || [0, 0], |       fixedPointRatio || [0, 0], | ||||||
|       boundElement, |       element, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     // NOTE: Resize scales the binding position point too, so we need to update it
 |     // NOTE: Resize scales the binding position point too, so we need to update it
 | ||||||
|     return Math.abs( |     return Math.abs( | ||||||
|       distanceToBindableElement(boundElement, fixedGlobalPoint) - |       distanceToBindableElement(element, fixedGlobalPoint) - | ||||||
|         FIXED_BINDING_DISTANCE, |         FIXED_BINDING_DISTANCE, | ||||||
|     ) > 0.01 |     ) > 0.01 | ||||||
|       ? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd) |       ? bindPointToSnapToElementOutline(arrow, element, startOrEnd) | ||||||
|       : fixedGlobalPoint; |       : fixedGlobalPoint; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @@ -2225,7 +2266,6 @@ const getGlobalPoint = ( | |||||||
| const getBindPointHeading = ( | const getBindPointHeading = ( | ||||||
|   p: GlobalPoint, |   p: GlobalPoint, | ||||||
|   otherPoint: GlobalPoint, |   otherPoint: GlobalPoint, | ||||||
|   elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, |  | ||||||
|   hoveredElement: ExcalidrawBindableElement | null | undefined, |   hoveredElement: ExcalidrawBindableElement | null | undefined, | ||||||
|   origPoint: GlobalPoint, |   origPoint: GlobalPoint, | ||||||
| ): Heading => | ): Heading => | ||||||
| @@ -2243,7 +2283,6 @@ const getBindPointHeading = ( | |||||||
|           number, |           number, | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|     elementsMap, |  | ||||||
|     origPoint, |     origPoint, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
| @@ -2,10 +2,12 @@ | |||||||
|  * Create and link between shapes. |  * Create and link between shapes. | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import { ELEMENT_LINK_KEY } from "../constants"; | import { ELEMENT_LINK_KEY, normalizeLink } from "@excalidraw/common"; | ||||||
| import { normalizeLink } from "../data/url"; | 
 | ||||||
| import { elementsAreInSameGroup } from "../groups"; | import type { AppProps, AppState } from "@excalidraw/excalidraw/types"; | ||||||
| import type { AppProps, AppState } from "../types"; | 
 | ||||||
|  | import { elementsAreInSameGroup } from "./groups"; | ||||||
|  | 
 | ||||||
| import type { ExcalidrawElement } from "./types"; | import type { ExcalidrawElement } from "./types"; | ||||||
| 
 | 
 | ||||||
| export const defaultGetElementLinkFromSelection: Exclude< | export const defaultGetElementLinkFromSelection: Exclude< | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user