Compare commits

..

1 Commits

Author SHA1 Message Date
zsviczian
332bc4d732 debounce context menu if app is resizing 2025-03-09 11:17:05 +01:00
600 changed files with 35376 additions and 54144 deletions

View File

@@ -1,5 +1,3 @@
MODE="development"
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/
@@ -50,6 +48,3 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
# set to true in .env.development.local to disable the prevent unload dialog
VITE_APP_DISABLE_PREVENT_UNLOAD=

View File

@@ -1,5 +1,3 @@
MODE="production"
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/

View File

@@ -1,21 +1,6 @@
{
"extends": ["@excalidraw/eslint-config", "react-app"],
"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",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": [
@@ -32,12 +17,6 @@
"name": "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
}
]
}
}

View File

@@ -1,45 +0,0 @@
# 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}

View File

@@ -24,4 +24,4 @@ jobs:
- name: Auto release
run: |
yarn add @actions/core -W
yarn release --tag=next --non-interactive
yarn autorelease

View File

@@ -0,0 +1,55 @@
name: Auto release excalidraw preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core -W
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"

View File

@@ -17,14 +17,9 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: excalidraw/excalidraw:latest
platforms: linux/amd64, linux/arm64, linux/arm/v7

3
.gitignore vendored
View File

@@ -25,5 +25,4 @@ packages/excalidraw/types
coverage
dev-dist
html
meta*.json
.claude
meta*.json

View File

@@ -1,34 +0,0 @@
# 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

View File

@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:18 AS build
FROM node:18 AS build
WORKDIR /opt/node_app
@@ -6,14 +6,13 @@ COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
RUN yarn --network-timeout 600000
ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
RUN yarn build:app:docker
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html

View File

@@ -34,9 +34,6 @@
<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"/>
</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">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
@@ -66,7 +63,7 @@ The Excalidraw editor (npm package) supports:
- 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support.
- 🌐&nbsp;Localization (i18n) support.
- 👅&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...

View File

@@ -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.
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.
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.
**Usage**
@@ -25,7 +25,7 @@ function App() {
}
```
This will only work for `Desktop` devices.
This will only 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.
@@ -65,4 +65,4 @@ const App = () => (
// Need to render when code is span across multiple components
// in Live Code blocks editor
render(<App />);
```
```

View File

@@ -363,7 +363,13 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts
(
tool: (
| { type: ToolType }
| (
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: "custom"; customType: string }
) & { locked?: boolean },
) => {};
@@ -371,7 +377,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor

View File

@@ -3,7 +3,7 @@
All `props` are _optional_.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; 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 |
| [`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. |
| [`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. |
| [`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. |
| [`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 |
@@ -29,9 +29,8 @@ All `props` are _optional_.
| [`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 |
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
### Storing custom data on Excalidraw elements

View File

@@ -24,16 +24,36 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
[http://localhost:3001](http://localhost:3001) will open in your default browser.
This is the same example as the [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
This is the same example as the [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/mrazator/release-v18/examples/with-script-in-browser) example.
## Releasing
### Create a test release
You can create a test release by posting the below comment in your pull request:
```bash
@excalibot trigger release
```
Once the version is released `@excalibot` will post a comment with the release version.
### Creating a production release
To release the next stable version follow the below steps:
```bash
yarn release --tag=latest --version=0.19.0
yarn prerelease:excalidraw
```
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
The next step is to run the `release` script:
```bash
yarn release:excalidraw
```
This will publish the package.
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.

View File

@@ -52,4 +52,4 @@ Excalidraw takes _100%_ of `width` and `height` of the containing block so make
## Demo
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/mrazator/release-v18/examples/with-script-in-browser) example.

View File

@@ -38,8 +38,6 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers
import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{
@@ -133,7 +131,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
{/* Link should be updated to point to the latest! */}
Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs.vercel.app/).
The `types` are available at `@excalidraw/excalidraw/types`, check [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example for details.
The `types` are available at `@excalidraw/excalidraw/types`, check [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/mrazator/release-v18/examples/with-script-in-browser) example for details.
### Preact
@@ -208,7 +206,7 @@ import TabItem from "@theme/TabItem";
```js showLineNumbers
// See https://www.npmjs.com/package/@excalidraw/excalidraw documentation.
import * as ExcalidrawLib from 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.js?external=react,react-dom';
import * as ExcalidrawLib from 'https://esm.sh/@excalidraw/excalidraw@0.18.0-rc.1/dist/dev/index.js?external=react,react-dom';
import React from "https://esm.sh/react@19.0.0";
import ReactDOM from "https://esm.sh/react-dom@19.0.0"
@@ -237,4 +235,4 @@ root.render(React.createElement(App));
</TabItem>
</Tabs>
You can try it out [here](https://jsfiddle.net/vfn6dm14/3/).
You can try it out [here](https://jsfiddle.net/64y130L8/1/).

View File

@@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.18.0",
"@excalidraw/excalidraw": "0.18.0-rc.5",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",

View File

@@ -1,6 +1,5 @@
import clsx from "clsx";
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
const FeatureList = [

View File

@@ -1,6 +1,5 @@
import clsx from "clsx";
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
type FeatureItem = {

View File

@@ -1,11 +1,10 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
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";
import HomepageFeatures from "@site/src/components/Homepage";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();

View File

@@ -1,6 +1,6 @@
// Import the original mapper
import Highlight from "@site/src/components/Highlight";
import MDXComponents from "@theme-original/MDXComponents";
import Highlight from "@site/src/components/Highlight";
export default {
// Re-use the default mapping

View File

@@ -12,7 +12,7 @@ if (ExecutionEnvironment.canUseDOM) {
const Excalidraw = React.forwardRef((props, ref) => {
if (!window.EXCALIDRAW_ASSET_PATH) {
window.EXCALIDRAW_ASSET_PATH =
"https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/";
"https://esm.sh/@excalidraw/excalidraw@0.18.0-rc.5/dist/prod/";
}
const { colorMode } = useColorMode();
@@ -33,7 +33,6 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;

View File

@@ -1735,16 +1735,16 @@
url-loader "^4.1.1"
webpack "^5.73.0"
"@excalidraw/excalidraw@0.18.0":
version "0.18.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.18.0.tgz#9f818e2df80a8735af54f8cc21da67997785532f"
integrity sha512-QkIiS+5qdy8lmDWTKsuy0sK/fen/LRDtbhm2lc2xcFcqhv2/zdg95bYnl+wnwwXGHo7kEmP65BSiMHE7PJ3Zpw==
"@excalidraw/excalidraw@0.18.0-rc.5":
version "0.18.0-rc.5"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.18.0-rc.5.tgz#c55598e01808693702251322e59bf9dba931b6e0"
integrity sha512-f6Z6cWlx+FWl1nxCu5F6OdKm9ooV/FPjndjIfFhGLVyERKATXhuNwZUHWwqsEW+SIGmiPG2515NG9KIOhjGd5g==
dependencies:
"@braintree/sanitize-url" "6.0.2"
"@excalidraw/laser-pointer" "1.3.1"
"@excalidraw/mermaid-to-excalidraw" "1.1.2"
"@excalidraw/random-username" "1.1.0"
"@radix-ui/react-popover" "1.1.6"
"@radix-ui/react-popover" "1.0.3"
"@radix-ui/react-tabs" "1.0.2"
browser-fs-access "0.29.1"
canvas-roundrect-polyfill "0.0.1"
@@ -1796,32 +1796,25 @@
resolved "https://registry.yarnpkg.com/@excalidraw/random-username/-/random-username-1.1.0.tgz#6f388d6a9708cf655b8c9c6aa3fa569ee71ecf0f"
integrity sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==
"@floating-ui/core@^1.6.0":
version "1.6.9"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.9.tgz#64d1da251433019dafa091de9b2886ff35ec14e6"
integrity sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==
dependencies:
"@floating-ui/utils" "^0.2.9"
"@floating-ui/core@^0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.7.3.tgz#d274116678ffae87f6b60e90f88cc4083eefab86"
integrity sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==
"@floating-ui/dom@^1.0.0":
version "1.6.13"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34"
integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==
"@floating-ui/dom@^0.5.3":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.5.4.tgz#4eae73f78bcd4bd553ae2ade30e6f1f9c73fe3f1"
integrity sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==
dependencies:
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.9"
"@floating-ui/core" "^0.7.3"
"@floating-ui/react-dom@^2.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31"
integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==
"@floating-ui/react-dom@0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.7.2.tgz#0bf4ceccb777a140fc535c87eb5d6241c8e89864"
integrity sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg==
dependencies:
"@floating-ui/dom" "^1.0.0"
"@floating-ui/utils@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429"
integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==
"@floating-ui/dom" "^0.5.3"
use-isomorphic-layout-effect "^1.1.1"
"@hapi/hoek@^9.0.0":
version "9.3.0"
@@ -1989,17 +1982,13 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3"
integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==
"@radix-ui/react-arrow@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab"
integrity sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==
"@radix-ui/react-arrow@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz#5246adf79e97f89e819af68da51ddcf349ecf1c4"
integrity sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==
dependencies:
"@radix-ui/react-primitive" "2.0.2"
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-collection@1.0.1":
version "1.0.1"
@@ -2019,11 +2008,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec"
integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==
"@radix-ui/react-context@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.0.tgz#f38e30c5859a9fb5e9aa9a9da452ee3ed9e0aee0"
@@ -2031,11 +2015,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a"
integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==
"@radix-ui/react-direction@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.0.tgz#a2e0b552352459ecf96342c79949dd833c1e6e45"
@@ -2043,30 +2022,34 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz#96dde2be078c694a621e55e047406c58cd5fe774"
integrity sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==
"@radix-ui/react-dismissable-layer@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.2.tgz#f04d1061bddf00b1ca304148516b9ddc62e45fb2"
integrity sha512-WjJzMrTWROozDqLB0uRWYvj4UuXsM/2L19EmQ3Au+IJWqwvwq9Bwd+P8ivo0Deg9JDPArR1I6MbWNi1CmXsskg==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-escape-keydown" "1.1.0"
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.0"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-escape-keydown" "1.0.2"
"@radix-ui/react-focus-guards@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe"
integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==
"@radix-ui/react-focus-scope@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz#c0a4519cd95c772606a82fc5b96226cd7fdd2602"
integrity sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==
"@radix-ui/react-focus-guards@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz#339c1c69c41628c1a5e655f15f7020bf11aa01fa"
integrity sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@babel/runtime" "^7.13.10"
"@radix-ui/react-focus-scope@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.1.tgz#faea8c25f537c5a5c38c50914b63722db0e7f951"
integrity sha512-Ej2MQTit8IWJiS2uuujGUmxXjF/y5xZptIIQnyd2JHLwtV0R2j9NRVoRj/1j/gJ7e3REdaBw4Hjf4a1ImhkZcQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-id@1.0.0":
version "1.0.0"
@@ -2076,57 +2059,52 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-id@1.1.0":
"@radix-ui/react-popover@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.3.tgz#65ae2ee1fca2d7fd750308549eb8e0857c6160fe"
integrity sha512-YwedSukfWsyJs3/yP3yXUq44k4/JBe3jqU63Z8v2i19qZZ3dsx32oma17ztgclWPNuqp3A+Xa9UiDlZHyVX8Vg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.0"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-dismissable-layer" "1.0.2"
"@radix-ui/react-focus-guards" "1.0.0"
"@radix-ui/react-focus-scope" "1.0.1"
"@radix-ui/react-id" "1.0.0"
"@radix-ui/react-popper" "1.1.0"
"@radix-ui/react-portal" "1.0.1"
"@radix-ui/react-presence" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-slot" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.0"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-popper@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed"
integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.0.tgz#2be7e4c0cd4581f54277ca33a981c9037d2a8e60"
integrity sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.0"
"@babel/runtime" "^7.13.10"
"@floating-ui/react-dom" "0.7.2"
"@radix-ui/react-arrow" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-use-rect" "1.0.0"
"@radix-ui/react-use-size" "1.0.0"
"@radix-ui/rect" "1.0.0"
"@radix-ui/react-popover@1.1.6":
version "1.1.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.6.tgz#699634dbc7899429f657bb590d71fb3ca0904087"
integrity sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==
"@radix-ui/react-portal@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.1.tgz#169c5a50719c2bb0079cf4c91a27aa6d37e5dd33"
integrity sha512-NY2vUWI5WENgAT1nfC6JS7RU5xRYBfjZVLq0HmgEN1Ezy3rk/UruMV4+Rd0F40PEaFC5SrLS1ixYvcYIQrb4Ig==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-dismissable-layer" "1.1.5"
"@radix-ui/react-focus-guards" "1.1.1"
"@radix-ui/react-focus-scope" "1.1.2"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-popper" "1.2.2"
"@radix-ui/react-portal" "1.1.4"
"@radix-ui/react-presence" "1.1.2"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-use-controllable-state" "1.1.0"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-popper@1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.2.tgz#d2e1ee5a9b24419c5936a1b7f6f472b7b412b029"
integrity sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==
dependencies:
"@floating-ui/react-dom" "^2.0.0"
"@radix-ui/react-arrow" "1.1.2"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-use-rect" "1.1.0"
"@radix-ui/react-use-size" "1.1.0"
"@radix-ui/rect" "1.1.0"
"@radix-ui/react-portal@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.4.tgz#ff5401ff63c8a825c46eea96d3aef66074b8c0c8"
integrity sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==
dependencies:
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-presence@1.0.0":
version "1.0.0"
@@ -2137,14 +2115,6 @@
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-presence@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.2.tgz#bb764ed8a9118b7ec4512da5ece306ded8703cdc"
integrity sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-primitive@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz#c1ebcce283dd2f02e4fbefdaa49d1cb13dbc990a"
@@ -2153,13 +2123,6 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.1"
"@radix-ui/react-primitive@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz#ac8b7854d87b0d7af388d058268d9a7eb64ca8ef"
integrity sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==
dependencies:
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-roving-focus@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz#d8ac2e3b8006697bdfc2b0eb06bef7e15b6245de"
@@ -2184,13 +2147,6 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-slot@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz#daffff7b2bfe99ade63b5168407680b93c00e1c6"
integrity sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-tabs@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz#8f5ec73ca41b151a413bdd6e00553408ff34ce07"
@@ -2213,11 +2169,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==
"@radix-ui/react-use-controllable-state@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz#a64deaafbbc52d5d407afaa22d493d687c538b7f"
@@ -2226,19 +2177,13 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-controllable-state@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0"
integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==
"@radix-ui/react-use-escape-keydown@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.2.tgz#09ab6455ab240b4f0a61faf06d4e5132c4d639f6"
integrity sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-escape-keydown@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754"
integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.0"
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-layout-effect@1.0.0":
version "1.0.0"
@@ -2247,29 +2192,28 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==
"@radix-ui/react-use-rect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88"
integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==
"@radix-ui/react-use-rect@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz#b040cc88a4906b78696cd3a32b075ed5b1423b3e"
integrity sha512-TB7pID8NRMEHxb/qQJpvSt3hQU4sqNPM1VCTjTRjEOa7cEop/QMuq8S6fb/5Tsz64kqSvB9WnwsDHtjnrM9qew==
dependencies:
"@radix-ui/rect" "1.1.0"
"@babel/runtime" "^7.13.10"
"@radix-ui/rect" "1.0.0"
"@radix-ui/react-use-size@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b"
integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==
"@radix-ui/react-use-size@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.0.tgz#a0b455ac826749419f6354dc733e2ca465054771"
integrity sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.0"
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/rect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
"@radix-ui/rect@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.0.tgz#0dc8e6a829ea2828d53cbc94b81793ba6383bf3c"
integrity sha512-d0O68AYy/9oeEy1DdC07bz1/ZXX+DqCskRd3i4JzLSTXwefzaepQrKjXC7aNM8lTHjFLDO0pDgaEiQ7jEk+HVg==
dependencies:
"@babel/runtime" "^7.13.10"
"@sideway/address@^4.1.3":
version "4.1.4"
@@ -3008,7 +2952,7 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.2.4:
aria-hidden@^1.1.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522"
integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==
@@ -7539,7 +7483,7 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
"@types/react" "*"
prop-types "^15.6.2"
react-remove-scroll-bar@^2.3.7:
react-remove-scroll-bar@^2.3.3:
version "2.3.8"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223"
integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==
@@ -7547,16 +7491,16 @@ react-remove-scroll-bar@^2.3.7:
react-style-singleton "^2.2.2"
tslib "^2.0.0"
react-remove-scroll@^2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2"
integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==
react-remove-scroll@2.5.5:
version "2.5.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77"
integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==
dependencies:
react-remove-scroll-bar "^2.3.7"
react-style-singleton "^2.2.3"
react-remove-scroll-bar "^2.3.3"
react-style-singleton "^2.2.1"
tslib "^2.1.0"
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-router-config@^5.1.1:
version "5.1.1"
@@ -7599,7 +7543,7 @@ react-simple-code-editor@^0.10.0:
resolved "https://registry.yarnpkg.com/react-simple-code-editor/-/react-simple-code-editor-0.10.0.tgz#73e7ac550a928069715482aeb33ccba36efe2373"
integrity sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
react-style-singleton@^2.2.1, react-style-singleton@^2.2.2:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==
@@ -8861,7 +8805,7 @@ url-parse-lax@^3.0.0:
dependencies:
prepend-http "^2.0.0"
use-callback-ref@^1.3.3:
use-callback-ref@^1.3.0:
version "1.3.3"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf"
integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==
@@ -8885,7 +8829,7 @@ use-latest@^1.2.1:
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-sidecar@^1.1.3:
use-sidecar@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"
integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==

View File

@@ -1,3 +1,5 @@
version: "3.8"
services:
excalidraw:
build:

View File

@@ -3,8 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:packages": "yarn --cwd ../../ build:packages",
"build:workspace": "yarn build:packages && yarn copy:assets",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",

View File

@@ -1,6 +1,5 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically

View File

@@ -1,11 +1,10 @@
"use client";
import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../with-script-in-browser/components/ExampleApp";
import "@excalidraw/excalidraw/index.css";
import App from "../../with-script-in-browser/components/ExampleApp";
const ExcalidrawWrapper: React.FC = () => {
return (
<>

View File

@@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically

View File

@@ -1,5 +1,4 @@
import React from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";

View File

@@ -52,7 +52,7 @@
transform: none;
}
.excalidraw .selected-shape-actions {
.excalidraw .panelColumn {
text-align: left;
}

View File

@@ -1,4 +1,3 @@
import { nanoid } from "nanoid";
import React, {
useEffect,
useState,
@@ -7,24 +6,13 @@ import React, {
Children,
cloneElement,
} from "react";
import ExampleSidebar from "./sidebar/ExampleSidebar";
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 initialData from "../initialData";
import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import {
resolvablePromise,
distance2d,
@@ -35,12 +23,25 @@ import {
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import initialData from "../initialData";
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 type { ResolvablePromise } from "../utils";
type Comment = {
x: number;
y: number;
@@ -104,7 +105,6 @@ export default function ExampleApp({
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
@@ -193,7 +193,6 @@ export default function ExampleApp({
}) => setPointerData(payload),
viewModeEnabled,
zenModeEnabled,
renderScrollbars,
gridModeEnabled,
theme,
name: "Custom name of drawing",
@@ -712,14 +711,6 @@ export default function ExampleApp({
/>
Grid mode
</label>
<label>
<input
type="checkbox"
checked={renderScrollbars}
onChange={() => setRenderScrollbars(!renderScrollbars)}
/>
Render scrollbars
</label>
<label>
<input
type="checkbox"

View File

@@ -1,9 +1,7 @@
import React from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";
const MobileFooter = ({
excalidrawAPI,

View File

@@ -1,5 +1,4 @@
import React, { useState } from "react";
import "./ExampleSidebar.scss";
export default function Sidebar({ children }: { children: React.ReactNode }) {

View File

@@ -12,8 +12,9 @@
<script>
window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH =
"https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/";
"https://esm.sh/@excalidraw/excalidraw@0.18.0-rc.5/dist/prod/";
</script>
<link rel="stylesheet" href="/dist/dev/index.css" />
</head>
<body>

View File

@@ -1,11 +1,10 @@
import App from "./components/ExampleApp";
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@excalidraw/excalidraw/index.css";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import App from "./components/ExampleApp";
import "@excalidraw/excalidraw/index.css";
declare global {
interface Window {

View File

@@ -5,7 +5,7 @@
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"@excalidraw/excalidraw": "0.18.0-rc.5",
"browser-fs-access": "0.29.1"
},
"devDependencies": {
@@ -15,8 +15,6 @@
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:packages": "yarn --cwd ../../ build:packages"
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@@ -1,6 +1,6 @@
import { MIME_TYPES } from "@excalidraw/excalidraw";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/excalidraw";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;

View File

@@ -1,5 +1,4 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:packages && yarn build"
"installCommand": "yarn install"
}

View File

@@ -1,3 +1,24 @@
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 {
Excalidraw,
LiveCollaborationTrigger,
@@ -5,22 +26,15 @@ import {
CaptureUpdateAction,
reconcileElements,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "@excalidraw/excalidraw/types";
import type { ResolvablePromise } from "@excalidraw/excalidraw/utils";
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,
VERSION_TIMEOUT,
debounce,
getVersion,
getFrame,
@@ -28,14 +42,75 @@ import {
preventUnload,
resolvablePromise,
isRunningInIframe,
isDevEnv,
} from "@excalidraw/common";
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
} from "@excalidraw/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
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 {
GithubIcon,
XBrandIcon,
@@ -46,84 +121,6 @@ import {
share,
youtubeIcon,
} 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,
localStorageQuotaExceededAtom,
} 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 { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
@@ -134,10 +131,7 @@ import DebugCanvas, {
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import "./index.scss";
import type { CollabAPI } from "./collab/Collab";
import { isElementLink } from "@excalidraw/excalidraw/element/elementLink";
polyfill();
@@ -383,7 +377,7 @@ const ExcalidrawWrapper = () => {
const [, forceRefresh] = useState(false);
useEffect(() => {
if (isDevEnv()) {
if (import.meta.env.DEV) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
@@ -499,6 +493,11 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@@ -589,6 +588,7 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -602,13 +602,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
)
) {
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
@@ -728,8 +722,6 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState],
@@ -904,15 +896,10 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="alertalert--warning">
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
@@ -1143,7 +1130,6 @@ const ExcalidrawWrapper = () => {
ref={debugCanvasRef}
/>
)}
{/* <FreedrawDebugSliders /> */}
</Excalidraw>
</div>
);

View File

@@ -1,21 +1,15 @@
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 type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils";
import {
getElementsStorageSize,
getTotalStorageSize,
} 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 };

View File

@@ -1,15 +1,13 @@
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";

View File

@@ -1,8 +1,6 @@
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
import React from "react";
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {

View File

@@ -1,5 +1,5 @@
import { defaultLang, languages } from "@excalidraw/excalidraw";
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "@excalidraw/excalidraw";
export const languageDetector = new LanguageDetector();

View File

@@ -1,7 +1,5 @@
import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());

View File

@@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
// should be aligned with MAX_ALLOWED_FILE_BYTES
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;

View File

@@ -1,45 +1,5 @@
import {
CaptureUpdateAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
assertNever,
isDevEnv,
isTestEnv,
preventUnload,
resolvablePromise,
throttleRAF,
} 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,
@@ -47,9 +7,28 @@ import type {
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
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 {
CaptureUpdateAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
reconcileElements,
} from "@excalidraw/excalidraw";
import {
assertNever,
preventUnload,
resolvablePromise,
throttleRAF,
} from "@excalidraw/excalidraw/utils";
import {
CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
@@ -60,17 +39,15 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
} from "../data";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
loadFilesFromFirebase,
@@ -82,15 +59,36 @@ import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
} from "../data/localStorage";
import { resetBrowserStateVersions } from "../data/tabSync";
import { collabErrorIndicatorAtom } from "./CollabError";
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 { 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 type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "@excalidraw/excalidraw/data/reconcile";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
@@ -238,7 +236,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
appJotaiStore.set(collabAPIAtom, collabAPI);
if (isTestEnv() || isDevEnv()) {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.collab = window.collab || ({} as Window["collab"]);
Object.defineProperties(window, {
collab: {
@@ -298,13 +296,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements);
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
preventUnload(event);
}
});
@@ -530,10 +522,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null;
}
if (existingRoomLinkData) {
// when joining existing room, don't merge it with current scene data
this.excalidrawAPI.resetScene();
} else {
if (!existingRoomLinkData) {
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" });
@@ -1020,7 +1009,7 @@ declare global {
}
}
if (isTestEnv() || isDevEnv()) {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.collab = window.collab || ({} as Window["collab"]);
}

View File

@@ -2,7 +2,6 @@ import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { warning } from "@excalidraw/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss";

View File

@@ -1,26 +1,25 @@
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 {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
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 { CaptureUpdateAction } from "@excalidraw/excalidraw";
class Portal {
collab: TCollabClass;

View File

@@ -1,3 +1,4 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import {
DiagramToCodePlugin,
exportToBlob,
@@ -6,9 +7,7 @@ import {
TTDDialog,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { safelyParseJSON } from "@excalidraw/excalidraw/utils";
export const AIComponents = ({
excalidrawAPI,
@@ -73,7 +72,7 @@ export const AIComponents = ({
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,

View File

@@ -1,11 +1,9 @@
import { Footer } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { Footer } from "@excalidraw/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {

View File

@@ -1,18 +1,13 @@
import React from "react";
import {
loginIcon,
ExcalLogo,
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { MainMenu } from "@excalidraw/excalidraw/index";
import React from "react";
import { isDevEnv } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
@@ -59,7 +54,7 @@ export const AppMainMenu: React.FC<{
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{isDevEnv() && (
{import.meta.env.DEV && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {

View File

@@ -1,10 +1,9 @@
import React from "react";
import { loginIcon } from "@excalidraw/excalidraw/components/icons";
import { POINTER_EVENTS } from "@excalidraw/common";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { WelcomeScreen } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
export const AppWelcomeScreen: React.FC<{
onCollabDialogOpen: () => any;

View File

@@ -1,30 +1,24 @@
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 {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "@excalidraw/excalidraw/components/icons";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import { STORAGE_KEYS } from "../app_constants";
import type { Curve } from "../../packages/math";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug";
import { STORAGE_KEYS } from "../app_constants";
} from "../../packages/math";
import { isCurve } from "../../packages/math/curve";
const renderLine = (
context: CanvasRenderingContext2D,
@@ -115,6 +109,10 @@ const _debugRenderer = (
scale,
);
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({
canvas,
scale,
@@ -312,29 +310,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps {
appState: AppState;
scale: number;
ref?: React.Ref<HTMLCanvasElement>;
}
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
const { width, height } = appState;
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
);
},
);
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
export default DebugCanvas;

View File

@@ -1,5 +1,5 @@
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { shield } from "@excalidraw/excalidraw/components/icons";
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { useI18n } from "@excalidraw/excalidraw/i18n";
export const EncryptedIcon = () => {
@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank"
rel="noopener"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>

View File

@@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noopener"
rel="noreferrer"
className="plus-button"
>
Go to Excalidraw+

View File

@@ -1,33 +1,31 @@
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 { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
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 { isInitializedImageElement } from "@excalidraw/element";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import type {
FileId,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
} 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 { encodeFilesForUpload } from "../data/FileManager";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { uploadBytes, ref } from "firebase/storage";
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 (
elements: readonly NonDeletedExcalidrawElement[],

View File

@@ -1,150 +0,0 @@
import { STROKE_OPTIONS, isFreeDrawElement } from "@excalidraw/element";
import { useState, useEffect } from "react";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useExcalidrawElements } from "@excalidraw/excalidraw/components/App";
import { round } from "../../packages/math/src";
export const FreedrawDebugSliders = () => {
const [streamline, setStreamline] = useState<number>(
STROKE_OPTIONS.default.streamline,
);
const [simplify, setSimplify] = useState<number>(
STROKE_OPTIONS.default.simplify,
);
useEffect(() => {
if (!window.h) {
window.h = {} as any;
}
if (!window.h.debugFreedraw) {
window.h.debugFreedraw = {
enabled: true,
...STROKE_OPTIONS.default,
};
}
setStreamline(window.h.debugFreedraw.streamline);
setSimplify(window.h.debugFreedraw.simplify);
}, []);
const handleStreamlineChange = (value: number) => {
setStreamline(value);
if (window.h && window.h.debugFreedraw) {
window.h.debugFreedraw.streamline = value;
}
};
const handleSimplifyChange = (value: number) => {
setSimplify(value);
if (window.h && window.h.debugFreedraw) {
window.h.debugFreedraw.simplify = value;
}
};
const [enabled, setEnabled] = useState<boolean>(
window.h?.debugFreedraw?.enabled ?? true,
);
// counter incrasing each 50ms
const [, setCounter] = useState<number>(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prev) => prev + 1);
}, 50);
return () => clearInterval(interval);
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const elements = useExcalidrawElements();
const appState = useUIAppState();
const newFreedrawElement =
appState.newElement && isFreeDrawElement(appState.newElement)
? appState.newElement
: null;
return (
<div
style={{
position: "absolute",
bottom: "70px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
padding: "10px",
borderRadius: "8px",
border: "1px solid #ccc",
display: "flex",
flexDirection: "column",
gap: "8px",
fontSize: "12px",
fontFamily: "monospace",
}}
>
{newFreedrawElement && (
<div>
pressures:{" "}
{newFreedrawElement.simulatePressure
? "simulated"
: JSON.stringify(
newFreedrawElement.pressures
.slice(-4)
.map((x) => round(x, 2))
.join(" ") || [],
)}{" "}
({round(window.__lastPressure__ || 0, 2) || "?"})
</div>
)}
<div>
<label>
{" "}
enabled
<br />
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
if (window.h.debugFreedraw) {
window.h.debugFreedraw.enabled = e.target.checked;
setEnabled(e.target.checked);
}
}}
/>
</label>
</div>
<div>
<label>
Streamline: {streamline.toFixed(2)}
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={streamline}
onChange={(e) => handleStreamlineChange(parseFloat(e.target.value))}
style={{ width: "150px" }}
/>
</label>
</div>
<div>
<label>
Simplify: {simplify.toFixed(2)}
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={simplify}
onChange={(e) => handleSimplifyChange(parseFloat(e.target.value))}
style={{ width: "150px" }}
/>
</label>
</div>
</div>
);
};

View File

@@ -1,8 +1,7 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import type { Theme } from "@excalidraw/element/types";
import { THEME } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(

View File

@@ -1,7 +1,7 @@
import Trans from "@excalidraw/excalidraw/components/Trans";
import { t } from "@excalidraw/excalidraw/i18n";
import * as Sentry from "@sentry/browser";
import React from "react";
import * as Sentry from "@sentry/browser";
import { t } from "@excalidraw/excalidraw/i18n";
import Trans from "@excalidraw/excalidraw/components/Trans";
interface TopErrorBoundaryState {
hasError: boolean;

View File

@@ -1,15 +1,14 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { compressData } from "@excalidraw/excalidraw/data/encode";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
import type {
BinaryFileData,
BinaryFileMetadata,

View File

@@ -10,13 +10,6 @@
* (localStorage, indexedDB).
*/
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import {
createStore,
entries,
@@ -26,29 +19,32 @@ import {
setMany,
get,
} from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
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 { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
import type {
ExcalidrawElement,
FileId,
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import type { MaybePromise } from "@excalidraw/excalidraw/utility-types";
import { debounce } from "@excalidraw/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => {
@@ -73,9 +69,6 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try {
const _appState = clearAppStateForLocalStorage(appState);
@@ -95,22 +88,12 @@ const saveDataStateToLocalStorage = (
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, true);
}
}
};
const isQuotaExceededError = (error: any) => {
return error instanceof DOMException && error.name === "QuotaExceededError";
};
type SavingLockTypes = "collaboration";
export class LocalData {

View File

@@ -1,12 +1,27 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/common";
import type {
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 {
encryptData,
decryptData,
} from "@excalidraw/excalidraw/data/encryption";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import { getSceneVersion } from "@excalidraw/element";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import { initializeApp } from "firebase/app";
import {
getFirestore,
@@ -16,27 +31,8 @@ import {
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
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";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
// private
// -----------------------------------------------------------------------------
@@ -259,9 +255,7 @@ export const loadFromFirebase = async (
}
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
}),
restoreElements(await decryptElements(storedScene, roomKey), null),
);
if (socket) {

View File

@@ -9,38 +9,34 @@ import {
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
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 { SceneBounds } from "@excalidraw/element";
import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
import type {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
} from "@excalidraw/excalidraw/types";
import type { MakeBrand } from "@excalidraw/common/utility-types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants";
import type { MakeBrand } from "@excalidraw/excalidraw/utility-types";
import { bytesToHexString } from "@excalidraw/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
import type { WS_SUBTYPES } from "../app_constants";
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;
@@ -258,16 +254,11 @@ export const loadScene = async (
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
{ repairBindings: true, refreshDimensions: false },
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}

View File

@@ -1,12 +1,10 @@
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants";
export const saveUsernameToLocalStorage = (username: string) => {

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Excalidraw Whiteboard</title>
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +14,7 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
name="description"

View File

@@ -58,7 +58,7 @@
}
}
.alert {
.collab-offline-warning {
pointer-events: none;
position: absolute;
top: 6.5rem;
@@ -69,18 +69,10 @@
text-align: center;
line-height: 1.5;
border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6;
white-space: pre;
&--warning {
background-color: var(--color-warning);
color: var(--color-text-warning);
}
&--danger {
background-color: var(--color-danger-dark);
color: var(--color-danger-text);
}
}
}

View File

@@ -1,11 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./App";
import { registerSW } from "virtual:pwa-register";
import "../excalidraw-app/sentry";
import ExcalidrawApp from "./App";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement);

View File

@@ -1,8 +1,10 @@
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { useEffect, useRef, useState } from "react";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS } from "@excalidraw/excalidraw/keys";
import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import {
copyIcon,
LinkIcon,
@@ -12,19 +14,16 @@ import {
shareIOS,
shareWindows,
} 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 { 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 { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss";
import type { CollabAPI } from "../collab/Collab";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";

View File

@@ -0,0 +1,34 @@
import { defaultLang } from "@excalidraw/excalidraw/i18n";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
screen,
fireEvent,
waitFor,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
describe("Test LanguageList", () => {
it("rerenders UI on language change", async () => {
await render(<ExcalidrawApp />);
// select rectangle tool to show properties menu
UI.clickTool("rectangle");
// english lang should display `thin` label
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: "de-DE" },
});
// switching to german, `thin` label should no longer exist
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
// reset language
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: defaultLang.code },
});
// switching back to English
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
});
});

View File

@@ -1,11 +1,11 @@
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import ExcalidrawApp from "../App";
import {
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
describe("Test MobileMenu", () => {
const { h } = window;
@@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
},
"isTouchScreen": false,
"viewport": {
"isLandscape": true,
"isLandscape": false,
"isMobile": true,
},
}

View File

@@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<a
class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noopener"
rel="noreferrer"
target="_blank"
>
<div

View File

@@ -1,18 +1,13 @@
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
import { vi } from "vitest";
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 {
createRedoAction,
createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory";
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";
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
const { h } = window;
@@ -69,79 +64,6 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
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 () => {
await render(<ExcalidrawApp />);
const rect1Props = {
@@ -199,13 +121,12 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history);
const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -232,7 +153,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history);
const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!
@@ -248,5 +169,79 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});

View File

@@ -1,9 +1,8 @@
import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
import { THEME } from "@excalidraw/excalidraw";
import { EVENT } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { CODES, KEYS } from "@excalidraw/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>

View File

@@ -23,57 +23,29 @@ export default defineConfig(({ mode }) => {
envDir: "../",
resolve: {
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$/,
replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
},
{
find: /^@excalidraw\/excalidraw\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/excalidraw/$1"),
},
{
find: /^@excalidraw\/math$/,
replacement: path.resolve(__dirname, "../packages/math/src/index.ts"),
},
{
find: /^@excalidraw\/math\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/math/src/$1"),
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
replacement: path.resolve(__dirname, "../packages/utils/index.ts"),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
replacement: path.resolve(__dirname, "../packages/utils/$1"),
},
{
find: /^@excalidraw\/math$/,
replacement: path.resolve(__dirname, "../packages/math/index.ts"),
},
{
find: /^@excalidraw\/math\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/math/$1"),
},
],
},
@@ -225,7 +197,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id: "excalidraw",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",

View File

@@ -4,7 +4,9 @@
"packageManager": "yarn@1.22.22",
"workspaces": [
"excalidraw-app",
"packages/*",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/*"
],
"devDependencies": {
@@ -24,7 +26,6 @@
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",
@@ -33,7 +34,6 @@
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
@@ -52,17 +52,13 @@
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:common": "yarn --cwd ./packages/common build:esm",
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -80,12 +76,11 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
"release:latest": "node scripts/release.js --tag=latest",
"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": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {

View File

@@ -1,3 +0,0 @@
{
"extends": ["../eslintrc.base.json"]
}

View File

@@ -1,19 +0,0 @@
# @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
```

View File

@@ -1,3 +0,0 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@@ -1,59 +0,0 @@
{
"name": "@excalidraw/common",
"version": "0.18.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",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"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"
}
}

View File

@@ -1,12 +0,0 @@
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";

View File

@@ -1,50 +0,0 @@
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);
});
}
}

View File

@@ -1,82 +0,0 @@
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);
});
});
});

View File

@@ -1,8 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}

View File

@@ -1,3 +0,0 @@
{
"extends": ["../eslintrc.base.json"]
}

View File

@@ -1,19 +0,0 @@
# @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
```

View File

@@ -1,3 +0,0 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@@ -1,63 +0,0 @@
{
"name": "@excalidraw/element",
"version": "0.18.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",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"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"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
}
}

View File

@@ -1,556 +0,0 @@
import { isTransparent } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointFromVector,
pointRotateRads,
pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import {
ellipse,
ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse";
import type {
Curve,
GlobalPoint,
LineSegment,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
type Bounds,
doBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
getElementBounds,
} from "./bounds";
import {
hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
return false;
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
if (element.type === "freedraw") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs = {
point: GlobalPoint;
element: ExcalidrawElement;
threshold: number;
elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null;
};
export const hitElementItself = ({
point,
element,
threshold,
elementsMap,
frameNameBound = null,
}: HitTestArgs) => {
// Hit test against a frame's name
const hitFrameName = frameNameBound
? isPointWithinBounds(
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
point,
pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
pointRotateRads(
point,
getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
}
// Do the precise (and relatively costly) hit test
const hitElement = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
};
export const hitElementBoundingBox = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
};
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
elementsMap: ElementsMap,
) =>
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
export const hitElementBoundText = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
};
/**
* Intersect a line with an element for binding test
*
* @param element
* @param line
* @param offset
* @returns
*/
export const intersectElementWithLineSegment = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "selection":
case "magicframe":
return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond":
return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse":
return intersectEllipseWithLineSegment(
element,
elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
}
};
const curveIntersections = (
curves: Curve<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
for (const j of hits) {
intersections.push(pointRotateRads(j, center, angle));
}
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const lineIntersections = (
lines: LineSegment<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(pointRotateRads(intersection, center, angle));
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>(
segment[0],
center,
-element.angle as Radians,
);
const rotatedB = pointRotateRads<GlobalPoint>(
segment[1],
center,
-element.angle as Radians,
);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset);
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
*
* @param element
* @param a
* @param b
* @returns
*/
const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
const [sides, corners] = deconstructDiamondElement(element, offset);
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
*
* @param element
* @param a
* @param b
* @returns
*/
const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle));
};
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};

View File

@@ -1,487 +0,0 @@
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,
});
};

View File

@@ -1,373 +0,0 @@
import { LaserPointer, type Point } from "@excalidraw/laser-pointer";
import {
clamp,
lineSegment,
pointFrom,
pointRotateRads,
round,
type LocalPoint,
} from "@excalidraw/math";
import getStroke from "perfect-freehand";
import { invariant } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import { getElementBounds } from "./bounds";
import type { StrokeOptions } from "perfect-freehand";
import type {
ElementsMap,
ExcalidrawFreeDrawElement,
PointerType,
} from "./types";
export const STROKE_OPTIONS: Record<
PointerType | "default",
{ streamline: number; simplify: number }
> = {
default: {
streamline: 0.35,
simplify: 0.1,
},
mouse: {
streamline: 0.6,
simplify: 0.1,
},
pen: {
// for optimal performance, we use a lower streamline and simplify
streamline: 0.2,
simplify: 0.1,
},
touch: {
streamline: 0.65,
simplify: 0.1,
},
} as const;
export const getFreedrawConfig = (eventType: string | null | undefined) => {
return (
STROKE_OPTIONS[(eventType as PointerType | null) || "default"] ||
STROKE_OPTIONS.default
);
};
/**
* Calculates simulated pressure based on velocity between consecutive points.
* Fast movement (large distances) -> lower pressure
* Slow movement (small distances) -> higher pressure
*/
const calculateVelocityBasedPressure = (
points: readonly LocalPoint[],
index: number,
fixedStrokeWidth: boolean | undefined,
maxDistance = 8, // Maximum expected distance for normalization
): number => {
if (fixedStrokeWidth) {
return 1;
}
// First point gets highest pressure
// This avoid "a dot followed by a line" effect, •== when first stroke is "slow"
if (index === 0) {
return 1;
}
const [x1, y1] = points[index - 1];
const [x2, y2] = points[index];
// Calculate distance between consecutive points
const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
// Normalize distance and invert for pressure (0 = fast/low pressure, 1 = slow/high pressure)
const normalizedDistance = Math.min(distance / maxDistance, 1);
const basePressure = Math.max(0.1, 1 - normalizedDistance * 0.7); // Range: 0.1 to 1.0
const constantPressure = 0.5;
const pressure = constantPressure + (basePressure - constantPressure);
return Math.max(0.1, Math.min(1.0, pressure));
};
export const getFreedrawStroke = (element: ExcalidrawFreeDrawElement) => {
// Compose points as [x, y, pressure]
let points: [number, number, number][];
if (element.freedrawOptions?.fixedStrokeWidth) {
points = element.points.map(
([x, y]: LocalPoint): [number, number, number] => [x, y, 1],
);
} else if (element.simulatePressure) {
// Simulate pressure based on velocity between consecutive points
points = element.points.map(([x, y]: LocalPoint, i) => [
x,
y,
calculateVelocityBasedPressure(
element.points,
i,
element.freedrawOptions?.fixedStrokeWidth,
),
]);
} else {
points = element.points.map(([x, y]: LocalPoint, i) => {
const rawPressure = element.pressures?.[i] ?? 0.5;
const amplifiedPressure = Math.pow(rawPressure, 0.6);
const adjustedPressure = amplifiedPressure;
return [x, y, clamp(adjustedPressure, 0.1, 1.0)];
});
}
const streamline =
element.freedrawOptions?.streamline ?? STROKE_OPTIONS.default.streamline;
const simplify =
element.freedrawOptions?.simplify ?? STROKE_OPTIONS.default.simplify;
const laser = new LaserPointer({
size: element.strokeWidth,
streamline,
simplify,
sizeMapping: ({ pressure: t }) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.6;
}
if (element.simulatePressure) {
return 0.2 + t * 0.6;
}
return 0.2 + t * 0.8;
},
});
for (const pt of points) {
laser.addPoint(pt);
}
laser.close();
return laser.getStrokeOutline();
};
/**
* Generates an SVG path for a freedraw element using LaserPointer logic.
* Uses actual pressure data if available, otherwise simulates pressure based on velocity.
* No streamline, smoothing, or simulation is performed.
*/
export const getFreeDrawSvgPath = (
element: ExcalidrawFreeDrawElement,
): string => {
// legacy, for backwards compatibility
if (element.freedrawOptions === null) {
return _legacy_getFreeDrawSvgPath(element);
}
return _transition_getFreeDrawSvgPath(element);
// return getSvgPathFromStroke(getFreedrawStroke(element));
};
const roundPoint = (A: Point): string => {
return `${round(A[0], 4, "round")},${round(A[1], 4, "round")} `;
};
const average = (A: Point, B: Point): string => {
return `${round((A[0] + B[0]) / 2, 4, "round")},${round(
(A[1] + B[1]) / 2,
4,
"round",
)} `;
};
export const getSvgPathFromStroke = (points: Point[]): string => {
const len = points.length;
if (len < 2) {
return "";
}
let a = points[0];
let b = points[1];
if (len === 2) {
return `M${roundPoint(a)}L${roundPoint(b)}`;
}
let result = "";
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += average(a, b);
}
return `M${roundPoint(points[0])}Q${roundPoint(points[1])}${average(
points[1],
points[2],
)}${points.length > 3 ? "T" : ""}${result}L${roundPoint(points[len - 1])}`;
};
function _transition_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.5;
}
return Math.sin((t * Math.PI) / 2) * 0.65;
}, // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
function _legacy_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const _legacy_getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
};
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}

View File

@@ -1,282 +0,0 @@
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
import {
pointFrom,
pointFromVector,
pointRotateRads,
pointScaleFromOrigin,
pointsEqual,
triangleIncludesPoint,
vectorCross,
vectorFromPoint,
vectorScale,
} from "@excalidraw/math";
import type {
LocalPoint,
GlobalPoint,
Triangle,
Vector,
} from "@excalidraw/math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;
export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec;
const absX = Math.abs(x);
const absY = Math.abs(y);
if (x > absY) {
return HEADING_RIGHT;
} else if (x <= -absY) {
return HEADING_LEFT;
} else if (y > absX) {
return HEADING_DOWN;
}
return HEADING_UP;
};
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => vectorToHeading(vectorFromPoint<P>(p, o));
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => headingIsHorizontal(headingForPoint<P>(p, o));
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
export const headingIsHorizontal = (a: Heading) =>
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
const headingForPointFromDiamondElement = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<GlobalPoint>,
): Heading => {
const midPoint = getCenterForBounds(aabb);
if (isDevEnv() || isTestEnv()) {
invariant(
element.width > 0 && element.height > 0,
"Diamond element has no width or height",
);
invariant(
!pointsEqual(midPoint, point),
"The point is too close to the element mid point to determine heading",
);
}
const SHRINK = 0.95; // Rounded elements tolerance
const top = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const right = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const bottom = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const left = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
// Corners
if (
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
0 &&
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
) {
return headingForPoint(top, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, right),
vectorFromPoint(right, bottom),
) <= 0 &&
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
) {
return headingForPoint(right, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, left),
) <= 0 &&
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, right),
) > 0
) {
return headingForPoint(bottom, midPoint);
} else if (
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
0 &&
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
) {
return headingForPoint(left, midPoint);
}
// Sides
if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(top, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) > 0
) {
const p = element.width > element.height ? top : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(left, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : left;
return headingForPoint(p, midPoint);
}
const p = element.width > element.height ? top : left;
return headingForPoint(p, midPoint);
};
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = <Point extends GlobalPoint>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
p: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
return headingForPointFromDiamondElement(element, aabb, p);
}
const topLeft = pointScaleFromOrigin(
pointFrom(aabb[0], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const topRight = pointScaleFromOrigin(
pointFrom(aabb[2], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const bottomLeft = pointScaleFromOrigin(
pointFrom(aabb[0], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const bottomRight = pointScaleFromOrigin(
pointFrom(aabb[2], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
return triangleIncludesPoint<Point>(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: triangleIncludesPoint<Point>(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: triangleIncludesPoint<Point>(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
? HEADING_DOWN
: HEADING_LEFT;
};
export const flipHeading = (h: Heading): Heading =>
[
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
] as Heading;

View File

@@ -1,112 +0,0 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,483 +0,0 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "@excalidraw/common";
import {
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment,
pointDistance,
pointFrom,
pointFromArray,
rectangle,
type GlobalPoint,
} from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import type {
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
>();
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
offset: number,
): ElementShape | undefined => {
const record = ElementShapesCache.get(element);
if (!record) {
return undefined;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.delete(element);
return undefined;
}
return shapes.get(offset);
};
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
shape: ElementShape,
offset: number,
) => {
const record = ElementShapesCache.get(element);
if (!record) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
shapes.set(offset, shape);
};
/**
* Returns the **rotated** components of freedraw, line or arrow elements.
*
* @param element The linear element to deconstruct
* @returns The rotated in components.
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = [];
const curves = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
lines.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
curves.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
const shape = [lines, curves] as ElementShape;
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
/**
* Get the building components of a rectanguloid element in the form of
* line segments and curves **unrotated**.
*
* @param element Target rectanguloid element
* @param offset Optional offset to expand the rectanguloid shape
* @returns Tuple of **unrotated** line segments (0) and curves (1)
*/
export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (radius === 0) {
radius = 0.01;
}
const r = rectangle(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
);
const baseCorners = [
curve(
left[1],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
top[0],
), // TOP LEFT
curve(
top[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
right[0],
), // TOP RIGHT
curve(
right[1],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
bottom[1],
), // BOTTOM RIGHT
curve(
bottom[0],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
left[0],
), // BOTTOM LEFT
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY),
pointFrom(element.x + rightX, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY),
pointFrom(element.x + leftX, element.y + leftY),
];
const baseCorners = [
curve(
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
right,
right,
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), // RIGHT
curve(
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
bottom,
bottom,
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), // BOTTOM
curve(
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
left,
left,
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), // LEFT
curve(
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
top,
top,
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), // TOP
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
}
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};

View File

@@ -1,38 +0,0 @@
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});

View File

@@ -1,573 +0,0 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = {
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {},
activeLockedId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElement: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElement,
};
const prevAppState2: ObservedAppState = {
selectedLinearElement: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElement,
name,
...commonAppState,
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedElementIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: { id5: true, id2: true, id4: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id1: true,
id2: true,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: { id4: true, id2: true, id5: true },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id3: true,
id2: true,
id1: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedGroupIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id0: true,
id1: true,
id2: false,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id3: true,
id2: false,
id1: true,
id0: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
});

View File

@@ -1,128 +0,0 @@
import {
distributeHorizontally,
distributeVertically,
} from "@excalidraw/excalidraw/actions";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
unmountComponent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
const mouse = new Pointer("mouse");
// Scenario: three rectangles that will be distributed with gaps
const createAndSelectThreeRectanglesWithGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
// Scenario: three rectangles that will be distributed by their centers
const createAndSelectThreeRectanglesWithoutGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(200, 200);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
describe("distributing", () => {
beforeEach(async () => {
unmountComponent();
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(300);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(300);
});
it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(300);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(300);
});
it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
});

View File

@@ -1,849 +0,0 @@
import { pointFrom } from "@excalidraw/math";
import {
FONT_FAMILY,
ORIG_ID,
ROUNDNESS,
isPrimitive,
} from "@excalidraw/common";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { LocalPoint } from "@excalidraw/math";
import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
expect(clone[key]).not.toBe(source[key]);
if (source[key]) {
assertCloneObjects(source[key], clone[key]);
}
}
}
};
describe("duplicating single elements", () => {
it("clones arrow element", () => {
const element = API.createElement({
type: "arrow",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 1,
opacity: 100,
});
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, new Map(), {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
});
const copy = duplicateElement(null, new Map(), element, true);
assertCloneObjects(element, copy);
// assert we clone the object's prototype
// @ts-ignore
expect(copy.__proto__).toEqual({ hello: "world" });
expect(copy.hasOwnProperty("hello")).toBe(false);
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
version: copy.version,
versionNonce: copy.versionNonce,
});
});
it("clones text element", () => {
const element = API.createElement({
type: "text",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 1,
opacity: 100,
text: "hello",
fontSize: 20,
fontFamily: FONT_FAMILY.Virgil,
textAlign: "left",
verticalAlign: "top",
});
const copy = duplicateElement(null, new Map(), element);
assertCloneObjects(element, copy);
expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(typeof copy.seed).toBe("number");
});
});
describe("duplicating multiple elements", () => {
it("duplicateElements should clone bindings", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
{ id: "text1", type: "text" },
],
});
const text1 = API.createElement({
type: "text",
id: "text1",
containerId: "rectangle1",
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
boundElements: [{ id: "text2", type: "text" }],
});
const text2 = API.createElement({
type: "text",
id: "text2",
containerId: "arrow2",
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const { duplicatedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
// generic id in-equality checks
// --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual(
duplicatedElements.map((e) => e.type),
);
origElements.forEach((origElement, idx) => {
const clonedElement = duplicatedElements[idx];
expect(origElement).toEqual(
expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id),
type: clonedElement.type,
}),
);
if ("containerId" in origElement) {
expect(origElement.containerId).not.toBe(
(clonedElement as any).containerId,
);
}
if ("endBinding" in origElement) {
if (origElement.endBinding) {
expect(origElement.endBinding.elementId).not.toBe(
(clonedElement as any).endBinding?.elementId,
);
} else {
expect((clonedElement as any).endBinding).toBeNull();
}
}
if ("startBinding" in origElement) {
if (origElement.startBinding) {
expect(origElement.startBinding.elementId).not.toBe(
(clonedElement as any).startBinding?.elementId,
);
} else {
expect((clonedElement as any).startBinding).toBeNull();
}
}
});
// --------------------------------------------------------------------------
const clonedArrows = duplicatedElements.filter(
(e) => e.type === "arrow",
) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
duplicatedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect(
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
).toEqual(
expect.objectContaining({
id: clonedText1.id,
type: clonedText1.type,
}),
);
expect(clonedRectangle.type).toBe("rectangle");
clonedArrows.forEach((arrow) => {
expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual(
expect.objectContaining({
id: arrow.id,
type: arrow.type,
}),
);
if (arrow.endBinding) {
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
}
if (arrow.startBinding) {
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
}
});
expect(clonedArrow2.boundElements).toEqual([
{ type: "text", id: clonedArrowLabel.id },
]);
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
});
it("should remove id references of elements that aren't found", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
// should keep
{ id: "arrow1", type: "arrow" },
// should drop
{ id: "arrow-not-exists", type: "arrow" },
// should drop
{ id: "text-not-exists", type: "text" },
],
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
const text1 = API.createElement({
type: "text",
id: "text1",
containerId: "rectangle-not-exists",
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
const arrow3 = API.createElement({
type: "arrow",
id: "arrow3",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const duplicatedElements = duplicateElements({
type: "everything",
elements: origElements,
}).duplicatedElements as any as typeof origElements;
const [
clonedRectangle,
clonedText1,
clonedArrow1,
clonedArrow2,
clonedArrow3,
] = duplicatedElements;
expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" },
]);
expect(clonedText1.containerId).toBe(null);
expect(clonedArrow2.startBinding).toEqual({
...arrow2.startBinding,
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
elementId: clonedRectangle.id,
});
});
describe("should duplicate all group ids", () => {
it("should regenerate all group ids and keep them consistent across elements", () => {
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const origElements = [rectangle1, rectangle2, rectangle3] as const;
const { duplicatedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
duplicatedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
});
it("should keep and regenerate ids of groups even if invalid", () => {
// lone element shouldn't be able to be grouped with itself,
// but hard to check against in a performant way so we ignore it
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const {
duplicatedElements: [clonedRectangle1],
} = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
});
});
});
describe("group-related duplication", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("action-duplicating within group", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
expect(h.state.editingGroupId).toBe("group1");
});
it("alt-duplicating within group", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
expect(h.state.editingGroupId).toBe("group1");
});
it("alt-duplicating within group away outside frame", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["group1"],
frameId: frame.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
width: 50,
height: 50,
groupIds: ["group1"],
frameId: frame.id,
});
API.setElements([frame, rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle1.id, frameId: frame.id },
{ id: rectangle2.id, frameId: frame.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
]);
expect(h.state.editingGroupId).toBe(null);
});
});
describe("duplication z-order", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("duplication z order with Cmd+D for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
API.setSelectedElements([rectangle1]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with Cmd+D for the highest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
API.setSelectedElements([rectangle3]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with alt+drag for the highest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle3);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle3.x + 5, rectangle3.y + 5);
mouse.up(rectangle3.x + 5, rectangle3.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with alt+drag with grouped elements should consider the group together when determining z-index", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 15, rectangle1.y + 15);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ [ORIG_ID]: rectangle2.id, selected: true },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("alt-duplicating text container (in-order)", async () => {
const [rectangle, text] = API.createTextContainer();
API.setElements([rectangle, text]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5);
mouse.up(rectangle.x + 15, rectangle.y + 15);
});
assertElements(h.elements, [
{ id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
},
]);
});
it("alt-duplicating text container (out-of-order)", async () => {
const [rectangle, text] = API.createTextContainer();
API.setElements([text, rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5);
mouse.up(rectangle.x + 15, rectangle.y + 15);
});
assertElements(h.elements, [
{ id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
},
]);
});
it("alt-duplicating labeled arrows (in-order)", async () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5);
mouse.up(arrow.x + 15, arrow.y + 15);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id,
},
]);
expect(h.state.selectedLinearElement).toEqual(
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
);
});
it("alt-duplicating labeled arrows (out-of-order)", async () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([text, arrow]);
API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5);
mouse.up(arrow.x + 15, arrow.y + 15);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id,
},
]);
});
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
const rect = UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -100,
y: 50,
width: 95,
height: 0,
});
expect(arrow.endBinding?.elementId).toBe(rect.id);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(5, 5);
mouse.up(15, 15);
});
assertElements(h.elements, [
{
id: rect.id,
boundElements: expect.arrayContaining([
expect.objectContaining({ id: arrow.id }),
]),
},
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
{
id: arrow.id,
endBinding: expect.objectContaining({ elementId: rect.id }),
},
]);
});
});

View File

@@ -1,153 +0,0 @@
import { getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
expectedStart: 90,
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
expectedStart: 120,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
expectedStart: 150,
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should parse YouTube URLs with timestamp in time format", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
expectedStart: 90, // 1*60 + 30
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
expectedStart: 165, // 2*60 + 45
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
expectedStart: 3723, // 1*3600 + 2*60 + 3
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
expectedStart: 45,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
expectedStart: 300, // 5*60
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
expectedStart: 7200, // 2*3600
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should handle YouTube URLs without timestamps", () => {
const testCases = [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
];
testCases.forEach((url) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).not.toContain("start=");
}
});
});
it("should handle YouTube shorts URLs with timestamps", () => {
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=30");
}
// Shorts should have portrait aspect ratio
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
});
it("should handle playlist URLs with timestamps", () => {
const url =
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=60");
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
}
});
it("should handle malformed or edge case timestamps", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
expectedStart: 0, // Invalid timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
expectedStart: 0, // Empty timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
expectedStart: 0, // Zero timestamp should be handled
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
if (expectedStart === 0) {
expect(result.link).not.toContain("start=");
} else {
expect(result.link).toContain(`start=${expectedStart}`);
}
}
});
});
it("should preserve other URL parameters", () => {
const url =
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=90");
expect(result.link).toContain("enablejsapi=1");
}
});
});

Some files were not shown because too many files have changed in this diff Show More