Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be5d0bd925 | |||
| 292aa1cddb | |||
| e95222ed32 | |||
| d87620b239 | |||
| 7cc31ac64a | |||
| 071b17a217 | |||
| 859207b8bc | |||
| becaabfa0f | |||
| f06484c6ab | |||
| bf4c65f483 | |||
| 8d18078f5c | |||
| d080833f4d | |||
| 451bcac0b7 | |||
| 06f01e11f8 | |||
| 51ad8951d4 | |||
| 7497a08270 | |||
| 210dc85c8c | |||
| 019ce4c52c | |||
| c141960ada | |||
| d7e63e66a7 | |||
| b660478164 | |||
| 37882c66cb | |||
| 7f66e1fe89 | |||
| 2b4540225d | |||
| dc2f25c14a | |||
| 8fb16669ab | |||
| f2600fe3e8 | |||
| 95ddc66339 | |||
| 5bcd8280c9 | |||
| c99e81678b | |||
| d1f39823f1 | |||
| 47cbb5b6fb | |||
| 8fd970320e | |||
| 8d8f696628 | |||
| 19b3dc658a | |||
| 4e0441eeb4 | |||
| 8013eb5e16 | |||
| 725412ebd3 | |||
| 7da176ff7d | |||
| 5fffc4743f | |||
| 8608d7b2e0 | |||
| 19b03b4ca9 | |||
| 416e8b3e42 | |||
| 98e0cd9078 | |||
| f3c16a600d | |||
| 835eb8d2fd | |||
| fde796a7a0 | |||
| 7c41944856 | |||
| f1b097ad06 | |||
| 9fcbbe0d27 | |||
| ec070911b8 | |||
| dcdeb2be57 | |||
| a8acc8212d | |||
| a89a03c66c | |||
| e32836f799 | |||
| 06c40006db | |||
| 91c7748c3d | |||
| f738b74791 | |||
| 00ae455873 | |||
| 06c5ea94d3 | |||
| f55ecb96cc | |||
| a6a32b9b29 | |||
| ac0d3059dc | |||
| 1161f1b8ba | |||
| 204e06b77b | |||
| 414182f599 | |||
| b9d27d308e | |||
| 3bdaafe4b5 | |||
| ae89608985 | |||
| 3085f4af81 | |||
| 531f3e5524 | |||
| 90ec2739ae | |||
| f29e9df72d | |||
| b5ad7ae4e3 | |||
| c78e4aab7f | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe | |||
| 178eca5828 | |||
| cb33de25f4 | |||
| 37ad85cbaf | |||
| d6a934ed19 | |||
| 416da62138 | |||
| f38f381989 | |||
| e5e07260c6 | |||
| 8492b144b0 | |||
| e46f038132 | |||
| 678dff25ed | |||
| 0cfa53b764 | |||
| cde46793f8 | |||
| 2d127f8c22 | |||
| 4eadb891f8 | |||
| 258605d1d5 | |||
| c141500400 | |||
| 8e27de2cdc | |||
| 0a19c93509 | |||
| 958597dfaa | |||
| 058918f8e5 | |||
| 3f194918e6 | |||
| 93c92d13e9 | |||
| 84e96e9393 | |||
| 320af405e9 | |||
| 60512f13d5 | |||
| f0458cc216 | |||
| 9f3fdf5505 | |||
| f42e1ab64e | |||
| 18808481fd | |||
| a7b64f02b3 | |||
| 0d4abd1ddc | |||
| 9e77373c81 | |||
| d108053351 | |||
| d4e85a9480 | |||
| 08cd4c4f9a | |||
| 469caadb87 | |||
| ca1a4f25e7 | |||
| 56c05b3099 | |||
| 6c0ff7fc5c | |||
| 7cad3645a0 | |||
| 5921ebc416 | |||
| 864353be5f | |||
| db2911c6c4 | |||
| fc3e062074 | |||
| 87c87a9fb1 | |||
| 4dc205537c | |||
| cc571c4681 | |||
| 14d512f321 | |||
| 41c036e1a5 | |||
| 91d36e9b81 | |||
| 27522110df | |||
| 712f267519 | |||
| 41a7613dff | |||
| 95d89a751a | |||
| 6b5fb30d69 | |||
| d92a849038 | |||
| 0a534f1bc6 | |||
| 4ca5f53b1f | |||
| f7dcc893ea | |||
| 4dfb8a3f8e | |||
| 298812e1d0 | |||
| 35bb449a4b | |||
| c4c064982f | |||
| 51dbd4831b | |||
| 7e41026812 | |||
| a8ebe514da | |||
| a30e1b25c6 | |||
| ff2ed5d26a | |||
| e058a08b33 | |||
| a306a909a0 | |||
| 3dc54a724a | |||
| a7c61319dd | |||
| cec5232a7a | |||
| d4f70e9f31 | |||
| e19fd1332a | |||
| 6e655cdb24 | |||
| 192c4e7658 | |||
| 195a743874 | |||
| 4a60fe3d22 | |||
| 2a0d15799c | |||
| a18b139a60 |
@@ -1,3 +1,5 @@
|
|||||||
|
MODE="development"
|
||||||
|
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
MODE="production"
|
||||||
|
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
"name": "jotai",
|
"name": "jotai",
|
||||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-no-target-blank": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allowReferrer": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Project coding standards
|
||||||
|
|
||||||
|
## Generic Communication Guidelines
|
||||||
|
|
||||||
|
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||||
|
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||||
|
- Stop apologising if corrected, just provide the correct information or code
|
||||||
|
- Prefer code unless asked for explanation
|
||||||
|
- Stop summarizing what you've changed after modifications unless asked for
|
||||||
|
|
||||||
|
## TypeScript Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript for all new code
|
||||||
|
- Where possible, prefer implementations without allocation
|
||||||
|
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||||
|
- Prefer immutable data (const, readonly)
|
||||||
|
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||||
|
|
||||||
|
## React Guidelines
|
||||||
|
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Follow the React hooks rules (no conditional hooks)
|
||||||
|
- Keep components small and focused
|
||||||
|
- Use CSS modules for component styling
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Use PascalCase for component names, interfaces, and type aliases
|
||||||
|
- Use camelCase for variables, functions, and methods
|
||||||
|
- Use ALL_CAPS for constants
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Use try/catch blocks for async operations
|
||||||
|
- Implement proper error boundaries in React components
|
||||||
|
- Always log errors with contextual information
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Always attempt to fix #problems
|
||||||
|
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
||||||
@@ -12,10 +12,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
- name: Set up publish access
|
- name: Set up publish access
|
||||||
run: |
|
run: |
|
||||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core -W
|
yarn add @actions/core -W
|
||||||
yarn autorelease
|
yarn release --tag=next --non-interactive
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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 }}"
|
|
||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
|
||||||
- name: Install and lint
|
- name: Install and lint
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
|
||||||
- name: Create report file
|
- name: Create report file
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: excalidraw/excalidraw:latest
|
tags: excalidraw/excalidraw:latest
|
||||||
|
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
- name: Install and build
|
- name: Install and build
|
||||||
run: |
|
run: |
|
||||||
yarn --frozen-lockfile
|
yarn --frozen-lockfile
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
- name: Install in packages/excalidraw
|
- name: Install in packages/excalidraw
|
||||||
run: yarn
|
run: yarn
|
||||||
working-directory: packages/excalidraw
|
working-directory: packages/excalidraw
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ coverage
|
|||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
meta*.json
|
meta*.json
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||||
|
|
||||||
|
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||||
|
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||||
|
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||||
|
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Package Development**: Work in `packages/*` for editor features
|
||||||
|
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||||
|
3. **Testing**: Always run `yarn test:update` before committing
|
||||||
|
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test:typecheck # TypeScript type checking
|
||||||
|
yarn test:update # Run all tests (with snapshot updates)
|
||||||
|
yarn fix # Auto-fix formatting and linting issues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
### Package System
|
||||||
|
|
||||||
|
- Uses Yarn workspaces for monorepo management
|
||||||
|
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||||
|
- Build system uses esbuild for packages, Vite for the app
|
||||||
|
- TypeScript throughout with strict configuration
|
||||||
+5
-4
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18 AS build
|
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||||
|
|
||||||
WORKDIR /opt/node_app
|
WORKDIR /opt/node_app
|
||||||
|
|
||||||
@@ -6,13 +6,14 @@ COPY . .
|
|||||||
|
|
||||||
# do not ignore optional dependencies:
|
# do not ignore optional dependencies:
|
||||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||||
RUN yarn --network-timeout 600000
|
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||||
|
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -23,20 +23,17 @@
|
|||||||
<br />
|
<br />
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
||||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
||||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
|
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
|
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://discord.gg/UexuTaE">
|
<a href="https://discord.gg/UexuTaE">
|
||||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||||
</a>
|
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||||
|
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||||
<a href="https://twitter.com/excalidraw">
|
<a href="https://twitter.com/excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -63,7 +60,7 @@ The Excalidraw editor (npm package) supports:
|
|||||||
- 🏗️ Customizable.
|
- 🏗️ Customizable.
|
||||||
- 📷 Image support.
|
- 📷 Image support.
|
||||||
- 😀 Shape libraries support.
|
- 😀 Shape libraries support.
|
||||||
- 👅 Localization (i18n) support.
|
- 🌐 Localization (i18n) support.
|
||||||
- 🖼️ Export to PNG, SVG & clipboard.
|
- 🖼️ Export to PNG, SVG & clipboard.
|
||||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
|
|||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: "500px"}}>
|
<div style={{ height: "500px" }}>
|
||||||
<Excalidraw>
|
<Excalidraw>
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
@@ -27,19 +27,19 @@ function App() {
|
|||||||
|
|
||||||
This will only work for `Desktop` devices.
|
This will only work for `Desktop` devices.
|
||||||
|
|
||||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useEditorInterface`](#useEditorInterface) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||||
|
|
||||||
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
||||||
|
|
||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
if (device.editor.isMobile) {
|
if (editorInterface.formFactor === "phone") {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
className="custom-footer"
|
className="custom-footer"
|
||||||
style= {{ marginLeft: '20px', height: '2rem'}}
|
style={{ marginLeft: "20px", height: "2rem" }}
|
||||||
onClick={() => alert("This is custom footer in mobile menu")}
|
onClick={() => alert("This is custom footer in mobile menu")}
|
||||||
>
|
>
|
||||||
custom footer
|
custom footer
|
||||||
|
|||||||
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
```ts
|
```ts
|
||||||
(
|
(
|
||||||
tool: (
|
tool: (
|
||||||
| (
|
| { type: ToolType }
|
||||||
| { type: Exclude<ToolType, "image"> }
|
|
||||||
| {
|
|
||||||
type: Extract<ToolType, "image">;
|
|
||||||
insertOnCanvasDirectly?: boolean;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
| { type: "custom"; customType: string }
|
| { type: "custom"; customType: string }
|
||||||
) & { locked?: boolean },
|
) & { locked?: boolean },
|
||||||
) => {};
|
) => {};
|
||||||
@@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
|
|
||||||
| Name | Type | Default | Description |
|
| 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. 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` |
|
| `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 |
|
||||||
| `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 |
|
| `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
|
## setCursor
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords({ clientX: number, clientY: number },<br/> 
|
|||||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
### useDevice
|
### useEditorInterface
|
||||||
|
|
||||||
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
||||||
|
|
||||||
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
|
|||||||
|
|
||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
if (device.editor.isMobile) {
|
if (editorInterface.formFactor === "phone") {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
@@ -336,12 +336,20 @@ render(<App />);
|
|||||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||||
|
|
||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
| --- | --- | --- |
|
| ---- | ---- | ----------- |
|
||||||
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
|
|
||||||
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
|
The `EditorInterface` object has the following properties:
|
||||||
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
|
|
||||||
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
|
| Name | Type | Description |
|
||||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
|
||||||
|
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
|
||||||
|
| `userAgent.raw` | `string` | Raw user agent string |
|
||||||
|
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
|
||||||
|
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
|
||||||
|
| `isTouchScreen` | `boolean` | True if touch events are detected |
|
||||||
|
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
|
||||||
|
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
|
|||||||
@@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
|||||||
|
|
||||||
## Releasing
|
## 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
|
### Creating a production release
|
||||||
|
|
||||||
To release the next stable version follow the below steps:
|
To release the next stable version follow the below steps:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn prerelease:excalidraw
|
yarn release --tag=latest --version=0.19.0
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
|||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
const Excalidraw = dynamic(
|
const Excalidraw = dynamic(
|
||||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
|||||||
initialData,
|
initialData,
|
||||||
useI18n: ExcalidrawComp.useI18n,
|
useI18n: ExcalidrawComp.useI18n,
|
||||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||||
|
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExcalidrawScope;
|
export default ExcalidrawScope;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
"build:packages": "yarn --cwd ../../ build:packages",
|
||||||
|
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||||
"dev": "yarn build:workspace && next dev -p 3005",
|
"dev": "yarn build:workspace && next dev -p 3005",
|
||||||
"build": "yarn build:workspace && next build",
|
"build": "yarn build:workspace && next build",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw .panelColumn {
|
.excalidraw .selected-shape-actions {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ const MobileFooter = ({
|
|||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
excalidrawLib: typeof TExcalidraw;
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}) => {
|
}) => {
|
||||||
const { useDevice, Footer } = excalidrawLib;
|
const { useEditorInterface, Footer } = excalidrawLib;
|
||||||
|
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
if (device.editor.isMobile) {
|
if (editorInterface.formFactor === "phone") {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<CustomFooter
|
<CustomFooter
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5002",
|
"preview": "vite preview --port 5002",
|
||||||
"build:preview": "yarn build && yarn preview",
|
"build:preview": "yarn build && yarn preview",
|
||||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
"build:packages": "yarn --cwd ../../ build:packages"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"outputDirectory": "dist",
|
"outputDirectory": "dist",
|
||||||
"installCommand": "yarn install",
|
"installCommand": "yarn install",
|
||||||
"buildCommand": "yarn build:package && yarn build"
|
"buildCommand": "yarn build:packages && yarn build"
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-13
@@ -4,6 +4,7 @@ import {
|
|||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
CaptureUpdateAction,
|
CaptureUpdateAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
|
useEditorInterface,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
TITLE_TIMEOUT,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -47,10 +47,10 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
import { isElementLink } from "@excalidraw/element/elementLink";
|
import { isElementLink } from "@excalidraw/element";
|
||||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
parseLibraryTokensFromUrl,
|
parseLibraryTokensFromUrl,
|
||||||
@@ -120,6 +120,7 @@ import {
|
|||||||
LibraryIndexedDBAdapter,
|
LibraryIndexedDBAdapter,
|
||||||
LibraryLocalStorageMigrationAdapter,
|
LibraryLocalStorageMigrationAdapter,
|
||||||
LocalData,
|
LocalData,
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
} from "./data/LocalData";
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
@@ -137,6 +138,9 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
|||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
|
||||||
|
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
|
||||||
|
import { AppSidebar } from "./components/AppSidebar";
|
||||||
|
|
||||||
import type { CollabAPI } from "./collab/Collab";
|
import type { CollabAPI } from "./collab/Collab";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
@@ -342,6 +346,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
|
const editorInterface = useEditorInterface();
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -499,11 +505,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleTimeout = setTimeout(
|
|
||||||
() => (document.title = APP_NAME),
|
|
||||||
TITLE_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@@ -594,7 +595,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
clearTimeout(titleTimeout);
|
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
@@ -669,8 +669,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
debugRenderer(
|
debugRenderer(
|
||||||
debugCanvasRef.current,
|
debugCanvasRef.current,
|
||||||
appState,
|
appState,
|
||||||
|
elements,
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
() => forceRefresh((prev) => !prev),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -734,6 +734,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
|
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||||
[setShareDialogState],
|
[setShareDialogState],
|
||||||
@@ -852,14 +854,22 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="top-right-ui">
|
<div className="excalidraw-ui-top-right">
|
||||||
|
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
||||||
|
<ExcalidrawPlusPromoBanner
|
||||||
|
isSignedIn={isExcalidrawPlusSignedUser}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{collabError.message && <CollabError collabError={collabError} />}
|
{collabError.message && <CollabError collabError={collabError} />}
|
||||||
<LiveCollaborationTrigger
|
<LiveCollaborationTrigger
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
setShareDialogState({ isOpen: true, type: "share" })
|
setShareDialogState({ isOpen: true, type: "share" })
|
||||||
}
|
}
|
||||||
|
editorInterface={editorInterface}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -908,10 +918,15 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="alertalert--warning">
|
||||||
{t("alerts.collabOfflineWarning")}
|
{t("alerts.collabOfflineWarning")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{localStorageQuotaExceeded && (
|
||||||
|
<div className="alert alert--danger">
|
||||||
|
{t("alerts.localStorageQuotaExceeded")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{latestShareableLink && (
|
{latestShareableLink && (
|
||||||
<ShareableLinkDialog
|
<ShareableLinkDialog
|
||||||
link={latestShareableLink}
|
link={latestShareableLink}
|
||||||
@@ -940,6 +955,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
|||||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||||
|
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,9 @@ import {
|
|||||||
throttleRAF,
|
throttleRAF,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import {
|
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||||
isImageElement,
|
|
||||||
isInitializedImageElement,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
|
||||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||||
@@ -444,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private decryptPayload = async (
|
private decryptPayload = async (
|
||||||
iv: Uint8Array,
|
iv: Uint8Array<ArrayBuffer>,
|
||||||
encryptedData: ArrayBuffer,
|
encryptedData: ArrayBuffer,
|
||||||
decryptionKey: string,
|
decryptionKey: string,
|
||||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||||
@@ -533,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingRoomLinkData) {
|
if (existingRoomLinkData) {
|
||||||
|
// when joining existing room, don't merge it with current scene data
|
||||||
|
this.excalidrawAPI.resetScene();
|
||||||
|
} else {
|
||||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||||
if (isImageElement(element) && element.status === "saved") {
|
if (isImageElement(element) && element.status === "saved") {
|
||||||
return newElementWith(element, { status: "pending" });
|
return newElementWith(element, { status: "pending" });
|
||||||
@@ -562,7 +562,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
// All socket listeners are moving to Portal
|
// All socket listeners are moving to Portal
|
||||||
this.portal.socket.on(
|
this.portal.socket.on(
|
||||||
"client-broadcast",
|
"client-broadcast",
|
||||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
|
||||||
if (!this.portal.roomKey) {
|
if (!this.portal.roomKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -746,7 +746,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
): ReconciledExcalidrawElement[] => {
|
): ReconciledExcalidrawElement[] => {
|
||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
const restoredRemoteElements = restoreElements(remoteElements, null);
|
const restoredRemoteElements = restoreElements(
|
||||||
|
remoteElements,
|
||||||
|
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
|
||||||
|
);
|
||||||
const reconciledElements = reconcileElements(
|
const reconciledElements = reconcileElements(
|
||||||
localElements,
|
localElements,
|
||||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const AIComponents = ({
|
|||||||
</br>
|
</br>
|
||||||
<div>You can also try <a href="${
|
<div>You can also try <a href="${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|||||||
|
|
||||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
|
||||||
|
|
||||||
export const AppFooter = React.memo(
|
export const AppFooter = React.memo(
|
||||||
({ onChange }: { onChange: () => void }) => {
|
({ onChange }: { onChange: () => void }) => {
|
||||||
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
{isExcalidrawPlusSignedUser ? (
|
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
||||||
<ExcalidrawPlusAppLink />
|
|
||||||
) : (
|
|
||||||
<EncryptedIcon />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.app-sidebar-promo-container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-promo-image {
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
height: 16.25rem;
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
background-image: radial-gradient(
|
||||||
|
circle,
|
||||||
|
transparent 60%,
|
||||||
|
var(--sidebar-bg-color) 100%
|
||||||
|
),
|
||||||
|
var(--image-source);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-promo-text {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
|
||||||
|
import {
|
||||||
|
messageCircleIcon,
|
||||||
|
presentationIcon,
|
||||||
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
|
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
|
||||||
|
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||||
|
|
||||||
|
import "./AppSidebar.scss";
|
||||||
|
|
||||||
|
export const AppSidebar = () => {
|
||||||
|
const { theme, openSidebar } = useUIAppState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultSidebar>
|
||||||
|
<DefaultSidebar.TabTriggers>
|
||||||
|
<Sidebar.TabTrigger
|
||||||
|
tab="comments"
|
||||||
|
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{messageCircleIcon}
|
||||||
|
</Sidebar.TabTrigger>
|
||||||
|
<Sidebar.TabTrigger
|
||||||
|
tab="presentation"
|
||||||
|
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{presentationIcon}
|
||||||
|
</Sidebar.TabTrigger>
|
||||||
|
</DefaultSidebar.TabTriggers>
|
||||||
|
<Sidebar.Tab tab="comments">
|
||||||
|
<div className="app-sidebar-promo-container">
|
||||||
|
<div
|
||||||
|
className="app-sidebar-promo-image"
|
||||||
|
style={{
|
||||||
|
["--image-source" as any]: `url(/oss_promo_comments_${
|
||||||
|
theme === THEME.DARK ? "dark" : "light"
|
||||||
|
}.jpg)`,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="app-sidebar-promo-text">
|
||||||
|
Make comments with Excalidraw+
|
||||||
|
</div>
|
||||||
|
<LinkButton
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
|
||||||
|
>
|
||||||
|
Sign up now
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</Sidebar.Tab>
|
||||||
|
<Sidebar.Tab tab="presentation" className="px-3">
|
||||||
|
<div className="app-sidebar-promo-container">
|
||||||
|
<div
|
||||||
|
className="app-sidebar-promo-image"
|
||||||
|
style={{
|
||||||
|
["--image-source" as any]: `url(/oss_promo_presentations_${
|
||||||
|
theme === THEME.DARK ? "dark" : "light"
|
||||||
|
}.svg)`,
|
||||||
|
backgroundSize: "60%",
|
||||||
|
opacity: 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="app-sidebar-promo-text">
|
||||||
|
Create presentations with Excalidraw+
|
||||||
|
</div>
|
||||||
|
<LinkButton
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
|
||||||
|
>
|
||||||
|
Sign up now
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</Sidebar.Tab>
|
||||||
|
</DefaultSidebar>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,8 +8,14 @@ import {
|
|||||||
getNormalizedCanvasDimensions,
|
getNormalizedCanvasDimensions,
|
||||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||||
import { throttleRAF } from "@excalidraw/common";
|
import { arrayToMap, throttleRAF } from "@excalidraw/common";
|
||||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getGlobalFixedPointForBindableElement,
|
||||||
|
isArrowElement,
|
||||||
|
isBindableElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLineSegment,
|
isLineSegment,
|
||||||
@@ -18,9 +24,17 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
import React from "react";
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
import type { DebugElement } from "@excalidraw/common";
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
|
FixedPointBinding,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
@@ -73,6 +87,176 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
|||||||
context.save();
|
context.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _renderBinding = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
binding: FixedPointBinding,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
zoom: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
if (!binding.fixedPoint) {
|
||||||
|
console.warn("Binding must have a fixedPoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindable = elementsMap.get(
|
||||||
|
binding.elementId,
|
||||||
|
) as ExcalidrawBindableElement;
|
||||||
|
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||||
|
binding.fixedPoint,
|
||||||
|
bindable,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x * zoom, y * zoom);
|
||||||
|
context.bezierCurveTo(
|
||||||
|
x * zoom - width,
|
||||||
|
y * zoom - height,
|
||||||
|
x * zoom - width,
|
||||||
|
y * zoom + height,
|
||||||
|
x * zoom,
|
||||||
|
y * zoom,
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const _renderBindableBinding = (
|
||||||
|
binding: FixedPointBinding,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
zoom: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
const bindable = elementsMap.get(
|
||||||
|
binding.elementId,
|
||||||
|
) as ExcalidrawBindableElement;
|
||||||
|
if (!binding.fixedPoint) {
|
||||||
|
console.warn("Binding must have a fixedPoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||||
|
binding.fixedPoint,
|
||||||
|
bindable,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x * zoom, y * zoom);
|
||||||
|
context.bezierCurveTo(
|
||||||
|
x * zoom + width,
|
||||||
|
y * zoom + height,
|
||||||
|
x * zoom + width,
|
||||||
|
y * zoom - height,
|
||||||
|
x * zoom,
|
||||||
|
y * zoom,
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBindings = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
|
zoom: number,
|
||||||
|
) => {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
|
const dim = 16;
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (element.isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArrowElement(element)) {
|
||||||
|
if (element.startBinding) {
|
||||||
|
if (
|
||||||
|
!elementsMap
|
||||||
|
.get(element.startBinding.elementId)
|
||||||
|
?.boundElements?.find((e) => e.id === element.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderBinding(
|
||||||
|
context,
|
||||||
|
element.startBinding,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
element.startBinding?.mode === "orbit" ? "red" : "black",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.endBinding) {
|
||||||
|
if (
|
||||||
|
!elementsMap
|
||||||
|
.get(element.endBinding.elementId)
|
||||||
|
?.boundElements?.find((e) => e.id === element.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_renderBinding(
|
||||||
|
context,
|
||||||
|
element.endBinding,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
element.endBinding?.mode === "orbit" ? "red" : "black",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBindableElement(element) && element.boundElements?.length) {
|
||||||
|
element.boundElements.forEach((boundElement) => {
|
||||||
|
if (boundElement.type !== "arrow") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = elementsMap.get(
|
||||||
|
boundElement.id,
|
||||||
|
) as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
if (arrow && arrow.startBinding?.elementId === element.id) {
|
||||||
|
_renderBindableBinding(
|
||||||
|
arrow.startBinding,
|
||||||
|
context,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
"green",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (arrow && arrow.endBinding?.elementId === element.id) {
|
||||||
|
_renderBindableBinding(
|
||||||
|
arrow.endBinding,
|
||||||
|
context,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
"green",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const render = (
|
const render = (
|
||||||
frame: DebugElement[],
|
frame: DebugElement[],
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@@ -105,18 +289,14 @@ const render = (
|
|||||||
const _debugRenderer = (
|
const _debugRenderer = (
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
scale: number,
|
scale: number,
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
) => {
|
||||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
const context = bootstrapCanvas({
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
@@ -133,6 +313,7 @@ const _debugRenderer = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderOrigin(context, appState.zoom.value);
|
renderOrigin(context, appState.zoom.value);
|
||||||
|
renderBindings(context, elements, appState.zoom.value);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
window.visualDebug?.currentFrame &&
|
window.visualDebug?.currentFrame &&
|
||||||
@@ -184,10 +365,10 @@ export const debugRenderer = throttleRAF(
|
|||||||
(
|
(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
scale: number,
|
scale: number,
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
) => {
|
||||||
_debugRenderer(canvas, appState, scale, refresh);
|
_debugRenderer(canvas, appState, elements, scale);
|
||||||
},
|
},
|
||||||
{ trailing: true },
|
{ trailing: true },
|
||||||
);
|
);
|
||||||
@@ -314,35 +495,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
|||||||
interface DebugCanvasProps {
|
interface DebugCanvasProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
scale: number;
|
scale: number;
|
||||||
ref?: React.Ref<HTMLCanvasElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||||
const { width, height } = appState;
|
({ appState, scale }, ref) => {
|
||||||
|
const { width, height } = appState;
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
return (
|
||||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
<canvas
|
||||||
ref,
|
style={{
|
||||||
() => canvasRef.current,
|
width,
|
||||||
[canvasRef],
|
height,
|
||||||
);
|
position: "absolute",
|
||||||
|
zIndex: 2,
|
||||||
return (
|
pointerEvents: "none",
|
||||||
<canvas
|
}}
|
||||||
style={{
|
width={width * scale}
|
||||||
width,
|
height={height * scale}
|
||||||
height,
|
ref={ref}
|
||||||
position: "absolute",
|
>
|
||||||
zIndex: 2,
|
Debug Canvas
|
||||||
pointerEvents: "none",
|
</canvas>
|
||||||
}}
|
);
|
||||||
width={width * scale}
|
},
|
||||||
height={height * scale}
|
);
|
||||||
ref={canvasRef}
|
|
||||||
>
|
|
||||||
Debug Canvas
|
|
||||||
</canvas>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DebugCanvas;
|
export default DebugCanvas;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
|
|||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
>
|
>
|
||||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|
||||||
|
|
||||||
export const ExcalidrawPlusAppLink = () => {
|
|
||||||
if (!isExcalidrawPlusSignedUser) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`${
|
|
||||||
import.meta.env.VITE_APP_PLUS_APP
|
|
||||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="plus-button"
|
|
||||||
>
|
|
||||||
Go to Excalidraw+
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const ExcalidrawPlusPromoBanner = ({
|
||||||
|
isSignedIn,
|
||||||
|
}: {
|
||||||
|
isSignedIn: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
isSignedIn
|
||||||
|
? import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
: `${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="plus-banner"
|
||||||
|
>
|
||||||
|
Excalidraw+
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
debounce,
|
debounce,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
|
||||||
import {
|
import {
|
||||||
createStore,
|
createStore,
|
||||||
entries,
|
entries,
|
||||||
@@ -27,6 +26,9 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
|
|
||||||
|
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||||
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||||
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
|
|||||||
|
|
||||||
const filesStore = createStore("files-db", "files-store");
|
const filesStore = createStore("files-db", "files-store");
|
||||||
|
|
||||||
|
export const localStorageQuotaExceededAtom = atom(false);
|
||||||
|
|
||||||
class LocalFileManager extends FileManager {
|
class LocalFileManager extends FileManager {
|
||||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||||
await entries(filesStore).then((entries) => {
|
await entries(filesStore).then((entries) => {
|
||||||
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
|
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const _appState = clearAppStateForLocalStorage(appState);
|
const _appState = clearAppStateForLocalStorage(appState);
|
||||||
|
|
||||||
@@ -81,19 +88,29 @@ const saveDataStateToLocalStorage = (
|
|||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
JSON.stringify(getNonDeletedElements(elements)),
|
||||||
);
|
);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
JSON.stringify(_appState),
|
JSON.stringify(_appState),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
|
if (localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Unable to access window.localStorage
|
// Unable to access window.localStorage
|
||||||
console.error(error);
|
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";
|
type SavingLockTypes = "collaboration";
|
||||||
|
|
||||||
export class LocalData {
|
export class LocalData {
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ const decryptElements = async (
|
|||||||
data: FirebaseStoredScene,
|
data: FirebaseStoredScene,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
): Promise<readonly ExcalidrawElement[]> => {
|
): Promise<readonly ExcalidrawElement[]> => {
|
||||||
const ciphertext = data.ciphertext.toUint8Array();
|
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||||
const iv = data.iv.toUint8Array();
|
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||||
|
|
||||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||||
const decodedData = new TextDecoder("utf-8").decode(
|
const decodedData = new TextDecoder("utf-8").decode(
|
||||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import { bytesToHexString } from "@excalidraw/common";
|
import { bytesToHexString } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
import type { SceneBounds } from "@excalidraw/element";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{ repairBindings: true, refreshDimensions: false },
|
{
|
||||||
|
repairBindings: true,
|
||||||
|
refreshDimensions: false,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
clearAppStateForLocalStorage,
|
clearAppStateForLocalStorage,
|
||||||
getDefaultAppState,
|
getDefaultAppState,
|
||||||
} from "@excalidraw/excalidraw/appState";
|
} from "@excalidraw/excalidraw/appState";
|
||||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@@ -50,7 +49,7 @@ export const importFromLocalStorage = () => {
|
|||||||
let elements: ExcalidrawElement[] = [];
|
let elements: ExcalidrawElement[] = [];
|
||||||
if (savedElements) {
|
if (savedElements) {
|
||||||
try {
|
try {
|
||||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
elements = JSON.parse(savedElements);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
// Do nothing because elements array is already empty
|
// Do nothing because elements array is already empty
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
<title>Excalidraw Whiteboard</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
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 -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
+27
-14
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../packages/excalidraw/css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||||
|
|
||||||
@@ -5,12 +7,6 @@
|
|||||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-right-ui {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -58,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.collab-offline-warning {
|
.alert {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6.5rem;
|
top: 6.5rem;
|
||||||
@@ -69,10 +65,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: var(--color-text-warning);
|
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
white-space: pre;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,22 +86,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.plus-button {
|
.plus-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--color-primary);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.875rem;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
color: var(--color-primary) !important;
|
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
font-family: var(--ui-font);
|
||||||
|
font-size: 0.8333rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--lg-button-size);
|
height: var(--lg-button-size);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||||
|
background-color: var(--color-surface-low);
|
||||||
|
color: var(--button-color, var(--color-on-surface)) !important;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
@@ -109,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark {
|
.theme--dark {
|
||||||
.plus-button {
|
.plus-banner {
|
||||||
&:hover {
|
&:hover {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 22.x.x"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getFeatureFlag } from "@excalidraw/common";
|
||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import callsites from "callsites";
|
import callsites from "callsites";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ Sentry.init({
|
|||||||
Sentry.captureConsoleIntegration({
|
Sentry.captureConsoleIntegration({
|
||||||
levels: ["error"],
|
levels: ["error"],
|
||||||
}),
|
}),
|
||||||
|
Sentry.featureFlagsIntegration(),
|
||||||
],
|
],
|
||||||
beforeSend(event) {
|
beforeSend(event) {
|
||||||
if (event.request?.url) {
|
if (event.request?.url) {
|
||||||
@@ -79,3 +81,14 @@ Sentry.init({
|
|||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flagsIntegration =
|
||||||
|
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||||
|
"FeatureFlags",
|
||||||
|
);
|
||||||
|
if (flagsIntegration) {
|
||||||
|
flagsIntegration.addFeatureFlag(
|
||||||
|
"COMPLEX_BINDINGS",
|
||||||
|
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,30 +17,15 @@ describe("Test MobileMenu", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
// @ts-ignore
|
h.app.refreshEditorInterface();
|
||||||
h.app.refreshViewportBreakpoints();
|
|
||||||
// @ts-ignore
|
|
||||||
h.app.refreshEditorBreakpoints();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
restoreOriginalGetBoundingClientRect();
|
restoreOriginalGetBoundingClientRect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set device correctly", () => {
|
it("should set editor interface correctly", () => {
|
||||||
expect(h.app.device).toMatchInlineSnapshot(`
|
expect(h.app.editorInterface.formFactor).toBe("phone");
|
||||||
{
|
|
||||||
"editor": {
|
|
||||||
"canFitSidebar": false,
|
|
||||||
"isMobile": true,
|
|
||||||
},
|
|
||||||
"isTouchScreen": false,
|
|
||||||
"viewport": {
|
|
||||||
"isLandscape": false,
|
|
||||||
"isMobile": true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize with welcome screen and hide once user interacts", async () => {
|
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
<a
|
<a
|
||||||
class="welcome-screen-menu-item "
|
class="welcome-screen-menu-item "
|
||||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||||
rel="noreferrer"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import {
|
|||||||
createRedoAction,
|
createRedoAction,
|
||||||
createUndoAction,
|
createUndoAction,
|
||||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||||
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncInvalidIndices } from "@excalidraw/element";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import { StoreIncrement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@@ -65,6 +69,79 @@ vi.mock("socket.io-client", () => {
|
|||||||
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
*/
|
*/
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
|
it("should emit two ephemeral increments even though updates get batched", async () => {
|
||||||
|
const durableIncrements: DurableIncrement[] = [];
|
||||||
|
const ephemeralIncrements: EphemeralIncrement[] = [];
|
||||||
|
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
h.store.onStoreIncrementEmitter.on((increment) => {
|
||||||
|
if (StoreIncrement.isDurable(increment)) {
|
||||||
|
durableIncrements.push(increment);
|
||||||
|
} else {
|
||||||
|
ephemeralIncrements.push(increment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line dot-notation
|
||||||
|
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||||
|
expect(durableIncrements.length).toBe(0);
|
||||||
|
expect(ephemeralIncrements.length).toBe(0);
|
||||||
|
|
||||||
|
const rectProps = {
|
||||||
|
type: "rectangle",
|
||||||
|
id: "A",
|
||||||
|
height: 200,
|
||||||
|
width: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rect = API.createElement({ ...rectProps });
|
||||||
|
|
||||||
|
API.updateScene({
|
||||||
|
elements: [rect],
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(durableIncrements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate two batched remote updates
|
||||||
|
act(() => {
|
||||||
|
h.app.updateScene({
|
||||||
|
elements: [newElementWith(h.elements[0], { x: 100 })],
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
h.app.updateScene({
|
||||||
|
elements: [newElementWith(h.elements[0], { x: 200 })],
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
|
||||||
|
// we scheduled two micro actions,
|
||||||
|
// which confirms they are going to be executed as part of one batched component update
|
||||||
|
// eslint-disable-next-line dot-notation
|
||||||
|
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// altough the updates get batched,
|
||||||
|
// we expect two ephemeral increments for each update,
|
||||||
|
// and each such update should have the expected change
|
||||||
|
expect(ephemeralIncrements.length).toBe(2);
|
||||||
|
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
||||||
|
expect.objectContaining({ x: 100 }),
|
||||||
|
);
|
||||||
|
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
||||||
|
expect.objectContaining({ x: 200 }),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line dot-notation
|
||||||
|
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
const rect1Props = {
|
const rect1Props = {
|
||||||
@@ -122,12 +199,13 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
expect.objectContaining(rect1Props),
|
expect.objectContaining(rect1Props),
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
@@ -154,7 +232,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(redoAction));
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
@@ -170,79 +248,5 @@ describe("collaboration", () => {
|
|||||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
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 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-9
@@ -33,7 +33,8 @@
|
|||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
"typescript": "4.9.4",
|
"rimraf": "^5.0.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.7.2",
|
"vite-plugin-checker": "0.7.2",
|
||||||
"vite-plugin-ejs": "1.7.0",
|
"vite-plugin-ejs": "1.7.0",
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 22.x.x"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
@@ -51,13 +52,17 @@
|
|||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||||
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
"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:version": "yarn --cwd ./excalidraw-app build:version",
|
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||||
"build": "yarn --cwd ./excalidraw-app build",
|
"build": "yarn --cwd ./excalidraw-app build",
|
||||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||||
"start": "yarn --cwd ./excalidraw-app start",
|
"start": "yarn --cwd ./excalidraw-app start",
|
||||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
"start:example": "yarn build:packages && 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:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||||
"test:app": "vitest",
|
"test:app": "vitest",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||||
@@ -75,11 +80,12 @@
|
|||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"autorelease": "node scripts/autorelease.js",
|
"release": "node scripts/release.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"release:test": "node scripts/release.js --tag=test",
|
||||||
"release:excalidraw": "node scripts/release.js",
|
"release:next": "node scripts/release.js --tag=next",
|
||||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
"release:latest": "node scripts/release.js --tag=latest",
|
||||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
"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",
|
||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@excalidraw/common",
|
"name": "@excalidraw/common",
|
||||||
"version": "0.1.0",
|
"version": "0.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/types/common/src/index.d.ts",
|
"types": "./dist/types/common/src/index.d.ts",
|
||||||
"main": "./dist/prod/index.js",
|
"main": "./dist/prod/index.js",
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
"types": "./dist/types/common/src/*.d.ts",
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"production": "./dist/prod/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
|||||||
|
|
||||||
sinkDown(idx: number) {
|
sinkDown(idx: number) {
|
||||||
const node = this.content[idx];
|
const node = this.content[idx];
|
||||||
|
const nodeScore = this.scoreFunction(node);
|
||||||
while (idx > 0) {
|
while (idx > 0) {
|
||||||
const parentN = ((idx + 1) >> 1) - 1;
|
const parentN = ((idx + 1) >> 1) - 1;
|
||||||
const parent = this.content[parentN];
|
const parent = this.content[parentN];
|
||||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
if (nodeScore < this.scoreFunction(parent)) {
|
||||||
this.content[parentN] = node;
|
|
||||||
this.content[idx] = parent;
|
this.content[idx] = parent;
|
||||||
idx = parentN; // TODO: Optimize
|
idx = parentN; // TODO: Optimize
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
bubbleUp(idx: number) {
|
bubbleUp(idx: number) {
|
||||||
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
|
|||||||
const score = this.scoreFunction(node);
|
const score = this.scoreFunction(node);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const child2N = (idx + 1) << 1;
|
const child1N = ((idx + 1) << 1) - 1;
|
||||||
const child1N = child2N - 1;
|
const child2N = child1N + 1;
|
||||||
let swap = null;
|
let smallestIdx = idx;
|
||||||
let child1Score = 0;
|
let smallestScore = score;
|
||||||
|
|
||||||
|
// Check left child
|
||||||
if (child1N < length) {
|
if (child1N < length) {
|
||||||
const child1 = this.content[child1N];
|
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||||
child1Score = this.scoreFunction(child1);
|
if (child1Score < smallestScore) {
|
||||||
if (child1Score < score) {
|
smallestIdx = child1N;
|
||||||
swap = child1N;
|
smallestScore = child1Score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check right child
|
||||||
if (child2N < length) {
|
if (child2N < length) {
|
||||||
const child2 = this.content[child2N];
|
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||||
const child2Score = this.scoreFunction(child2);
|
if (child2Score < smallestScore) {
|
||||||
if (child2Score < (swap === null ? score : child1Score)) {
|
smallestIdx = child2N;
|
||||||
swap = child2N;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (swap !== null) {
|
if (smallestIdx === idx) {
|
||||||
this.content[idx] = this.content[swap];
|
|
||||||
this.content[swap] = node;
|
|
||||||
idx = swap; // TODO: Optimize
|
|
||||||
} else {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the smaller child up, continue finding position for node
|
||||||
|
this.content[idx] = this.content[smallestIdx];
|
||||||
|
idx = smallestIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Place node in its final position
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
push(node: T) {
|
push(node: T) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* x and y position of top left corner, x and y position of bottom right corner
|
||||||
|
*/
|
||||||
|
export type Bounds = readonly [
|
||||||
|
minX: number,
|
||||||
|
minY: number,
|
||||||
|
maxX: number,
|
||||||
|
maxY: number,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isBounds = (box: unknown): box is Bounds =>
|
||||||
|
Array.isArray(box) &&
|
||||||
|
box.length === 4 &&
|
||||||
|
typeof box[0] === "number" &&
|
||||||
|
typeof box[1] === "number" &&
|
||||||
|
typeof box[2] === "number" &&
|
||||||
|
typeof box[3] === "number";
|
||||||
@@ -6,24 +6,6 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import { COLOR_PALETTE } from "./colors";
|
import { COLOR_PALETTE } from "./colors";
|
||||||
|
|
||||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|
||||||
export const isWindows = /^Win/.test(navigator.platform);
|
|
||||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
|
||||||
export const isFirefox =
|
|
||||||
"netscape" in window &&
|
|
||||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
|
||||||
navigator.userAgent.indexOf("Gecko") > 1;
|
|
||||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|
||||||
export const isSafari =
|
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
|
||||||
export const isIOS =
|
|
||||||
/iPad|iPhone/.test(navigator.platform) ||
|
|
||||||
// iPadOS 13+
|
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
|
||||||
// keeping function so it can be mocked in test
|
|
||||||
export const isBrave = () =>
|
|
||||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
|
||||||
|
|
||||||
export const supportsResizeObserver =
|
export const supportsResizeObserver =
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
@@ -35,6 +17,7 @@ export const APP_NAME = "Excalidraw";
|
|||||||
// (happens a lot with fast clicks with the text tool)
|
// (happens a lot with fast clicks with the text tool)
|
||||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
|
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
@@ -116,11 +99,22 @@ export const ENV = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
|
SIDEBAR: "sidebar",
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
|
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||||
|
FRAME_NAME: "frame-name",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FONT_SIZES = {
|
||||||
|
sm: 16,
|
||||||
|
md: 20,
|
||||||
|
lg: 28,
|
||||||
|
xl: 36,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||||
|
|
||||||
@@ -142,21 +136,52 @@ export const FONT_FAMILY = {
|
|||||||
"Lilita One": 7,
|
"Lilita One": 7,
|
||||||
"Comic Shanns": 8,
|
"Comic Shanns": 8,
|
||||||
"Liberation Sans": 9,
|
"Liberation Sans": 9,
|
||||||
|
Assistant: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
|
||||||
|
// so we need to have generic font fallback before it
|
||||||
|
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
|
||||||
|
export const MONOSPACE_GENERIC_FONT = "monospace";
|
||||||
|
|
||||||
|
export const FONT_FAMILY_GENERIC_FALLBACKS = {
|
||||||
|
[SANS_SERIF_GENERIC_FONT]: 998,
|
||||||
|
[MONOSPACE_GENERIC_FONT]: 999,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FONT_FAMILY_FALLBACKS = {
|
export const FONT_FAMILY_FALLBACKS = {
|
||||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||||
|
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||||
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getGenericFontFamilyFallback(
|
||||||
|
fontFamily: number,
|
||||||
|
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
|
||||||
|
switch (fontFamily) {
|
||||||
|
case FONT_FAMILY.Cascadia:
|
||||||
|
case FONT_FAMILY["Comic Shanns"]:
|
||||||
|
return MONOSPACE_GENERIC_FONT;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return SANS_SERIF_GENERIC_FONT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getFontFamilyFallbacks = (
|
export const getFontFamilyFallbacks = (
|
||||||
fontFamily: number,
|
fontFamily: number,
|
||||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||||
|
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||||
|
|
||||||
switch (fontFamily) {
|
switch (fontFamily) {
|
||||||
case FONT_FAMILY.Excalifont:
|
case FONT_FAMILY.Excalifont:
|
||||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
return [
|
||||||
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
|
genericFallbackFont,
|
||||||
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
];
|
||||||
default:
|
default:
|
||||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,13 +243,20 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const STRING_MIME_TYPES = {
|
||||||
text: "text/plain",
|
text: "text/plain",
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
// LEGACY: fully-qualified library JSON data
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
// list of excalidraw library item ids
|
||||||
|
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
...STRING_MIME_TYPES,
|
||||||
// image-encoded excalidraw data
|
// image-encoded excalidraw data
|
||||||
"excalidraw.svg": "image/svg+xml",
|
"excalidraw.svg": "image/svg+xml",
|
||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
@@ -253,7 +285,7 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_SOURCE =
|
export const getExportSource = () =>
|
||||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
@@ -299,16 +331,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// breakpoints
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
|
||||||
// sidebar
|
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||||
|
|
||||||
export const EXPORT_SCALES = [1, 2, 3];
|
export const EXPORT_SCALES = [1, 2, 3];
|
||||||
@@ -474,3 +496,19 @@ export enum UserIdleState {
|
|||||||
AWAY = "away",
|
AWAY = "away",
|
||||||
IDLE = "idle",
|
IDLE = "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* distance at which we merge points instead of adding a new merge-point
|
||||||
|
* when converting a line to a polygon (merge currently means overlaping
|
||||||
|
* the start and end points)
|
||||||
|
*/
|
||||||
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
|
||||||
|
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||||
|
|
||||||
|
export const BIND_MODE_TIMEOUT = 700; // ms
|
||||||
|
|
||||||
|
// glass background for mobile action buttons
|
||||||
|
export const MOBILE_ACTION_BUTTON_BG = {
|
||||||
|
background: "var(--mobile-action-button-bg)",
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
export type StylesPanelMode = "compact" | "full" | "mobile";
|
||||||
|
|
||||||
|
export type EditorInterface = Readonly<{
|
||||||
|
formFactor: "phone" | "tablet" | "desktop";
|
||||||
|
desktopUIMode: "compact" | "full";
|
||||||
|
userAgent: Readonly<{
|
||||||
|
isMobileDevice: boolean;
|
||||||
|
platform: "ios" | "android" | "other" | "unknown";
|
||||||
|
}>;
|
||||||
|
isTouchScreen: boolean;
|
||||||
|
canFitSidebar: boolean;
|
||||||
|
isLandscape: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// storage key
|
||||||
|
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
|
||||||
|
|
||||||
|
// breakpoints
|
||||||
|
// mobile: up to 699px
|
||||||
|
export const MQ_MAX_MOBILE = 599;
|
||||||
|
|
||||||
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
|
|
||||||
|
// tablets
|
||||||
|
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||||
|
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||||
|
|
||||||
|
// desktop/laptop
|
||||||
|
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||||
|
|
||||||
|
// sidebar
|
||||||
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// user agent detections
|
||||||
|
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
|
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||||
|
export const isFirefox =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
"netscape" in window &&
|
||||||
|
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||||
|
navigator.userAgent.indexOf("Gecko") > 1;
|
||||||
|
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||||
|
export const isSafari =
|
||||||
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
|
export const isIOS =
|
||||||
|
/iPad|iPhone/i.test(navigator.platform) ||
|
||||||
|
// iPadOS 13+
|
||||||
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
|
// keeping function so it can be mocked in test
|
||||||
|
export const isBrave = () =>
|
||||||
|
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||||
|
|
||||||
|
// export const isMobile =
|
||||||
|
// isIOS ||
|
||||||
|
// /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||||
|
// navigator.userAgent,
|
||||||
|
// ) ||
|
||||||
|
// /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||||
|
|
||||||
|
// utilities
|
||||||
|
export const isMobileBreakpoint = (width: number, height: number) => {
|
||||||
|
return (
|
||||||
|
width <= MQ_MAX_MOBILE ||
|
||||||
|
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTabletBreakpoint = (
|
||||||
|
editorWidth: number,
|
||||||
|
editorHeight: number,
|
||||||
|
) => {
|
||||||
|
const minSide = Math.min(editorWidth, editorHeight);
|
||||||
|
const maxSide = Math.max(editorWidth, editorHeight);
|
||||||
|
|
||||||
|
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMobileOrTablet = (): boolean => {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
const platform = navigator.platform || "";
|
||||||
|
const uaData = (navigator as any).userAgentData as
|
||||||
|
| { mobile?: boolean; platform?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||||
|
if (uaData) {
|
||||||
|
const plat = (uaData.platform || "").toLowerCase();
|
||||||
|
const isDesktopOS =
|
||||||
|
plat === "windows" ||
|
||||||
|
plat === "macos" ||
|
||||||
|
plat === "linux" ||
|
||||||
|
plat === "chrome os";
|
||||||
|
if (uaData.mobile === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (uaData.mobile === false && plat === "android") {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
if (isDesktopOS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) ios (includes ipad) --------------------------------------------
|
||||||
|
if (isIOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3) android legacy ua fallback -------------------------------------
|
||||||
|
if (isAndroid) {
|
||||||
|
const isAndroidPhone = /Mobile/i.test(ua);
|
||||||
|
const isAndroidTablet = !isAndroidPhone;
|
||||||
|
if (isAndroidPhone || isAndroidTablet) {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4) last resort desktop exclusion ----------------------------------
|
||||||
|
const looksDesktopPlatform =
|
||||||
|
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||||
|
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||||
|
if (looksDesktopPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFormFactor = (
|
||||||
|
editorWidth: number,
|
||||||
|
editorHeight: number,
|
||||||
|
): EditorInterface["formFactor"] => {
|
||||||
|
if (isMobileBreakpoint(editorWidth, editorHeight)) {
|
||||||
|
return "phone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTabletBreakpoint(editorWidth, editorHeight)) {
|
||||||
|
return "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "desktop";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deriveStylesPanelMode = (
|
||||||
|
editorInterface: EditorInterface,
|
||||||
|
): StylesPanelMode => {
|
||||||
|
if (editorInterface.formFactor === "phone") {
|
||||||
|
return "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorInterface.formFactor === "tablet") {
|
||||||
|
return "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
return editorInterface.desktopUIMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserAgentDescriptor = (
|
||||||
|
userAgentString: string,
|
||||||
|
): EditorInterface["userAgent"] => {
|
||||||
|
const normalizedUA = userAgentString ?? "";
|
||||||
|
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
platform = "ios";
|
||||||
|
} else if (isAndroid) {
|
||||||
|
platform = "android";
|
||||||
|
} else if (normalizedUA) {
|
||||||
|
platform = "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMobileDevice: isMobileOrTablet(),
|
||||||
|
platform,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadDesktopUIModePreference = () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
|
||||||
|
if (stored === "compact" || stored === "full") {
|
||||||
|
return stored as EditorInterface["desktopUIMode"];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore storage access issues (e.g., Safari private mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
// ignore storage access issues (e.g., Safari private mode)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||||
|
if (mode !== "compact" && mode !== "full") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistDesktopUIMode(mode);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UnsubscribeCallback } from "./types";
|
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||||
|
|
||||||
@@ -22,8 +22,10 @@ export interface FontMetadata {
|
|||||||
};
|
};
|
||||||
/** flag to indicate a deprecated font */
|
/** flag to indicate a deprecated font */
|
||||||
deprecated?: true;
|
deprecated?: true;
|
||||||
/** flag to indicate a server-side only font */
|
/**
|
||||||
serverSide?: true;
|
* whether this is a font that users can use (= shown in font picker)
|
||||||
|
*/
|
||||||
|
private?: true;
|
||||||
/** flag to indiccate a local-only font */
|
/** flag to indiccate a local-only font */
|
||||||
local?: true;
|
local?: true;
|
||||||
/** flag to indicate a fallback font */
|
/** flag to indicate a fallback font */
|
||||||
@@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||||||
unitsPerEm: 1000,
|
unitsPerEm: 1000,
|
||||||
ascender: 1011,
|
ascender: 1011,
|
||||||
descender: -353,
|
descender: -353,
|
||||||
lineHeight: 1.35,
|
lineHeight: 1.25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[FONT_FAMILY["Lilita One"]]: {
|
[FONT_FAMILY["Lilita One"]]: {
|
||||||
@@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||||||
descender: -434,
|
descender: -434,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
},
|
},
|
||||||
serverSide: true,
|
private: true,
|
||||||
|
},
|
||||||
|
[FONT_FAMILY.Assistant]: {
|
||||||
|
metrics: {
|
||||||
|
unitsPerEm: 2048,
|
||||||
|
ascender: 1021,
|
||||||
|
descender: -287,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
},
|
||||||
|
private: true,
|
||||||
},
|
},
|
||||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||||
metrics: {
|
metrics: {
|
||||||
unitsPerEm: 1000,
|
unitsPerEm: 1000,
|
||||||
ascender: 880,
|
ascender: 880,
|
||||||
descender: -144,
|
descender: -144,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.25,
|
||||||
},
|
},
|
||||||
fallback: true,
|
fallback: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./binary-heap";
|
export * from "./binary-heap";
|
||||||
|
export * from "./bounds";
|
||||||
export * from "./colors";
|
export * from "./colors";
|
||||||
export * from "./constants";
|
export * from "./constants";
|
||||||
export * from "./font-metadata";
|
export * from "./font-metadata";
|
||||||
@@ -9,3 +10,6 @@ export * from "./promise-pool";
|
|||||||
export * from "./random";
|
export * from "./random";
|
||||||
export * from "./url";
|
export * from "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
export * from "./emitter";
|
||||||
|
export * from "./visualdebug";
|
||||||
|
export * from "./editorInterface";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isDarwin } from "./constants";
|
import { isDarwin } from "./editorInterface";
|
||||||
|
|
||||||
import type { ValueOf } from "./utility-types";
|
import type { ValueOf } from "./utility-types";
|
||||||
|
|
||||||
|
|||||||
@@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
|
|||||||
|
|
||||||
// get union of all keys from the union of types
|
// get union of all keys from the union of types
|
||||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||||
|
|
||||||
|
/** Strip all the methods or functions from a type */
|
||||||
|
export type DTO<T> = {
|
||||||
|
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||||
|
? [K, V]
|
||||||
|
: never;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
isTransparent,
|
||||||
|
mapFind,
|
||||||
|
reduceToCommonValue,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
describe("@excalidraw/common/utils", () => {
|
||||||
|
describe("isTransparent()", () => {
|
||||||
|
it("should return true when color is rgb transparent", () => {
|
||||||
|
expect(isTransparent("#ff00")).toEqual(true);
|
||||||
|
expect(isTransparent("#fff00000")).toEqual(true);
|
||||||
|
expect(isTransparent("transparent")).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when color is not transparent", () => {
|
||||||
|
expect(isTransparent("#ced4da")).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reduceToCommonValue()", () => {
|
||||||
|
it("should return the common value when all values are the same", () => {
|
||||||
|
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||||
|
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||||
|
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||||
|
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||||
|
expect(reduceToCommonValue([""])).toEqual("");
|
||||||
|
expect(reduceToCommonValue([0])).toEqual(0);
|
||||||
|
|
||||||
|
const o = {};
|
||||||
|
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||||
|
).toEqual(1);
|
||||||
|
expect(
|
||||||
|
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||||
|
).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return `null` when values are different", () => {
|
||||||
|
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return `null` when some values are nullable", () => {
|
||||||
|
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([null])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([])).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapFind()", () => {
|
||||||
|
it("should return the first mapped non-null element", () => {
|
||||||
|
{
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const result = mapFind(["a", "b", "c"], (value) => {
|
||||||
|
counter++;
|
||||||
|
return value === "b" ? 42 : null;
|
||||||
|
});
|
||||||
|
expect(result).toEqual(42);
|
||||||
|
expect(counter).toBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||||
|
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||||
|
expect(mapFind([1, 2], () => "")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined if no mapped element is found", () => {
|
||||||
|
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||||
|
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+122
-44
@@ -1,11 +1,6 @@
|
|||||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
import { average } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
|
||||||
ExcalidrawBindableElement,
|
|
||||||
FontFamilyValues,
|
|
||||||
FontString,
|
|
||||||
ExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ActiveTool,
|
ActiveTool,
|
||||||
@@ -21,7 +16,6 @@ import {
|
|||||||
ENV,
|
ENV,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
isDarwin,
|
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@@ -92,7 +86,8 @@ export const isWritableElement = (
|
|||||||
(target instanceof HTMLInputElement &&
|
(target instanceof HTMLInputElement &&
|
||||||
(target.type === "text" ||
|
(target.type === "text" ||
|
||||||
target.type === "number" ||
|
target.type === "number" ||
|
||||||
target.type === "password"));
|
target.type === "password" ||
|
||||||
|
target.type === "search"));
|
||||||
|
|
||||||
export const getFontFamilyString = ({
|
export const getFontFamilyString = ({
|
||||||
fontFamily,
|
fontFamily,
|
||||||
@@ -101,7 +96,6 @@ export const getFontFamilyString = ({
|
|||||||
}) => {
|
}) => {
|
||||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||||
if (id === fontFamily) {
|
if (id === fontFamily) {
|
||||||
// TODO: we should fallback first to generic family names first
|
|
||||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||||
.map((x) => `, ${x}`)
|
.map((x) => `, ${x}`)
|
||||||
.join("")}`;
|
.join("")}`;
|
||||||
@@ -121,6 +115,11 @@ export const getFontString = ({
|
|||||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** executes callback in the frame that's after the current one */
|
||||||
|
export const nextAnimationFrame = async (cb: () => any) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||||
|
};
|
||||||
|
|
||||||
export const debounce = <T extends any[]>(
|
export const debounce = <T extends any[]>(
|
||||||
fn: (...args: T) => void,
|
fn: (...args: T) => void,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
@@ -379,6 +378,10 @@ export const removeSelection = () => {
|
|||||||
|
|
||||||
export const distance = (x: number, y: number) => Math.abs(x - y);
|
export const distance = (x: number, y: number) => Math.abs(x - y);
|
||||||
|
|
||||||
|
export const isSelectionLikeTool = (type: ToolType | "custom") => {
|
||||||
|
return type === "selection" || type === "lasso";
|
||||||
|
};
|
||||||
|
|
||||||
export const updateActiveTool = (
|
export const updateActiveTool = (
|
||||||
appState: Pick<AppState, "activeTool">,
|
appState: Pick<AppState, "activeTool">,
|
||||||
data: ((
|
data: ((
|
||||||
@@ -420,19 +423,6 @@ export const allowFullScreen = () =>
|
|||||||
|
|
||||||
export const exitFullScreen = () => document.exitFullscreen();
|
export const exitFullScreen = () => document.exitFullscreen();
|
||||||
|
|
||||||
export const getShortcutKey = (shortcut: string): string => {
|
|
||||||
shortcut = shortcut
|
|
||||||
.replace(/\bAlt\b/i, "Alt")
|
|
||||||
.replace(/\bShift\b/i, "Shift")
|
|
||||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
|
||||||
if (isDarwin) {
|
|
||||||
return shortcut
|
|
||||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
|
||||||
.replace(/\bAlt\b/i, "Option");
|
|
||||||
}
|
|
||||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewportCoordsToSceneCoords = (
|
export const viewportCoordsToSceneCoords = (
|
||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
{
|
{
|
||||||
@@ -544,6 +534,20 @@ export const findLastIndex = <T>(
|
|||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** returns the first non-null mapped value */
|
||||||
|
export const mapFind = <T, K>(
|
||||||
|
collection: readonly T[],
|
||||||
|
iteratee: (value: T, index: number) => K | undefined | null,
|
||||||
|
): K | undefined => {
|
||||||
|
for (let idx = 0; idx < collection.length; idx++) {
|
||||||
|
const result = iteratee(collection[idx], idx);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const isTransparent = (color: string) => {
|
export const isTransparent = (color: string) => {
|
||||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||||
@@ -554,9 +558,6 @@ export const isTransparent = (color: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
|
||||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
|
||||||
|
|
||||||
export type ResolvablePromise<T> = Promise<T> & {
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
resolve: [T] extends [undefined]
|
resolve: [T] extends [undefined]
|
||||||
? (value?: MaybePromise<Awaited<T>>) => void
|
? (value?: MaybePromise<Awaited<T>>) => void
|
||||||
@@ -698,8 +699,8 @@ export const arrayToObject = <T>(
|
|||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
groupBy?: (value: T) => string | number,
|
groupBy?: (value: T) => string | number,
|
||||||
) =>
|
) =>
|
||||||
array.reduce((acc, value) => {
|
array.reduce((acc, value, idx) => {
|
||||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
acc[groupBy ? groupBy(value) : idx] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as { [key: string]: T });
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
@@ -735,6 +736,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
|||||||
return acc;
|
return acc;
|
||||||
}, [] as Node<T>[]);
|
}, [] as Node<T>[]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a readonly array or map into an iterable.
|
||||||
|
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
||||||
|
*/
|
||||||
|
export const toIterable = <T>(
|
||||||
|
values: readonly T[] | ReadonlyMap<string, T>,
|
||||||
|
): Iterable<T> => {
|
||||||
|
return Array.isArray(values) ? values : values.values();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a readonly array or map into an array.
|
||||||
|
*/
|
||||||
|
export const toArray = <T>(
|
||||||
|
values: readonly T[] | ReadonlyMap<string, T>,
|
||||||
|
): T[] => {
|
||||||
|
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
||||||
|
};
|
||||||
|
|
||||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||||
|
|
||||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||||
@@ -1205,31 +1225,89 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
Array.isArray(value) ? value : [value];
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
export const elementCenterPoint = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
xOffset: number = 0,
|
|
||||||
yOffset: number = 0,
|
|
||||||
) => {
|
|
||||||
const { x, y, width, height } = element;
|
|
||||||
|
|
||||||
const centerXPoint = x + width / 2 + xOffset;
|
|
||||||
|
|
||||||
const centerYPoint = y + height / 2 + yOffset;
|
|
||||||
|
|
||||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sizeOf = (
|
export const sizeOf = (
|
||||||
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
|
value:
|
||||||
|
| readonly unknown[]
|
||||||
|
| Readonly<Map<string, unknown>>
|
||||||
|
| Readonly<Record<string, unknown>>
|
||||||
|
| ReadonlySet<unknown>,
|
||||||
): number => {
|
): number => {
|
||||||
return isReadonlyArray(value)
|
return isReadonlyArray(value)
|
||||||
? value.length
|
? value.length
|
||||||
: value instanceof Map
|
: value instanceof Map || value instanceof Set
|
||||||
? value.size
|
? value.size
|
||||||
: Object.keys(value).length;
|
: Object.keys(value).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const reduceToCommonValue = <T, R = T>(
|
||||||
|
collection: readonly T[] | ReadonlySet<T>,
|
||||||
|
getValue?: (item: T) => R,
|
||||||
|
): R | null => {
|
||||||
|
if (sizeOf(collection) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||||
|
|
||||||
|
let commonValue: R | null = null;
|
||||||
|
|
||||||
|
for (const item of collection) {
|
||||||
|
const value = valueExtractor(item);
|
||||||
|
if ((commonValue === null || commonValue === value) && value != null) {
|
||||||
|
commonValue = value;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commonValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FEATURE_FLAGS = {
|
||||||
|
COMPLEX_BINDINGS: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
|
||||||
|
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
|
||||||
|
COMPLEX_BINDINGS: false,
|
||||||
|
};
|
||||||
|
let featureFlags: FEATURE_FLAGS | null = null;
|
||||||
|
|
||||||
|
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||||
|
flag: F,
|
||||||
|
): FEATURE_FLAGS[F] => {
|
||||||
|
if (!featureFlags) {
|
||||||
|
try {
|
||||||
|
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
|
||||||
|
if (serializedFlags) {
|
||||||
|
const flags = JSON.parse(serializedFlags);
|
||||||
|
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||||
|
flag: F,
|
||||||
|
value: FEATURE_FLAGS[F],
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
featureFlags = {
|
||||||
|
...(featureFlags || DEFAULT_FEATURE_FLAGS),
|
||||||
|
[flag]: value,
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
FEATURE_FLAGS_STORAGE_KEY,
|
||||||
|
JSON.stringify(featureFlags),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("unable to set feature flag", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import {
|
|||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { isBounds } from "@excalidraw/element/typeChecks";
|
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
import type { LineSegment } from "@excalidraw/utils";
|
import type { LineSegment } from "@excalidraw/utils";
|
||||||
|
|
||||||
import type { Bounds } from "@excalidraw/element/bounds";
|
import { type Bounds, isBounds } from "./bounds";
|
||||||
|
|
||||||
// The global data holder to collect the debug operations
|
// The global data holder to collect the debug operations
|
||||||
declare global {
|
declare global {
|
||||||
@@ -139,7 +137,7 @@ export const debugDrawPoints = (
|
|||||||
}: {
|
}: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
points: LocalPoint[];
|
points: readonly LocalPoint[];
|
||||||
},
|
},
|
||||||
options?: any,
|
options?: any,
|
||||||
) => {
|
) => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@excalidraw/element",
|
"name": "@excalidraw/element",
|
||||||
"version": "0.1.0",
|
"version": "0.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/types/element/src/index.d.ts",
|
"types": "./dist/types/element/src/index.d.ts",
|
||||||
"main": "./dist/prod/index.js",
|
"main": "./dist/prod/index.js",
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
"types": "./dist/types/element/src/*.d.ts",
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"production": "./dist/prod/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -50,7 +53,11 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@excalidraw/common": "0.18.0",
|
||||||
|
"@excalidraw/math": "0.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,21 @@ import {
|
|||||||
toBrandedType,
|
toBrandedType,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
isReadonlyArray,
|
toArray,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { isNonDeletedElement } from "@excalidraw/element";
|
import { isNonDeletedElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
import { getElementsInGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
orderByFractionalIndex,
|
|
||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
syncMovedIndices,
|
syncMovedIndices,
|
||||||
validateFractionalIndices,
|
validateFractionalIndices,
|
||||||
} from "@excalidraw/element/fractionalIndex";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { getSelectedElements } from "@excalidraw/element/selection";
|
import { getSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
|
||||||
mutateElement,
|
|
||||||
type ElementUpdate,
|
|
||||||
} from "@excalidraw/element/mutateElement";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -109,7 +105,7 @@ const hashSelectionOpts = (
|
|||||||
// in our codebase
|
// in our codebase
|
||||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||||
|
|
||||||
class Scene {
|
export class Scene {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// instance methods/props
|
// instance methods/props
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -168,9 +164,14 @@ class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(elements: ElementsMapOrArray | null = null) {
|
constructor(
|
||||||
|
elements: ElementsMapOrArray | null = null,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (elements) {
|
if (elements) {
|
||||||
this.replaceAllElements(elements);
|
this.replaceAllElements(elements, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,20 +268,21 @@ class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(
|
||||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
nextElements: ElementsMapOrArray,
|
||||||
if (!isReadonlyArray(nextElements)) {
|
options?: {
|
||||||
// need to order by fractional indices to get the correct order
|
skipValidation?: true;
|
||||||
nextElements = orderByFractionalIndex(
|
},
|
||||||
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
|
) {
|
||||||
);
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
}
|
const _nextElements = toArray(nextElements);
|
||||||
|
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
validateIndicesThrottled(nextElements);
|
if (!options?.skipValidation) {
|
||||||
|
validateIndicesThrottled(_nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
this.elements.forEach((element) => {
|
this.elements.forEach((element) => {
|
||||||
if (isFrameLikeElement(element)) {
|
if (isFrameLikeElement(element)) {
|
||||||
@@ -464,5 +466,3 @@ class Scene {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Scene;
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
|
|
||||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AppState,
|
|
||||||
EmbedsValidationStatus,
|
|
||||||
} from "@excalidraw/excalidraw/types";
|
|
||||||
import type {
|
|
||||||
ElementShape,
|
|
||||||
ElementShapes,
|
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
|
||||||
|
|
||||||
import { _generateElementShape } from "./Shape";
|
|
||||||
|
|
||||||
import { elementWithCanvasCache } from "./renderElement";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
|
||||||
|
|
||||||
import type { Drawable } from "roughjs/bin/core";
|
|
||||||
|
|
||||||
export class ShapeCache {
|
|
||||||
private static rg = new RoughGenerator();
|
|
||||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves shape from cache if available. Use this only if shape
|
|
||||||
* is optional and you have a fallback in case it's not cached.
|
|
||||||
*/
|
|
||||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
|
||||||
return ShapeCache.cache.get(
|
|
||||||
element,
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]] | undefined
|
|
||||||
: ElementShape | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
public static set = <T extends ExcalidrawElement>(
|
|
||||||
element: T,
|
|
||||||
shape: T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable,
|
|
||||||
) => ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
public static delete = (element: ExcalidrawElement) =>
|
|
||||||
ShapeCache.cache.delete(element);
|
|
||||||
|
|
||||||
public static destroy = () => {
|
|
||||||
ShapeCache.cache = new WeakMap();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates & caches shape for element if not already cached, otherwise
|
|
||||||
* returns cached shape.
|
|
||||||
*/
|
|
||||||
public static generateElementShape = <
|
|
||||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
|
||||||
>(
|
|
||||||
element: T,
|
|
||||||
renderConfig: {
|
|
||||||
isExporting: boolean;
|
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
|
||||||
} | null,
|
|
||||||
) => {
|
|
||||||
// when exporting, always regenerated to guarantee the latest shape
|
|
||||||
const cachedShape = renderConfig?.isExporting
|
|
||||||
? undefined
|
|
||||||
: ShapeCache.get(element);
|
|
||||||
|
|
||||||
// `null` indicates no rc shape applicable for this element type,
|
|
||||||
// but it's considered a valid cache value (= do not regenerate)
|
|
||||||
if (cachedShape !== undefined) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
elementWithCanvasCache.delete(element);
|
|
||||||
|
|
||||||
const shape = _generateElementShape(
|
|
||||||
element,
|
|
||||||
ShapeCache.rg,
|
|
||||||
renderConfig || {
|
|
||||||
isExporting: false,
|
|
||||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
|
||||||
embedsValidationStatus: null,
|
|
||||||
},
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable | null;
|
|
||||||
|
|
||||||
ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+73
-44
@@ -88,8 +88,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "ellipse-1",
|
"elementId": "ellipse-1",
|
||||||
"focus": -0.007519379844961235,
|
"fixedPoint": [
|
||||||
"gap": 11.562288374879595,
|
0.04,
|
||||||
|
0.4633333333333333,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -98,7 +101,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -118,8 +120,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "id49",
|
"elementId": "id49",
|
||||||
"focus": -0.0813953488372095,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
1,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#1864ab",
|
"strokeColor": "#1864ab",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -144,8 +149,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "ellipse-1",
|
"elementId": "ellipse-1",
|
||||||
"focus": 0.10666666666666667,
|
"fixedPoint": [
|
||||||
"gap": 3.8343264684446097,
|
-0.01,
|
||||||
|
0.44666666666666666,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -154,7 +162,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a3",
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -174,8 +181,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "diamond-1",
|
"elementId": "diamond-1",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 4.545343408287929,
|
0.9357142857142857,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#e67700",
|
"strokeColor": "#e67700",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -334,8 +344,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "text-2",
|
"elementId": "text-2",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 14,
|
-2.05,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -344,7 +357,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -364,8 +376,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "text-1",
|
"elementId": "text-1",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
1,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -436,8 +451,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id42",
|
"elementId": "id42",
|
||||||
"focus": -0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
0,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -446,7 +464,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -466,8 +483,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "id41",
|
"elementId": "id41",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
1,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -612,8 +632,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id46",
|
"elementId": "id46",
|
||||||
"focus": -0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
0,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -622,7 +645,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -642,8 +664,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "id45",
|
"elementId": "id45",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
1,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -839,7 +864,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -887,7 +911,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -934,7 +957,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -948,6 +970,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
@@ -981,7 +1004,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a3",
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -995,6 +1017,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
@@ -1474,8 +1497,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "Alice",
|
"elementId": "Alice",
|
||||||
"focus": -0,
|
"fixedPoint": [
|
||||||
"gap": 5.299874999999986,
|
-0.07542628418945944,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -1484,7 +1510,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a4",
|
"index": "a4",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -1506,8 +1531,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "Bob",
|
"elementId": "Bob",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
1.000004978564514,
|
||||||
|
0.5001,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -1537,8 +1565,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "B",
|
"elementId": "B",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 14,
|
0.46387050630528887,
|
||||||
|
0.48466257668711654,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
@@ -1547,7 +1578,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a5",
|
"index": "a5",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -1565,8 +1595,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "Bob",
|
"elementId": "Bob",
|
||||||
"focus": 0,
|
"fixedPoint": [
|
||||||
"gap": 1,
|
0.39381496335223337,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
"mode": "orbit",
|
||||||
},
|
},
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
@@ -1789,7 +1822,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 120,
|
"width": 120,
|
||||||
"x": 187.7545,
|
"x": 187.75450000000004,
|
||||||
"y": 44.5,
|
"y": 44.5,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -1856,7 +1889,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -1909,7 +1941,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -1962,7 +1993,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
@@ -2015,7 +2045,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a3",
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
+7
-12
@@ -1,11 +1,12 @@
|
|||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
|
import {
|
||||||
|
convertToExcalidrawElements,
|
||||||
|
type ExcalidrawElementSkeleton,
|
||||||
|
} from "../transform";
|
||||||
|
|
||||||
import { convertToExcalidrawElements } from "./transform";
|
import type { ExcalidrawArrowElement } from "../types";
|
||||||
|
|
||||||
import type { ExcalidrawElementSkeleton } from "./transform";
|
|
||||||
|
|
||||||
const opts = { regenerateIds: false };
|
const opts = { regenerateIds: false };
|
||||||
|
|
||||||
@@ -432,12 +433,9 @@ describe("Test Transform", () => {
|
|||||||
boundElements: [{ id: text.id, type: "text" }],
|
boundElements: [{ id: text.id, type: "text" }],
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: rectangle.id,
|
elementId: rectangle.id,
|
||||||
focus: 0,
|
|
||||||
gap: 1,
|
|
||||||
},
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: ellipse.id,
|
elementId: ellipse.id,
|
||||||
focus: -0,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -517,12 +515,9 @@ describe("Test Transform", () => {
|
|||||||
boundElements: [{ id: text1.id, type: "text" }],
|
boundElements: [{ id: text1.id, type: "text" }],
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: text2.id,
|
elementId: text2.id,
|
||||||
focus: 0,
|
|
||||||
gap: 1,
|
|
||||||
},
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: text3.id,
|
elementId: text3.id,
|
||||||
focus: -0,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -780,8 +775,8 @@ describe("Test Transform", () => {
|
|||||||
const [arrow, rect] = excalidrawElements;
|
const [arrow, rect] = excalidrawElements;
|
||||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||||
elementId: "rect-1",
|
elementId: "rect-1",
|
||||||
focus: -0,
|
fixedPoint: [-2.05, 0.5001],
|
||||||
gap: 14,
|
mode: "orbit",
|
||||||
});
|
});
|
||||||
expect(rect.boundElements).toStrictEqual([
|
expect(rect.boundElements).toStrictEqual([
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getSelectedElementsByGroup } from "./groups";
|
||||||
|
|
||||||
import type Scene from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { BoundingBox } from "./bounds";
|
import type { BoundingBox } from "./bounds";
|
||||||
import type { ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement } from "./types";
|
||||||
@@ -16,11 +18,12 @@ export const alignElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
scene.getNonDeletedElementsMap(),
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
|
|||||||
+1848
-1260
File diff suppressed because it is too large
Load Diff
+176
-44
@@ -1,17 +1,18 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
rescalePoints,
|
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
|
type Bounds,
|
||||||
invariant,
|
invariant,
|
||||||
|
rescalePoints,
|
||||||
sizeOf,
|
sizeOf,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
pointDistance,
|
||||||
|
pointFrom,
|
||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
@@ -33,8 +34,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { generateRoughOptions } from "./shape";
|
||||||
import { generateRoughOptions } from "./Shape";
|
import { ShapeCache } from "./shape";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@@ -42,30 +43,31 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shapes";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
Arrowhead,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
NonDeleted,
|
|
||||||
ExcalidrawTextElementWithContainer,
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawRectanguloidElement,
|
|
||||||
ExcalidrawEllipseElement,
|
|
||||||
ElementsMapOrArray,
|
|
||||||
} from "./types";
|
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
import type {
|
||||||
|
Arrowhead,
|
||||||
|
ElementsMap,
|
||||||
|
ElementsMapOrArray,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawRectanguloidElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
|
NonDeleted,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -77,16 +79,6 @@ export type RectangleBox = {
|
|||||||
|
|
||||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||||
|
|
||||||
/**
|
|
||||||
* x and y position of top left corner, x and y position of bottom right corner
|
|
||||||
*/
|
|
||||||
export type Bounds = readonly [
|
|
||||||
minX: number,
|
|
||||||
minY: number,
|
|
||||||
maxX: number,
|
|
||||||
maxY: number,
|
|
||||||
];
|
|
||||||
|
|
||||||
export type SceneBounds = readonly [
|
export type SceneBounds = readonly [
|
||||||
sceneX: number,
|
sceneX: number,
|
||||||
sceneY: number,
|
sceneY: number,
|
||||||
@@ -102,9 +94,23 @@ export class ElementBounds {
|
|||||||
version: ExcalidrawElement["version"];
|
version: ExcalidrawElement["version"];
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
private static nonRotatedBoundsCache = new WeakMap<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{
|
||||||
|
bounds: Bounds;
|
||||||
|
version: ExcalidrawElement["version"];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
static getBounds(
|
||||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
nonRotated: boolean = false,
|
||||||
|
) {
|
||||||
|
const cachedBounds =
|
||||||
|
nonRotated && element.angle !== 0
|
||||||
|
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||||
|
: ElementBounds.boundsCache.get(element);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cachedBounds?.version &&
|
cachedBounds?.version &&
|
||||||
@@ -115,6 +121,23 @@ export class ElementBounds {
|
|||||||
) {
|
) {
|
||||||
return cachedBounds.bounds;
|
return cachedBounds.bounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nonRotated && element.angle !== 0) {
|
||||||
|
const nonRotatedBounds = ElementBounds.calculateBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
ElementBounds.nonRotatedBoundsCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
bounds: nonRotatedBounds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nonRotatedBounds;
|
||||||
|
}
|
||||||
|
|
||||||
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
||||||
|
|
||||||
ElementBounds.boundsCache.set(element, {
|
ElementBounds.boundsCache.set(element, {
|
||||||
@@ -290,19 +313,42 @@ export const getElementLineSegments = (
|
|||||||
|
|
||||||
if (shape.type === "polycurve") {
|
if (shape.type === "polycurve") {
|
||||||
const curves = shape.data;
|
const curves = shape.data;
|
||||||
const points = curves
|
const pointsOnCurves = curves.map((curve) =>
|
||||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
pointsOnBezierCurves(curve, 10),
|
||||||
.flat();
|
);
|
||||||
let i = 0;
|
|
||||||
const segments: LineSegment<GlobalPoint>[] = [];
|
const segments: LineSegment<GlobalPoint>[] = [];
|
||||||
while (i < points.length - 1) {
|
|
||||||
segments.push(
|
if (
|
||||||
lineSegment(
|
(isLineElement(element) && !element.polygon) ||
|
||||||
pointFrom(points[i][0], points[i][1]),
|
isArrowElement(element)
|
||||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
) {
|
||||||
),
|
for (const points of pointsOnCurves) {
|
||||||
);
|
let i = 0;
|
||||||
i++;
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const points = pointsOnCurves.flat();
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
@@ -553,7 +599,7 @@ const solveQuadratic = (
|
|||||||
return [s1, s2];
|
return [s1, s2];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCubicBezierCurveBound = (
|
export const getCubicBezierCurveBound = (
|
||||||
p0: GlobalPoint,
|
p0: GlobalPoint,
|
||||||
p1: GlobalPoint,
|
p1: GlobalPoint,
|
||||||
p2: GlobalPoint,
|
p2: GlobalPoint,
|
||||||
@@ -939,8 +985,9 @@ const getLinearElementRotatedBounds = (
|
|||||||
export const getElementBounds = (
|
export const getElementBounds = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
nonRotated: boolean = false,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
return ElementBounds.getBounds(element, elementsMap);
|
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCommonBounds = (
|
export const getCommonBounds = (
|
||||||
@@ -1094,7 +1141,9 @@ export interface BoundingBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCommonBoundingBox = (
|
export const getCommonBoundingBox = (
|
||||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
elements:
|
||||||
|
| readonly ExcalidrawElement[]
|
||||||
|
| readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): BoundingBox => {
|
): BoundingBox => {
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
return {
|
return {
|
||||||
@@ -1133,6 +1182,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
|||||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the axis-aligned bounding box for a given element
|
||||||
|
*/
|
||||||
|
export const aabbForElement = (
|
||||||
|
element: Readonly<ExcalidrawElement>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
offset?: [number, number, number, number],
|
||||||
|
) => {
|
||||||
|
const bbox = {
|
||||||
|
minX: element.x,
|
||||||
|
minY: element.y,
|
||||||
|
maxX: element.x + element.width,
|
||||||
|
maxY: element.y + element.height,
|
||||||
|
midX: element.x + element.width / 2,
|
||||||
|
midY: element.y + element.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [topRightX, topRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bounds = [
|
||||||
|
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (offset) {
|
||||||
|
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||||
|
return [
|
||||||
|
bounds[0] - leftOffset,
|
||||||
|
bounds[1] - topOffset,
|
||||||
|
bounds[2] + rightOffset,
|
||||||
|
bounds[3] + downOffset,
|
||||||
|
] as Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p: P,
|
||||||
|
bounds: Bounds,
|
||||||
|
): boolean =>
|
||||||
|
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||||
|
|
||||||
export const doBoundsIntersect = (
|
export const doBoundsIntersect = (
|
||||||
bounds1: Bounds | null,
|
bounds1: Bounds | null,
|
||||||
bounds2: Bounds | null,
|
bounds2: Bounds | null,
|
||||||
@@ -1146,3 +1260,21 @@ export const doBoundsIntersect = (
|
|||||||
|
|
||||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const elementCenterPoint = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
xOffset: number = 0,
|
||||||
|
yOffset: number = 0,
|
||||||
|
) => {
|
||||||
|
if (isLinearElement(element)) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
};
|
||||||
|
|||||||
+556
-128
@@ -1,53 +1,77 @@
|
|||||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
line,
|
|
||||||
lineSegment,
|
lineSegment,
|
||||||
lineSegmentIntersectionPoints,
|
lineSegmentIntersectionPoints,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
|
pointFromVector,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
pointsEqual,
|
pointsEqual,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
vectorScale,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ellipse,
|
ellipse,
|
||||||
ellipseLineIntersectionPoints,
|
ellipseSegmentInterceptPoints,
|
||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Curve,
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
LineSegment,
|
LineSegment,
|
||||||
LocalPoint,
|
|
||||||
Polygon,
|
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
import { isPathALoop } from "./utils";
|
||||||
import { getElementBounds } from "./bounds";
|
import {
|
||||||
|
doBoundsIntersect,
|
||||||
|
elementCenterPoint,
|
||||||
|
getCenterForBounds,
|
||||||
|
getCubicBezierCurveBound,
|
||||||
|
getDiamondPoints,
|
||||||
|
getElementBounds,
|
||||||
|
pointInsideBounds,
|
||||||
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isBindableElement,
|
||||||
|
isFrameLikeElement,
|
||||||
|
isFreeDrawElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
|
||||||
|
import { distanceToElement } from "./distance";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
|
NonDeleted,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
Ordered,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||||
@@ -72,45 +96,68 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
|||||||
return isDraggableFromInside || isImageElement(element);
|
return isDraggableFromInside || isImageElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
export type HitTestArgs = {
|
||||||
x: number;
|
point: GlobalPoint;
|
||||||
y: number;
|
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
shape: GeometricShape<Point>;
|
threshold: number;
|
||||||
threshold?: number;
|
elementsMap: ElementsMap;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
|
overrideShouldTestInside?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
export const hitElementItself = ({
|
||||||
x,
|
point,
|
||||||
y,
|
|
||||||
element,
|
element,
|
||||||
shape,
|
threshold,
|
||||||
threshold = 10,
|
elementsMap,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
}: HitTestArgs<Point>) => {
|
overrideShouldTestInside = false,
|
||||||
let hit = shouldTestInside(element)
|
}: HitTestArgs) => {
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
// Hit test against a frame's name
|
||||||
// we would need `onShape` as well to include the "borders"
|
const hitFrameName = frameNameBound
|
||||||
isPointInShape(pointFrom(x, y), shape) ||
|
? isPointWithinBounds(
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
point,
|
||||||
|
pointFrom(
|
||||||
|
frameNameBound.x + frameNameBound.width + threshold,
|
||||||
|
frameNameBound.y + frameNameBound.height + threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
// hit test against a frame's name
|
// Hit test against the extended, rotated bounding box of the element first
|
||||||
if (!hit && frameNameBound) {
|
const bounds = getElementBounds(element, elementsMap, true);
|
||||||
hit = isPointInShape(pointFrom(x, y), {
|
const hitBounds = isPointWithinBounds(
|
||||||
type: "polygon",
|
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
pointRotateRads(
|
||||||
.data as Polygon<Point>,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hit;
|
// Do the precise (and relatively costly) hit test
|
||||||
|
const hitElement = (
|
||||||
|
overrideShouldTestInside ? true : 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 = (
|
export const hitElementBoundingBox = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
tolerance = 0,
|
tolerance = 0,
|
||||||
@@ -120,37 +167,152 @@ export const hitElementBoundingBox = (
|
|||||||
y1 -= tolerance;
|
y1 -= tolerance;
|
||||||
x2 += tolerance;
|
x2 += tolerance;
|
||||||
y2 += tolerance;
|
y2 += tolerance;
|
||||||
return isPointWithinBounds(
|
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||||
pointFrom(x1, y1),
|
|
||||||
pointFrom(x, y),
|
|
||||||
pointFrom(x2, y2),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = <
|
export const hitElementBoundingBoxOnly = (
|
||||||
Point extends GlobalPoint | LocalPoint,
|
hitArgs: HitTestArgs,
|
||||||
>(
|
|
||||||
hitArgs: HitTestArgs<Point>,
|
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) =>
|
||||||
return (
|
!hitElementItself(hitArgs) &&
|
||||||
!hitElementItself(hitArgs) &&
|
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||||
!hitElementBoundText(
|
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||||
hitArgs.x,
|
|
||||||
hitArgs.y,
|
export const hitElementBoundText = (
|
||||||
getBoundTextShape(hitArgs.element, elementsMap),
|
point: GlobalPoint,
|
||||||
) &&
|
element: ExcalidrawElement,
|
||||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
const bindingBorderTest = (
|
||||||
x: number,
|
element: NonDeleted<ExcalidrawBindableElement>,
|
||||||
y: number,
|
[x, y]: Readonly<GlobalPoint>,
|
||||||
textShape: GeometricShape<Point> | null,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
tolerance: number = 0,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
const p = pointFrom<GlobalPoint>(x, y);
|
||||||
|
const shouldTestInside =
|
||||||
|
// disable fullshape snapping for frame elements so we
|
||||||
|
// can bind to frame children
|
||||||
|
!isFrameLikeElement(element);
|
||||||
|
|
||||||
|
// PERF: Run a cheap test to see if the binding element
|
||||||
|
// is even close to the element
|
||||||
|
const t = Math.max(1, tolerance);
|
||||||
|
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||||
|
const elementBounds = getElementBounds(element, elementsMap);
|
||||||
|
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the element is inside a frame, we should clip the element
|
||||||
|
if (element.frameId) {
|
||||||
|
const enclosingFrame = elementsMap.get(element.frameId);
|
||||||
|
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||||
|
const enclosingFrameBounds = getElementBounds(
|
||||||
|
enclosingFrame,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the intersection test against the element since it's close enough
|
||||||
|
const intersections = intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||||
|
);
|
||||||
|
const distance = distanceToElement(element, elementsMap, p);
|
||||||
|
|
||||||
|
return shouldTestInside
|
||||||
|
? intersections.length === 0 || distance <= tolerance
|
||||||
|
: intersections.length > 0 && distance <= t;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllHoveredElementAtPoint = (
|
||||||
|
point: Readonly<GlobalPoint>,
|
||||||
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||||
|
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||||
|
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||||
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||||
|
// because array is ordered from lower z-index to highest and we want element z-index
|
||||||
|
// with higher z-index
|
||||||
|
for (let index = elements.length - 1; index >= 0; --index) {
|
||||||
|
const element = elements[index];
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
!element.isDeleted,
|
||||||
|
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isBindableElement(element, false) &&
|
||||||
|
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
|
||||||
|
) {
|
||||||
|
candidateElements.push(element);
|
||||||
|
|
||||||
|
if (!isTransparent(element.backgroundColor)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHoveredElementForBinding = (
|
||||||
|
point: Readonly<GlobalPoint>,
|
||||||
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||||
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
|
const candidateElements = getAllHoveredElementAtPoint(
|
||||||
|
point,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
toleranceFn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!candidateElements || candidateElements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateElements.length === 1) {
|
||||||
|
return candidateElements[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer smaller shapes
|
||||||
|
return candidateElements
|
||||||
|
.sort(
|
||||||
|
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||||
|
)
|
||||||
|
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,9 +325,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
*/
|
*/
|
||||||
export const intersectElementWithLineSegment = (
|
export const intersectElementWithLineSegment = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
line: LineSegment<GlobalPoint>,
|
line: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): 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) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
@@ -173,67 +352,196 @@ export const intersectElementWithLineSegment = (
|
|||||||
case "iframe":
|
case "iframe":
|
||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
|
case "selection":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
return intersectRectanguloidWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return intersectDiamondWithLineSegment(element, line, offset);
|
return intersectDiamondWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return intersectEllipseWithLineSegment(element, line, offset);
|
return intersectEllipseWithLineSegment(
|
||||||
default:
|
element,
|
||||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
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 = (
|
const intersectRectanguloidWithLineSegment = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
l: LineSegment<GlobalPoint>,
|
elementsMap: ElementsMap,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||||
l[0],
|
segment[0],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||||
l[1],
|
segment[1],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
// Get the element's building components we can test against
|
// Get the element's building components we can test against
|
||||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||||
|
|
||||||
return (
|
const intersections: GlobalPoint[] = [];
|
||||||
// Test intersection against the sides, keep only the valid
|
|
||||||
// intersection points and rotate them back to scene space
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((x) => x != null)
|
|
||||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
|
|
||||||
// Test intersection against the corners which are cubic bezier curves,
|
|
||||||
// keep only the valid intersection points and rotate them back to scene
|
|
||||||
// space
|
|
||||||
.concat(
|
|
||||||
corners
|
|
||||||
.flatMap((t) =>
|
|
||||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((i) => i != null)
|
|
||||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,43 +553,45 @@ const intersectRectanguloidWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectDiamondWithLineSegment = (
|
const intersectDiamondWithLineSegment = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||||
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
return (
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((p): p is GlobalPoint => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
|
|
||||||
.concat(
|
|
||||||
curves
|
|
||||||
.flatMap((p) =>
|
|
||||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((p) => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,16 +603,134 @@ const intersectDiamondWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectEllipseWithLineSegment = (
|
const intersectEllipseWithLineSegment = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
|
||||||
return ellipseLineIntersectionPoints(
|
return ellipseSegmentInterceptPoints(
|
||||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||||
line(rotatedA, rotatedB),
|
lineSegment(rotatedA, rotatedB),
|
||||||
).map((p) => pointRotateRads(p, center, element.angle));
|
).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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isBindableElementInsideOtherBindable = (
|
||||||
|
innerElement: ExcalidrawBindableElement,
|
||||||
|
outerElement: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): boolean => {
|
||||||
|
// Get corner points of the inner element based on its type
|
||||||
|
const getCornerPoints = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
offset: number,
|
||||||
|
): GlobalPoint[] => {
|
||||||
|
const { x, y, width, height, angle } = element;
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
|
if (element.type === "diamond") {
|
||||||
|
// Diamond has 4 corner points at the middle of each side
|
||||||
|
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||||
|
getDiamondPoints(element);
|
||||||
|
const corners: GlobalPoint[] = [
|
||||||
|
pointFrom(x + topX, y + topY - offset), // top
|
||||||
|
pointFrom(x + rightX + offset, y + rightY), // right
|
||||||
|
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||||
|
pointFrom(x + leftX - offset, y + leftY), // left
|
||||||
|
];
|
||||||
|
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||||
|
}
|
||||||
|
if (element.type === "ellipse") {
|
||||||
|
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||||
|
const cx = x + width / 2;
|
||||||
|
const cy = y + height / 2;
|
||||||
|
const rx = width / 2;
|
||||||
|
const ry = height / 2;
|
||||||
|
const corners: GlobalPoint[] = [
|
||||||
|
pointFrom(cx, cy - ry - offset), // top
|
||||||
|
pointFrom(cx + rx + offset, cy), // right
|
||||||
|
pointFrom(cx, cy + ry + offset), // bottom
|
||||||
|
pointFrom(cx - rx - offset, cy), // left
|
||||||
|
];
|
||||||
|
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||||
|
}
|
||||||
|
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||||
|
const corners: GlobalPoint[] = [
|
||||||
|
pointFrom(x - offset, y - offset), // top-left
|
||||||
|
pointFrom(x + width + offset, y - offset), // top-right
|
||||||
|
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||||
|
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||||
|
];
|
||||||
|
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||||
|
};
|
||||||
|
|
||||||
|
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||||
|
const innerCorners = getCornerPoints(innerElement, offset);
|
||||||
|
|
||||||
|
// Check if all corner points of the inner element are inside the outer element
|
||||||
|
return innerCorners.every((corner) =>
|
||||||
|
isPointInElement(corner, outerElement, elementsMap),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ export const hasBackground = (type: ElementOrToolType) =>
|
|||||||
type === "freedraw";
|
type === "freedraw";
|
||||||
|
|
||||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
type === "rectangle" ||
|
||||||
|
type === "ellipse" ||
|
||||||
|
type === "diamond" ||
|
||||||
|
type === "freedraw" ||
|
||||||
|
type === "arrow" ||
|
||||||
|
type === "line" ||
|
||||||
|
type === "text" ||
|
||||||
|
type === "embeddable";
|
||||||
|
|
||||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Point } from "points-on-curve";
|
import { type Point } from "points-on-curve";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
elementCenterPoint,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
@@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
|
|||||||
|
|
||||||
export const cropElement = (
|
export const cropElement = (
|
||||||
element: ExcalidrawImageElement,
|
element: ExcalidrawImageElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
transformHandle: TransformHandleType,
|
transformHandle: TransformHandleType,
|
||||||
naturalWidth: number,
|
naturalWidth: number,
|
||||||
naturalHeight: number,
|
naturalHeight: number,
|
||||||
@@ -63,7 +63,7 @@ export const cropElement = (
|
|||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
pointFrom(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,27 +6,33 @@ import {
|
|||||||
|
|
||||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "./bounds";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToElement = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
|
case "selection":
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return distanceToRectanguloidElement(element, p);
|
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return distanceToDiamondElement(element, p);
|
return distanceToDiamondElement(element, elementsMap, p);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return distanceToEllipseElement(element, p);
|
return distanceToEllipseElement(element, elementsMap, p);
|
||||||
|
case "line":
|
||||||
|
case "arrow":
|
||||||
|
case "freedraw":
|
||||||
|
return distanceToLinearOrFreeDraElement(element, p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,9 +62,10 @@ export const distanceToBindableElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToRectanguloidElement = (
|
const distanceToRectanguloidElement = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToDiamondElement = (
|
const distanceToDiamondElement = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
@@ -108,12 +120,24 @@ const distanceToDiamondElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToEllipseElement = (
|
const distanceToEllipseElement = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
return ellipseDistanceFromPoint(
|
return ellipseDistanceFromPoint(
|
||||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||||
pointRotateRads(p, center, -element.angle as Radians),
|
pointRotateRads(p, center, -element.angle as Radians),
|
||||||
ellipse(center, element.width / 2, element.height / 2),
|
ellipse(center, element.width / 2, element.height / 2),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const distanceToLinearOrFreeDraElement = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
p: GlobalPoint,
|
||||||
|
) => {
|
||||||
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
return Math.min(
|
||||||
|
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||||
|
...curves.map((a) => curvePointDistance(a, p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getSelectedElementsByGroup } from "./groups";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export const distributeElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
distribution: Distribution,
|
distribution: Distribution,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const [start, mid, end, extent] =
|
const [start, mid, end, extent] =
|
||||||
distribution.axis === "x"
|
distribution.axis === "x"
|
||||||
@@ -21,7 +24,11 @@ export const distributeElements = (
|
|||||||
: (["minY", "midY", "maxY", "height"] as const);
|
: (["minY", "midY", "maxY", "height"] as const);
|
||||||
|
|
||||||
const bounds = getCommonBoundingBox(selectedElements);
|
const bounds = getCommonBoundingBox(selectedElements);
|
||||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
const groups = getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
)
|
||||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
type Bounds,
|
||||||
TEXT_AUTOWRAP_THRESHOLD,
|
TEXT_AUTOWRAP_THRESHOLD,
|
||||||
getGridPoint,
|
getGridPoint,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
DRAGGING_THRESHOLD,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -13,7 +15,7 @@ import type {
|
|||||||
|
|
||||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||||
import { getCommonBounds } from "./bounds";
|
import { getCommonBounds } from "./bounds";
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
@@ -26,9 +28,8 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import type Scene from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
|
||||||
import type { ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
@@ -102,9 +103,26 @@ export const dragSelectedElements = (
|
|||||||
gridSize,
|
gridSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const elementsToUpdateIds = new Set(
|
||||||
|
Array.from(elementsToUpdate, (el) => el.id),
|
||||||
|
);
|
||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
const isArrow = !isArrowElement(element);
|
||||||
|
const isStartBoundElementSelected =
|
||||||
|
isArrow ||
|
||||||
|
(element.startBinding
|
||||||
|
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||||
|
: false);
|
||||||
|
const isEndBoundElementSelected =
|
||||||
|
isArrow ||
|
||||||
|
(element.endBinding
|
||||||
|
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||||
|
: false);
|
||||||
|
|
||||||
if (!isArrowElement(element)) {
|
if (!isArrowElement(element)) {
|
||||||
|
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||||
|
|
||||||
// skip arrow labels since we calculate its position during render
|
// skip arrow labels since we calculate its position during render
|
||||||
const textElement = getBoundTextElement(
|
const textElement = getBoundTextElement(
|
||||||
element,
|
element,
|
||||||
@@ -121,6 +139,33 @@ export const dragSelectedElements = (
|
|||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
});
|
});
|
||||||
|
} else if (
|
||||||
|
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||||
|
// is the single element being dragged to avoid accidentally unbinding
|
||||||
|
// the arrow when the user just wants to select it.
|
||||||
|
|
||||||
|
elementsToUpdate.size > 1 ||
|
||||||
|
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||||
|
DRAGGING_THRESHOLD ||
|
||||||
|
(!element.startBinding && !element.endBinding)
|
||||||
|
) {
|
||||||
|
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||||
|
|
||||||
|
const shouldUnbindStart =
|
||||||
|
element.startBinding && !isStartBoundElementSelected;
|
||||||
|
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||||
|
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||||
|
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||||
|
// have weird situations, like 0 lenght arrow when the user moves
|
||||||
|
// the arrow outside a filled shape suddenly forcing the arrow start
|
||||||
|
// and end point to jump "outside" the shape.
|
||||||
|
if (shouldUnbindStart) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
if (shouldUnbindEnd) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,25 +14,26 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type Bounds,
|
||||||
BinaryHeap,
|
BinaryHeap,
|
||||||
invariant,
|
invariant,
|
||||||
isAnyTrue,
|
isAnyTrue,
|
||||||
tupleToCoors,
|
|
||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
arrayToMap,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindPointToSnapToElementOutline,
|
bindPointToSnapToElementOutline,
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
getHeadingForElbowArrowSnap,
|
getHeadingForElbowArrowSnap,
|
||||||
getGlobalFixedPointForBindableElement,
|
getGlobalFixedPointForBindableElement,
|
||||||
snapToMid,
|
getBindingGap,
|
||||||
getHoveredElementForBinding,
|
maxBindingDistance_simple,
|
||||||
|
BASE_BINDING_GAP_ELBOW,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
compareHeading,
|
||||||
flipHeading,
|
flipHeading,
|
||||||
@@ -51,10 +52,9 @@ import {
|
|||||||
type ExcalidrawElbowArrowElement,
|
type ExcalidrawElbowArrowElement,
|
||||||
type NonDeletedSceneElementsMap,
|
type NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||||
|
import { getHoveredElementForBinding } from "./collision";
|
||||||
|
|
||||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
import type {
|
import type {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
@@ -63,6 +63,7 @@ import type {
|
|||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
|
Ordered,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||||
@@ -359,6 +360,12 @@ const handleSegmentRelease = (
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!restoredPoints || restoredPoints.length < 2) {
|
||||||
|
throw new Error(
|
||||||
|
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextPoints: GlobalPoint[] = [];
|
const nextPoints: GlobalPoint[] = [];
|
||||||
|
|
||||||
// First part of the arrow are the old points
|
// First part of the arrow are the old points
|
||||||
@@ -706,7 +713,7 @@ const handleEndpointDrag = (
|
|||||||
endGlobalPoint: GlobalPoint,
|
endGlobalPoint: GlobalPoint,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
) => {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||||
@@ -741,8 +748,15 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second point connection and add the start point
|
// Calculate the moving second point connection and add the start point
|
||||||
{
|
{
|
||||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||||
|
|
||||||
|
if (!secondPoint || !thirdPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||||
const secondIsHorizontal = headingIsHorizontal(
|
const secondIsHorizontal = headingIsHorizontal(
|
||||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||||
@@ -801,10 +815,19 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second to last point connection
|
// Calculate the moving second to last point connection
|
||||||
{
|
{
|
||||||
const secondToLastPoint =
|
const secondToLastPoint = globalUpdatedPoints.at(
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||||
const thirdToLastPoint =
|
);
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||||
|
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||||
thirdToLastPoint,
|
thirdToLastPoint,
|
||||||
@@ -898,50 +921,6 @@ export const updateElbowArrowPoints = (
|
|||||||
return { points: updates.points ?? arrow.points };
|
return { points: updates.points ?? arrow.points };
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
|
||||||
// arrow size is valid. This check will be removed once the issue is identified
|
|
||||||
if (
|
|
||||||
arrow.x < -MAX_POS ||
|
|
||||||
arrow.x > MAX_POS ||
|
|
||||||
arrow.y < -MAX_POS ||
|
|
||||||
arrow.y > MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
|
||||||
{
|
|
||||||
arrow,
|
|
||||||
updates,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
|
||||||
if (updates.points) {
|
|
||||||
updates.points = updates.points.map(([x, y]) =>
|
|
||||||
pointFrom<LocalPoint>(
|
|
||||||
clamp(x, -MAX_POS, MAX_POS),
|
|
||||||
clamp(y, -MAX_POS, MAX_POS),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
invariant(
|
invariant(
|
||||||
!updates.points || updates.points.length >= 2,
|
!updates.points || updates.points.length >= 2,
|
||||||
@@ -974,6 +953,25 @@ export const updateElbowArrowPoints = (
|
|||||||
),
|
),
|
||||||
"Elbow arrow segments must be either horizontal or vertical",
|
"Elbow arrow segments must be either horizontal or vertical",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
updates.fixedSegments?.find(
|
||||||
|
(segment) =>
|
||||||
|
segment.index === 1 &&
|
||||||
|
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
|
||||||
|
) == null &&
|
||||||
|
updates.fixedSegments?.find(
|
||||||
|
(segment) =>
|
||||||
|
segment.index === (updates.points ?? arrow.points).length - 1 &&
|
||||||
|
pointsEqual(
|
||||||
|
segment.end,
|
||||||
|
(updates.points ?? arrow.points)[
|
||||||
|
(updates.points ?? arrow.points).length - 1
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) == null,
|
||||||
|
"The first and last segments cannot be fixed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||||
@@ -1246,6 +1244,7 @@ const getElbowArrowData = (
|
|||||||
const startGlobalPoint = getGlobalPoint(
|
const startGlobalPoint = getGlobalPoint(
|
||||||
{
|
{
|
||||||
...arrow,
|
...arrow,
|
||||||
|
angle: 0,
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
@@ -1254,11 +1253,13 @@ const getElbowArrowData = (
|
|||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const endGlobalPoint = getGlobalPoint(
|
const endGlobalPoint = getGlobalPoint(
|
||||||
{
|
{
|
||||||
...arrow,
|
...arrow,
|
||||||
|
angle: 0,
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
@@ -1267,6 +1268,7 @@ const getElbowArrowData = (
|
|||||||
arrow.endBinding?.fixedPoint,
|
arrow.endBinding?.fixedPoint,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const startHeading = getBindPointHeading(
|
const startHeading = getBindPointHeading(
|
||||||
@@ -1274,12 +1276,16 @@ const getElbowArrowData = (
|
|||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
|
options?.zoom,
|
||||||
);
|
);
|
||||||
const endHeading = getBindPointHeading(
|
const endHeading = getBindPointHeading(
|
||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
startGlobalPoint,
|
startGlobalPoint,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
|
options?.zoom,
|
||||||
);
|
);
|
||||||
const startPointBounds = [
|
const startPointBounds = [
|
||||||
startGlobalPoint[0] - 2,
|
startGlobalPoint[0] - 2,
|
||||||
@@ -1296,11 +1302,12 @@ const getElbowArrowData = (
|
|||||||
const startElementBounds = hoveredStartElement
|
const startElementBounds = hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
startHeading,
|
startHeading,
|
||||||
arrow.startArrowhead
|
arrow.startArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2,
|
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1308,11 +1315,12 @@ const getElbowArrowData = (
|
|||||||
const endElementBounds = hoveredEndElement
|
const endElementBounds = hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
endHeading,
|
endHeading,
|
||||||
arrow.endArrowhead
|
arrow.endArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2,
|
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1323,6 +1331,7 @@ const getElbowArrowData = (
|
|||||||
hoveredEndElement
|
hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
||||||
)
|
)
|
||||||
: endPointBounds,
|
: endPointBounds,
|
||||||
@@ -1332,6 +1341,7 @@ const getElbowArrowData = (
|
|||||||
hoveredStartElement
|
hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
||||||
)
|
)
|
||||||
: startPointBounds,
|
: startPointBounds,
|
||||||
@@ -1357,8 +1367,8 @@ const getElbowArrowData = (
|
|||||||
? 0
|
? 0
|
||||||
: BASE_PADDING -
|
: BASE_PADDING -
|
||||||
(arrow.startArrowhead
|
(arrow.startArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? BASE_BINDING_GAP_ELBOW * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2),
|
: BASE_BINDING_GAP_ELBOW * 2),
|
||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap
|
boundsOverlap
|
||||||
@@ -1373,13 +1383,13 @@ const getElbowArrowData = (
|
|||||||
? 0
|
? 0
|
||||||
: BASE_PADDING -
|
: BASE_PADDING -
|
||||||
(arrow.endArrowhead
|
(arrow.endArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? BASE_BINDING_GAP_ELBOW * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2),
|
: BASE_BINDING_GAP_ELBOW * 2),
|
||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap,
|
boundsOverlap,
|
||||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
|
||||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
|
||||||
);
|
);
|
||||||
const startDonglePosition = getDonglePosition(
|
const startDonglePosition = getDonglePosition(
|
||||||
dynamicAABBs[0],
|
dynamicAABBs[0],
|
||||||
@@ -2088,16 +2098,7 @@ const normalizeArrowElementUpdate = (
|
|||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
points: LocalPoint[];
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
} => {
|
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
let points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
@@ -2210,35 +2211,28 @@ const getGlobalPoint = (
|
|||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: GlobalPoint,
|
initialPoint: GlobalPoint,
|
||||||
element?: ExcalidrawBindableElement | null,
|
element?: ExcalidrawBindableElement | null,
|
||||||
|
elementsMap?: ElementsMap,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
if (element) {
|
if (element && elementsMap) {
|
||||||
const snapPoint = bindPointToSnapToElementOutline(
|
return bindPointToSnapToElementOutline(
|
||||||
arrow,
|
arrow,
|
||||||
element,
|
element,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
return snapToMid(element, snapPoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
return getGlobalFixedPointForBindableElement(
|
||||||
fixedPointRatio || [0, 0],
|
fixedPointRatio || [0, 0],
|
||||||
element,
|
element,
|
||||||
|
elementsMap ?? arrayToMap([element]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
|
||||||
return Math.abs(
|
|
||||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
) > 0.01
|
|
||||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
|
||||||
: fixedGlobalPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
@@ -2249,6 +2243,8 @@ const getBindPointHeading = (
|
|||||||
otherPoint: GlobalPoint,
|
otherPoint: GlobalPoint,
|
||||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
zoom?: AppState["zoom"],
|
||||||
): Heading =>
|
): Heading =>
|
||||||
getHeadingForElbowArrowSnap(
|
getHeadingForElbowArrowSnap(
|
||||||
p,
|
p,
|
||||||
@@ -2257,7 +2253,8 @@ const getBindPointHeading = (
|
|||||||
hoveredElement &&
|
hoveredElement &&
|
||||||
aabbForElement(
|
aabbForElement(
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
elementsMap,
|
||||||
|
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
@@ -2265,21 +2262,21 @@ const getBindPointHeading = (
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
origPoint,
|
origPoint,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getHoveredElement = (
|
const getHoveredElement = (
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
) => {
|
) => {
|
||||||
return getHoveredElementForBinding(
|
return getHoveredElementForBinding(
|
||||||
tupleToCoors(origPoint),
|
origPoint,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
zoom,
|
(element) => maxBindingDistance_simple(zoom),
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
|||||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||||
|
|
||||||
const RE_YOUTUBE =
|
const RE_YOUTUBE =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||||
|
|
||||||
const RE_VIMEO =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
@@ -33,6 +33,8 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
|||||||
const RE_GH_GIST_EMBED =
|
const RE_GH_GIST_EMBED =
|
||||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||||
|
|
||||||
|
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
|
||||||
|
|
||||||
// not anchored to start to allow <blockquote> twitter embeds
|
// not anchored to start to allow <blockquote> twitter embeds
|
||||||
const RE_TWITTER =
|
const RE_TWITTER =
|
||||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||||
@@ -54,6 +56,35 @@ const RE_REDDIT =
|
|||||||
const RE_REDDIT_EMBED =
|
const RE_REDDIT_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||||
|
|
||||||
|
const parseYouTubeTimestamp = (url: string): number => {
|
||||||
|
let timeParam: string | null | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||||
|
timeParam =
|
||||||
|
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
||||||
|
} catch (error) {
|
||||||
|
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
||||||
|
timeParam = timeMatch?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeParam) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(timeParam)) {
|
||||||
|
return parseInt(timeParam, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||||
|
if (!timeMatch) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
||||||
|
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
const ALLOWED_DOMAINS = new Set([
|
const ALLOWED_DOMAINS = new Set([
|
||||||
"youtube.com",
|
"youtube.com",
|
||||||
"youtu.be",
|
"youtu.be",
|
||||||
@@ -69,6 +100,7 @@ const ALLOWED_DOMAINS = new Set([
|
|||||||
"val.town",
|
"val.town",
|
||||||
"giphy.com",
|
"giphy.com",
|
||||||
"reddit.com",
|
"reddit.com",
|
||||||
|
"forms.microsoft.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOW_SAME_ORIGIN = new Set([
|
const ALLOW_SAME_ORIGIN = new Set([
|
||||||
@@ -82,6 +114,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
|||||||
"*.simplepdf.eu",
|
"*.simplepdf.eu",
|
||||||
"stackblitz.com",
|
"stackblitz.com",
|
||||||
"reddit.com",
|
"reddit.com",
|
||||||
|
"forms.microsoft.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const createSrcDoc = (body: string) => {
|
export const createSrcDoc = (body: string) => {
|
||||||
@@ -109,7 +142,8 @@ export const getEmbedLink = (
|
|||||||
let aspectRatio = { w: 560, h: 840 };
|
let aspectRatio = { w: 560, h: 840 };
|
||||||
const ytLink = link.match(RE_YOUTUBE);
|
const ytLink = link.match(RE_YOUTUBE);
|
||||||
if (ytLink?.[2]) {
|
if (ytLink?.[2]) {
|
||||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
const startTime = parseYouTubeTimestamp(originalLink);
|
||||||
|
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||||
const isPortrait = link.includes("shorts");
|
const isPortrait = link.includes("shorts");
|
||||||
type = "video";
|
type = "video";
|
||||||
switch (ytLink[1]) {
|
switch (ytLink[1]) {
|
||||||
@@ -206,6 +240,10 @@ export const getEmbedLink = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
|
||||||
|
link += link.includes("?") ? "&embed=true" : "?embed=true";
|
||||||
|
}
|
||||||
|
|
||||||
if (RE_TWITTER.test(link)) {
|
if (RE_TWITTER.test(link)) {
|
||||||
const postId = link.match(RE_TWITTER)![1];
|
const postId = link.match(RE_TWITTER)![1];
|
||||||
// the embed srcdoc still supports twitter.com domain only.
|
// the embed srcdoc still supports twitter.com domain only.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
PendingExcalidrawElements,
|
PendingExcalidrawElements,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { bindLinearElement } from "./binding";
|
import { bindBindingElement } from "./binding";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
import {
|
import {
|
||||||
HEADING_DOWN,
|
HEADING_DOWN,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import { aabbForElement } from "./shapes";
|
import { aabbForElement } from "./bounds";
|
||||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||||
import {
|
import {
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
type OrderedExcalidrawElement,
|
type OrderedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type Scene from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
@@ -95,10 +95,11 @@ const getNodeRelatives = (
|
|||||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||||
) as Readonly<LocalPoint>;
|
) as Readonly<LocalPoint>;
|
||||||
|
|
||||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
const heading = headingForPointFromElement(
|
||||||
edgePoint[0] + el.x,
|
node,
|
||||||
edgePoint[1] + el.y,
|
aabbForElement(node, elementsMap),
|
||||||
] as Readonly<GlobalPoint>);
|
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||||
|
);
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
relative,
|
relative,
|
||||||
@@ -445,8 +446,14 @@ const createBindingArrow = (
|
|||||||
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
bindBindingElement(
|
||||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
bindingArrow,
|
||||||
|
startBindingElement,
|
||||||
|
"orbit",
|
||||||
|
"start",
|
||||||
|
scene,
|
||||||
|
);
|
||||||
|
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||||
|
|
||||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
changedElements.set(
|
changedElements.set(
|
||||||
@@ -462,12 +469,18 @@ const createBindingArrow = (
|
|||||||
bindingArrow as OrderedExcalidrawElement,
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(bindingArrow, scene, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
bindingArrow,
|
||||||
index: 1,
|
scene,
|
||||||
point: bindingArrow.points[1],
|
new Map([
|
||||||
},
|
[
|
||||||
]);
|
1,
|
||||||
|
{
|
||||||
|
point: bindingArrow.points[1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const update = updateElbowArrowPoints(
|
const update = updateElbowArrowPoints(
|
||||||
bindingArrow,
|
bindingArrow,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
|||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { hasBoundTextElement } from "./typeChecks";
|
import { hasBoundTextElement } from "./typeChecks";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class InvalidFractionalIndexError extends Error {
|
export class InvalidFractionalIndexError extends Error {
|
||||||
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
|
|||||||
|
|
||||||
// try generatating indices, throws on invalid movedElements
|
// try generatating indices, throws on invalid movedElements
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
const elementsCandidates = elements.map((x) =>
|
const elementsCandidates = elements.map((x) => {
|
||||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
const elementUpdates = elementsUpdates.get(x);
|
||||||
);
|
|
||||||
|
if (elementUpdates) {
|
||||||
|
return { ...x, index: elementUpdates.index };
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
// ensure next indices are valid before mutation, throws on invalid ones
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
validateFractionalIndices(
|
validateFractionalIndices(
|
||||||
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// split mutation so we don't end up in an incosistent state
|
// split mutation so we don't end up in an incosistent state
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fallback to default sync
|
// fallback to default sync
|
||||||
@@ -189,7 +196,7 @@ export const syncMovedIndices = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||||
*
|
*
|
||||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
*/
|
*/
|
||||||
@@ -200,13 +207,32 @@ export const syncInvalidIndices = (
|
|||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements as OrderedExcalidrawElement[];
|
return elements as OrderedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
|
||||||
|
*
|
||||||
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
|
*/
|
||||||
|
export const syncInvalidIndicesImmutable = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): SceneElementsMap | undefined => {
|
||||||
|
const syncedElements = arrayToMap(elements);
|
||||||
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
|
syncedElements.set(element.id, newElementWith(element, { index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncedElements as SceneElementsMap;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get contiguous groups of indices of passed moved elements.
|
* Get contiguous groups of indices of passed moved elements.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -905,13 +905,16 @@ export const shouldApplyFrameClip = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
const DEFAULT_FRAME_NAME = "Frame";
|
||||||
|
const DEFAULT_AI_FRAME_NAME = "AI Frame";
|
||||||
|
|
||||||
|
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
|
||||||
// TODO name frames "AI" only if specific to AI frames
|
// TODO name frames "AI" only if specific to AI frames
|
||||||
return element.name === null
|
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
|
||||||
? isFrameElement(element)
|
};
|
||||||
? "Frame"
|
|
||||||
: "AI Frame"
|
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||||
: element.name;
|
return element.name === null ? getDefaultFrameName(element) : element.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getElementsOverlappingFrame = (
|
export const getElementsOverlappingFrame = (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { isBoundToContainer } from "./typeChecks";
|
||||||
|
|
||||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
|||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// given a list of selected elements, return the element grouped by their immediate group selected state
|
||||||
|
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
||||||
|
export const getSelectedElementsByGroup = (
|
||||||
|
selectedElements: ExcalidrawElement[],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
): ExcalidrawElement[][] => {
|
||||||
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
const unboundElements = selectedElements.filter(
|
||||||
|
(element) => !isBoundToContainer(element),
|
||||||
|
);
|
||||||
|
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
||||||
|
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
||||||
|
|
||||||
|
// helper function to add an element to the elements map
|
||||||
|
const addToElementsMap = (element: ExcalidrawElement) => {
|
||||||
|
// elements
|
||||||
|
const currentElementMembers = elements.get(element.id) || [];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
currentElementMembers.push(boundTextElement);
|
||||||
|
}
|
||||||
|
elements.set(element.id, [...currentElementMembers, element]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to add an element to the groups map
|
||||||
|
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
||||||
|
// groups
|
||||||
|
const currentGroupMembers = groups.get(groupId) || [];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
currentGroupMembers.push(boundTextElement);
|
||||||
|
}
|
||||||
|
groups.set(groupId, [...currentGroupMembers, element]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to handle the case where a single group is selected
|
||||||
|
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
||||||
|
// their nested grouping order
|
||||||
|
const handleSingleSelectedGroupCase = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
selectedGroupId: GroupId,
|
||||||
|
) => {
|
||||||
|
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
||||||
|
const nestedGroupCount = element.groupIds.slice(
|
||||||
|
0,
|
||||||
|
indexOfSelectedGroupId,
|
||||||
|
).length;
|
||||||
|
return nestedGroupCount > 0
|
||||||
|
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
||||||
|
: addToElementsMap(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllInSameGroup = selectedElements.every((element) =>
|
||||||
|
isSelectedViaGroup(appState, element),
|
||||||
|
);
|
||||||
|
|
||||||
|
unboundElements.forEach((element) => {
|
||||||
|
const selectedGroupId = getSelectedGroupIdForElement(
|
||||||
|
element,
|
||||||
|
appState.selectedGroupIds,
|
||||||
|
);
|
||||||
|
if (!selectedGroupId) {
|
||||||
|
addToElementsMap(element);
|
||||||
|
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
||||||
|
handleSingleSelectedGroupCase(element, selectedGroupId);
|
||||||
|
} else {
|
||||||
|
addToGroupsMap(element, selectedGroupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
import {
|
||||||
|
invariant,
|
||||||
|
isDevEnv,
|
||||||
|
isTestEnv,
|
||||||
|
type Bounds,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
pointFrom,
|
pointFrom,
|
||||||
@@ -19,7 +24,7 @@ import type {
|
|||||||
Vector,
|
Vector,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
import { getCenterForBounds } from "./bounds";
|
||||||
|
|
||||||
import type { ExcalidrawBindableElement } from "./types";
|
import type { ExcalidrawBindableElement } from "./types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { toIterable } from "@excalidraw/common";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
import { isLinearElementType } from "./typeChecks";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ElementsMapOrArray,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,18 +18,19 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
/**
|
/**
|
||||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||||
*/
|
*/
|
||||||
export const hashElementsVersion = (
|
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): number => {
|
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (const element of toIterable(elements)) {
|
||||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
hash = (hash << 5) + hash + element.versionNonce;
|
||||||
}
|
}
|
||||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||||
};
|
};
|
||||||
|
|
||||||
// string hash function (using djb2). Not cryptographically secure, use only
|
// string hash function (using djb2). Not cryptographically secure, use only
|
||||||
// for versioning and such.
|
// for versioning and such.
|
||||||
|
// note: hashes individual code units (not code points),
|
||||||
|
// but for hashing purposes this is fine as it iterates through every code unit
|
||||||
|
// (as such, no need to encode to byte string first)
|
||||||
export const hashString = (s: string): number => {
|
export const hashString = (s: string): number => {
|
||||||
let hash: number = 5381;
|
let hash: number = 5381;
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
@@ -51,23 +54,46 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
|||||||
element: T,
|
element: T,
|
||||||
): element is NonDeleted<T> => !element.isDeleted;
|
): element is NonDeleted<T> => !element.isDeleted;
|
||||||
|
|
||||||
const _clearElements = (
|
export * from "./align";
|
||||||
elements: readonly ExcalidrawElement[],
|
export * from "./binding";
|
||||||
): ExcalidrawElement[] =>
|
export * from "./bounds";
|
||||||
getNonDeletedElements(elements).map((element) =>
|
export * from "./collision";
|
||||||
isLinearElementType(element.type)
|
export * from "./comparisons";
|
||||||
? { ...element, lastCommittedPoint: null }
|
export * from "./containerCache";
|
||||||
: element,
|
export * from "./cropElement";
|
||||||
);
|
export * from "./delta";
|
||||||
|
export * from "./distance";
|
||||||
export const clearElementsForDatabase = (
|
export * from "./distribute";
|
||||||
elements: readonly ExcalidrawElement[],
|
export * from "./dragElements";
|
||||||
) => _clearElements(elements);
|
export * from "./duplicate";
|
||||||
|
export * from "./elbowArrow";
|
||||||
export const clearElementsForExport = (
|
export * from "./elementLink";
|
||||||
elements: readonly ExcalidrawElement[],
|
export * from "./embeddable";
|
||||||
) => _clearElements(elements);
|
export * from "./flowchart";
|
||||||
|
export * from "./fractionalIndex";
|
||||||
export const clearElementsForLocalStorage = (
|
export * from "./frame";
|
||||||
elements: readonly ExcalidrawElement[],
|
export * from "./groups";
|
||||||
) => _clearElements(elements);
|
export * from "./heading";
|
||||||
|
export * from "./image";
|
||||||
|
export * from "./linearElementEditor";
|
||||||
|
export * from "./mutateElement";
|
||||||
|
export * from "./newElement";
|
||||||
|
export * from "./positionElementsOnGrid";
|
||||||
|
export * from "./renderElement";
|
||||||
|
export * from "./resizeElements";
|
||||||
|
export * from "./resizeTest";
|
||||||
|
export * from "./Scene";
|
||||||
|
export * from "./selection";
|
||||||
|
export * from "./shape";
|
||||||
|
export * from "./showSelectedShapeActions";
|
||||||
|
export * from "./sizeHelpers";
|
||||||
|
export * from "./sortElements";
|
||||||
|
export * from "./store";
|
||||||
|
export * from "./textElement";
|
||||||
|
export * from "./textMeasurements";
|
||||||
|
export * from "./textWrapping";
|
||||||
|
export * from "./transform";
|
||||||
|
export * from "./transformHandles";
|
||||||
|
export * from "./typeChecks";
|
||||||
|
export * from "./utils";
|
||||||
|
export * from "./zindex";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
|
|
||||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
"id" | "version" | "versionNonce" | "updated"
|
"id" | "updated"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,16 +46,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
|
|
||||||
// casting to any because can't use `in` operator
|
// casting to any because can't use `in` operator
|
||||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
const { points, fixedSegments, fileId } = updates as any;
|
||||||
updates as any;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isElbowArrow(element) &&
|
isElbowArrow(element) &&
|
||||||
(Object.keys(updates).length === 0 || // normalization case
|
(Object.keys(updates).length === 0 || // normalization case
|
||||||
typeof points !== "undefined" || // repositioning
|
typeof points !== "undefined" || // repositioning
|
||||||
typeof fixedSegments !== "undefined" || // segment fixing
|
typeof fixedSegments !== "undefined") // segment fixing
|
||||||
typeof startBinding !== "undefined" ||
|
|
||||||
typeof endBinding !== "undefined") // manual binding to element
|
|
||||||
) {
|
) {
|
||||||
updates = {
|
updates = {
|
||||||
...updates,
|
...updates,
|
||||||
@@ -137,8 +134,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.version++;
|
element.version = updates.version ?? element.version + 1;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
@@ -172,9 +169,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
...updates,
|
...updates,
|
||||||
|
version: updates.version ?? element.version + 1,
|
||||||
|
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||||
updated: getUpdatedTimestamp(),
|
updated: getUpdatedTimestamp(),
|
||||||
version: element.version + 1,
|
|
||||||
versionNonce: randomInteger(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextMaxWidth } from "./textElement";
|
import { getBoundTextMaxWidth, getInitialTextMetrics } from "./textElement";
|
||||||
import { normalizeText, measureText } from "./textMeasurements";
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
|
|
||||||
|
import { isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
@@ -44,8 +46,8 @@ import type {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
FixedSegment,
|
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
@@ -234,27 +236,30 @@ const getTextElementPositionOffsets = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NewTextElementOptions = {
|
||||||
|
text: string;
|
||||||
|
originalText?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: FontFamilyValues;
|
||||||
|
textAlign?: TextAlign;
|
||||||
|
verticalAlign?: VerticalAlign;
|
||||||
|
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||||
|
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||||
|
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||||
|
} & ElementConstructorOpts;
|
||||||
|
|
||||||
export const newTextElement = (
|
export const newTextElement = (
|
||||||
opts: {
|
opts: NewTextElementOptions,
|
||||||
text: string;
|
|
||||||
originalText?: string;
|
|
||||||
fontSize?: number;
|
|
||||||
fontFamily?: FontFamilyValues;
|
|
||||||
textAlign?: TextAlign;
|
|
||||||
verticalAlign?: VerticalAlign;
|
|
||||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
|
||||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
|
||||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
|
||||||
} & ElementConstructorOpts,
|
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): NonDeleted<ExcalidrawTextElement> => {
|
||||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||||
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
||||||
const text = normalizeText(opts.text);
|
const normalizedText = normalizeText(opts.text);
|
||||||
const metrics = measureText(
|
const originalText = opts.originalText ?? normalizedText;
|
||||||
text,
|
const metrics = getInitialTextMetrics(
|
||||||
getFontString({ fontFamily, fontSize }),
|
{ ...opts, text: normalizedText },
|
||||||
lineHeight,
|
fontFamily,
|
||||||
|
fontSize,
|
||||||
);
|
);
|
||||||
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
||||||
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
||||||
@@ -265,7 +270,7 @@ export const newTextElement = (
|
|||||||
|
|
||||||
const textElementProps: ExcalidrawTextElement = {
|
const textElementProps: ExcalidrawTextElement = {
|
||||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||||
text,
|
text: normalizedText,
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
textAlign,
|
textAlign,
|
||||||
@@ -275,7 +280,7 @@ export const newTextElement = (
|
|||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
containerId: opts.containerId || null,
|
containerId: opts.containerId || null,
|
||||||
originalText: opts.originalText ?? text,
|
originalText,
|
||||||
autoResize: opts.autoResize ?? true,
|
autoResize: opts.autoResize ?? true,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
};
|
};
|
||||||
@@ -450,7 +455,6 @@ export const newFreeDrawElement = (
|
|||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
pressures: opts.pressures || [],
|
pressures: opts.pressures || [],
|
||||||
simulatePressure: opts.simulatePressure,
|
simulatePressure: opts.simulatePressure,
|
||||||
lastCommittedPoint: null,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -458,17 +462,29 @@ export const newLinearElement = (
|
|||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawLinearElement["type"];
|
type: ExcalidrawLinearElement["type"];
|
||||||
points?: ExcalidrawLinearElement["points"];
|
points?: ExcalidrawLinearElement["points"];
|
||||||
|
polygon?: ExcalidrawLineElement["polygon"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawLinearElement> => {
|
): NonDeleted<ExcalidrawLinearElement> => {
|
||||||
return {
|
const element = {
|
||||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: null,
|
endArrowhead: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLineElement(element)) {
|
||||||
|
const lineElement: NonDeleted<ExcalidrawLineElement> = {
|
||||||
|
...element,
|
||||||
|
polygon: opts.polygon ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return lineElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newArrowElement = <T extends boolean>(
|
export const newArrowElement = <T extends boolean>(
|
||||||
@@ -478,7 +494,7 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
endArrowhead?: Arrowhead | null;
|
endArrowhead?: Arrowhead | null;
|
||||||
points?: ExcalidrawArrowElement["points"];
|
points?: ExcalidrawArrowElement["points"];
|
||||||
elbowed?: T;
|
elbowed?: T;
|
||||||
fixedSegments?: FixedSegment[] | null;
|
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): T extends true
|
): T extends true
|
||||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||||
@@ -487,7 +503,6 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
return {
|
return {
|
||||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: opts.startArrowhead || null,
|
startArrowhead: opts.startArrowhead || null,
|
||||||
@@ -502,7 +517,6 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
return {
|
return {
|
||||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: opts.startArrowhead || null,
|
startArrowhead: opts.startArrowhead || null,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
import { getStroke } from "perfect-freehand";
|
||||||
|
|
||||||
import { isRightAngleRads } from "@excalidraw/math";
|
import {
|
||||||
|
type GlobalPoint,
|
||||||
|
isRightAngleRads,
|
||||||
|
lineSegment,
|
||||||
|
pointFrom,
|
||||||
|
pointRotateRads,
|
||||||
|
type Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
@@ -14,6 +21,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -32,7 +40,7 @@ import type {
|
|||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||||
import { getUncroppedImageElement } from "./cropElement";
|
import { getUncroppedImageElement } from "./cropElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import {
|
||||||
@@ -54,9 +62,9 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { getCornerRadius } from "./shapes";
|
import { getCornerRadius } from "./utils";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -90,7 +98,7 @@ const isPendingImageElement = (
|
|||||||
const shouldResetImageFilter = (
|
const shouldResetImageFilter = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
appState.theme === THEME.DARK &&
|
appState.theme === THEME.DARK &&
|
||||||
@@ -106,6 +114,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
|||||||
return element.strokeWidth * 12;
|
return element.strokeWidth * 12;
|
||||||
case "text":
|
case "text":
|
||||||
return element.fontSize / 2;
|
return element.fontSize / 2;
|
||||||
|
case "arrow":
|
||||||
|
if (element.endArrowhead || element.endArrowhead) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
default:
|
default:
|
||||||
return 20;
|
return 20;
|
||||||
}
|
}
|
||||||
@@ -212,7 +225,7 @@ const generateElementCanvas = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
): ExcalidrawElementWithCanvas | null => {
|
): ExcalidrawElementWithCanvas | null => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
@@ -264,7 +277,7 @@ const generateElementCanvas = (
|
|||||||
context.filter = IMAGE_INVERT_FILTER;
|
context.filter = IMAGE_INVERT_FILTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
@@ -351,12 +364,20 @@ const generateElementCanvas = (
|
|||||||
|
|
||||||
export const DEFAULT_LINK_SIZE = 14;
|
export const DEFAULT_LINK_SIZE = 14;
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
|
const IMAGE_PLACEHOLDER_IMG =
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? document.createElement("img")
|
||||||
|
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||||
|
|
||||||
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||||
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
|
const IMAGE_ERROR_PLACEHOLDER_IMG =
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? document.createElement("img")
|
||||||
|
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||||
|
|
||||||
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||||
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
||||||
)}`;
|
)}`;
|
||||||
@@ -391,7 +412,6 @@ const drawElementOnCanvas = (
|
|||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
|
||||||
) => {
|
) => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
@@ -537,7 +557,7 @@ const generateElementWithCanvas = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
const zoom: Zoom = renderConfig
|
const zoom: Zoom = renderConfig
|
||||||
? appState.zoom
|
? appState.zoom
|
||||||
@@ -594,7 +614,7 @@ const drawElementFromCanvas = (
|
|||||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
allElementsMap: NonDeletedSceneElementsMap,
|
allElementsMap: NonDeletedSceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const element = elementWithCanvas.element;
|
const element = elementWithCanvas.element;
|
||||||
@@ -712,7 +732,7 @@ export const renderElement = (
|
|||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
const reduceAlphaForSelection =
|
const reduceAlphaForSelection =
|
||||||
appState.openDialog?.name === "elementLinkSelector" &&
|
appState.openDialog?.name === "elementLinkSelector" &&
|
||||||
@@ -782,7 +802,7 @@ export const renderElement = (
|
|||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
context.rotate(element.angle);
|
context.rotate(element.angle);
|
||||||
context.translate(-shiftX, -shiftY);
|
context.translate(-shiftX, -shiftY);
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
context.restore();
|
context.restore();
|
||||||
} else {
|
} else {
|
||||||
const elementWithCanvas = generateElementWithCanvas(
|
const elementWithCanvas = generateElementWithCanvas(
|
||||||
@@ -875,13 +895,7 @@ export const renderElement = (
|
|||||||
|
|
||||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||||
|
|
||||||
drawElementOnCanvas(
|
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||||
element,
|
|
||||||
tempRc,
|
|
||||||
tempCanvasContext,
|
|
||||||
renderConfig,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
tempCanvasContext.translate(shiftX, shiftY);
|
tempCanvasContext.translate(shiftX, shiftY);
|
||||||
|
|
||||||
@@ -920,7 +934,7 @@ export const renderElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.translate(-shiftX, -shiftY);
|
context.translate(-shiftX, -shiftY);
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
@@ -1026,6 +1040,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||||
|
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// If input points are empty (should they ever be?) return a dot
|
||||||
const inputPoints = element.simulatePressure
|
const inputPoints = element.simulatePressure
|
||||||
? element.points
|
? element.points
|
||||||
@@ -1041,10 +1115,10 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|||||||
smoothing: 0.5,
|
smoothing: 0.5,
|
||||||
streamline: 0.5,
|
streamline: 0.5,
|
||||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
last: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||||
}
|
}
|
||||||
|
|
||||||
function med(A: number[], B: number[]) {
|
function med(A: number[], B: number[]) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
pointCenter,
|
pointCenter,
|
||||||
normalizeRadians,
|
normalizeRadians,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
@@ -21,7 +20,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
import {
|
||||||
|
getArrowLocalFixedPoints,
|
||||||
|
unbindBindingElement,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "./binding";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
@@ -36,6 +39,7 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
getMinTextElementWidth,
|
getMinTextElementWidth,
|
||||||
@@ -46,6 +50,7 @@ import {
|
|||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
|
isBindingElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
@@ -57,7 +62,7 @@ import {
|
|||||||
|
|
||||||
import { isInGroup } from "./groups";
|
import { isInGroup } from "./groups";
|
||||||
|
|
||||||
import type Scene from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { BoundingBox } from "./bounds";
|
import type { BoundingBox } from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
@@ -74,7 +79,9 @@ import type {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
|
|
||||||
// Returns true when transform (resizing/rotation) happened
|
// Returns true when transform (resizing/rotation) happened
|
||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
@@ -104,18 +111,6 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
updateBoundElements(element, scene);
|
updateBoundElements(element, scene);
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element) && transformHandleType) {
|
|
||||||
resizeSingleTextElement(
|
|
||||||
originalElements,
|
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
transformHandleType,
|
|
||||||
shouldResizeFromCenter,
|
|
||||||
pointerX,
|
|
||||||
pointerY,
|
|
||||||
);
|
|
||||||
updateBoundElements(element, scene);
|
|
||||||
return true;
|
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
const elementId = selectedElements[0].id;
|
const elementId = selectedElements[0].id;
|
||||||
const latestElement = elementsMap.get(elementId);
|
const latestElement = elementsMap.get(elementId);
|
||||||
@@ -150,6 +145,9 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
updateBoundElements(element, scene);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (selectedElements.length > 1) {
|
} else if (selectedElements.length > 1) {
|
||||||
if (transformHandleType === "rotation") {
|
if (transformHandleType === "rotation") {
|
||||||
@@ -229,13 +227,40 @@ const rotateSingleElement = (
|
|||||||
}
|
}
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
scene.mutateElement(element, { angle });
|
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
|
||||||
|
angle,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
update = {
|
||||||
|
...update,
|
||||||
|
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||||
|
|
||||||
|
if (element.startBinding) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
if (element.endBinding) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.mutateElement(element, update);
|
||||||
|
|
||||||
if (boundTextElementId) {
|
if (boundTextElementId) {
|
||||||
const textElement =
|
const textElement =
|
||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
|
|
||||||
if (textElement && !isArrowElement(element)) {
|
if (textElement && !isArrowElement(element)) {
|
||||||
scene.mutateElement(textElement, { angle });
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
textElement,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
scene.mutateElement(textElement, {
|
||||||
|
angle,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -282,151 +307,50 @@ export const measureFontSizeFromWidth = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
export const resizeSingleTextElement = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
transformHandleType: TransformHandleDirection,
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
nextWidth: number,
|
||||||
pointerY: number,
|
nextHeight: number,
|
||||||
) => {
|
) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
// rotation pointer with reverse angle
|
|
||||||
const [rotatedX, rotatedY] = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
-element.angle as Radians,
|
|
||||||
);
|
|
||||||
let scaleX = 0;
|
|
||||||
let scaleY = 0;
|
|
||||||
|
|
||||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
const metricsWidth = element.width * (nextHeight / element.height);
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||||
}
|
if (metrics === null) {
|
||||||
if (transformHandleType.includes("w")) {
|
return;
|
||||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("n")) {
|
|
||||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("s")) {
|
|
||||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = Math.max(scaleX, scaleY);
|
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||||
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
|
|
||||||
if (scale > 0) {
|
const newOrigin = getResizedOrigin(
|
||||||
const nextWidth = element.width * scale;
|
previousOrigin,
|
||||||
const nextHeight = element.height * scale;
|
origElement.width,
|
||||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
origElement.height,
|
||||||
if (metrics === null) {
|
metricsWidth,
|
||||||
return;
|
nextHeight,
|
||||||
}
|
origElement.angle,
|
||||||
|
transformHandleType,
|
||||||
const startTopLeft = [x1, y1];
|
false,
|
||||||
const startBottomRight = [x2, y2];
|
shouldResizeFromCenter,
|
||||||
const startCenter = [cx, cy];
|
|
||||||
|
|
||||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
startBottomRight[0] - Math.abs(nextWidth),
|
|
||||||
startBottomRight[1] - Math.abs(nextHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (transformHandleType === "ne") {
|
|
||||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
bottomLeft[0],
|
|
||||||
bottomLeft[1] - Math.abs(nextHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (transformHandleType === "sw") {
|
|
||||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
topRight[0] - Math.abs(nextWidth),
|
|
||||||
topRight[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["s", "n"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[0] = startCenter[0] - nextWidth / 2;
|
|
||||||
}
|
|
||||||
if (["e", "w"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[1] = startCenter[1] - nextHeight / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldResizeFromCenter) {
|
|
||||||
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
|
|
||||||
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angle = element.angle;
|
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
newTopLeft,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
);
|
||||||
const newCenter = pointFrom<GlobalPoint>(
|
|
||||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(
|
|
||||||
newCenter,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
|
||||||
const [nextX, nextY] = newTopLeft;
|
|
||||||
|
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fontSize: metrics.size,
|
fontSize: metrics.size,
|
||||||
width: nextWidth,
|
width: metricsWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: nextX,
|
x: newOrigin.x,
|
||||||
y: nextY,
|
y: newOrigin.y,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transformHandleType === "e" || transformHandleType === "w") {
|
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
|
||||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
|
||||||
stateAtResizeStart.width,
|
|
||||||
stateAtResizeStart.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
|
||||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
|
||||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
|
||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
startCenter,
|
|
||||||
-stateAtResizeStart.angle as Radians,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
element.width,
|
|
||||||
element.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
|
||||||
|
|
||||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
|
||||||
const minWidth = getMinTextElementWidth(
|
const minWidth = getMinTextElementWidth(
|
||||||
getFontString({
|
getFontString({
|
||||||
fontSize: element.fontSize,
|
fontSize: element.fontSize,
|
||||||
@@ -435,17 +359,7 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
const newWidth = Math.max(minWidth, nextWidth);
|
||||||
|
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("w")) {
|
|
||||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWidth =
|
|
||||||
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
|
||||||
|
|
||||||
const text = wrapText(
|
const text = wrapText(
|
||||||
element.originalText,
|
element.originalText,
|
||||||
@@ -458,49 +372,27 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
const eleNewHeight = metrics.height;
|
const newHeight = metrics.height;
|
||||||
|
|
||||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
|
||||||
newWidth,
|
|
||||||
eleNewHeight,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
|
||||||
|
|
||||||
let newTopLeft = [...startTopLeft] as [number, number];
|
const newOrigin = getResizedOrigin(
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
previousOrigin,
|
||||||
newTopLeft = [
|
origElement.width,
|
||||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
origElement.height,
|
||||||
startTopLeft[1],
|
newWidth,
|
||||||
];
|
newHeight,
|
||||||
}
|
element.angle,
|
||||||
|
transformHandleType,
|
||||||
// adjust topLeft to new rotation point
|
false,
|
||||||
const angle = stateAtResizeStart.angle;
|
shouldResizeFromCenter,
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
pointFromPair(newTopLeft),
|
|
||||||
startCenter,
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
const newCenter = pointFrom(
|
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||||
width: Math.abs(newWidth),
|
width: Math.abs(newWidth),
|
||||||
height: Math.abs(metrics.height),
|
height: Math.abs(metrics.height),
|
||||||
x: newTopLeft[0],
|
x: newOrigin.x,
|
||||||
y: newTopLeft[1],
|
y: newOrigin.y,
|
||||||
text,
|
text,
|
||||||
autoResize: false,
|
autoResize: false,
|
||||||
};
|
};
|
||||||
@@ -527,6 +419,11 @@ const rotateMultipleElements = (
|
|||||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotatedElementsMap = new Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
NonDeletedExcalidrawElement
|
||||||
|
>(elements.map((element) => [element.id, element]));
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!isFrameLikeElement(element)) {
|
if (!isFrameLikeElement(element)) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
@@ -557,11 +454,30 @@ const rotateMultipleElements = (
|
|||||||
simultaneouslyUpdated: elements,
|
simultaneouslyUpdated: elements,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
if (element.startBinding) {
|
||||||
|
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (element.endBinding) {
|
||||||
|
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundText,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
scene.mutateElement(boundText, {
|
scene.mutateElement(boundText, {
|
||||||
x: boundText.x + (rotatedCX - cx),
|
x,
|
||||||
y: boundText.y + (rotatedCY - cy),
|
y,
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -821,6 +737,18 @@ export const resizeSingleElement = (
|
|||||||
shouldInformMutation?: boolean;
|
shouldInformMutation?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||||
|
return resizeSingleTextElement(
|
||||||
|
origElement,
|
||||||
|
latestElement,
|
||||||
|
scene,
|
||||||
|
handleDirection,
|
||||||
|
shouldResizeFromCenter,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
@@ -950,23 +878,37 @@ export const resizeSingleElement = (
|
|||||||
Number.isFinite(newOrigin.x) &&
|
Number.isFinite(newOrigin.x) &&
|
||||||
Number.isFinite(newOrigin.y)
|
Number.isFinite(newOrigin.y)
|
||||||
) {
|
) {
|
||||||
const updates = {
|
let updates: ElementUpdate<ExcalidrawElement> = {
|
||||||
...newOrigin,
|
...newOrigin,
|
||||||
width: Math.abs(nextWidth),
|
width: Math.abs(nextWidth),
|
||||||
height: Math.abs(nextHeight),
|
height: Math.abs(nextHeight),
|
||||||
...rescaledPoints,
|
...rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isBindingElement(latestElement)) {
|
||||||
|
if (latestElement.startBinding) {
|
||||||
|
updates = {
|
||||||
|
...updates,
|
||||||
|
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||||
|
|
||||||
|
if (latestElement.startBinding) {
|
||||||
|
unbindBindingElement(latestElement, "start", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestElement.endBinding) {
|
||||||
|
updates = {
|
||||||
|
...updates,
|
||||||
|
endBinding: null,
|
||||||
|
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scene.mutateElement(latestElement, updates, {
|
scene.mutateElement(latestElement, updates, {
|
||||||
informMutation: shouldInformMutation,
|
informMutation: shouldInformMutation,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateBoundElements(latestElement, scene, {
|
|
||||||
// TODO: confirm with MARK if this actually makes sense
|
|
||||||
newSize: { width: nextWidth, height: nextHeight },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont != null) {
|
if (boundTextElement && boundTextFont != null) {
|
||||||
scene.mutateElement(boundTextElement, {
|
scene.mutateElement(boundTextElement, {
|
||||||
fontSize: boundTextFont.fontSize,
|
fontSize: boundTextFont.fontSize,
|
||||||
@@ -978,6 +920,8 @@ export const resizeSingleElement = (
|
|||||||
handleDirection,
|
handleDirection,
|
||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
updateBoundElements(latestElement, scene);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1511,24 +1455,36 @@ export const resizeMultipleElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||||
|
const resizedElementsMap = new Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
NonDeletedExcalidrawElement
|
||||||
|
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
element,
|
element,
|
||||||
update: { boundTextFontSize, ...update },
|
update: { boundTextFontSize, ...update },
|
||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { width, height, angle } = update;
|
const { angle } = update;
|
||||||
|
|
||||||
scene.mutateElement(element, update, {
|
scene.mutateElement(element, update);
|
||||||
informMutation: true,
|
|
||||||
// needed for the fixed binding point udpate to take effect
|
|
||||||
isDragging: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
newSize: { width, height },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
if (element.startBinding) {
|
||||||
|
if (!resizedElementsMap.has(element.startBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (element.endBinding) {
|
||||||
|
if (!resizedElementsMap.has(element.endBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement && boundTextFontSize) {
|
if (boundTextElement && boundTextFontSize) {
|
||||||
scene.mutateElement(boundTextElement, {
|
scene.mutateElement(boundTextElement, {
|
||||||
|
|||||||
@@ -5,22 +5,25 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
import {
|
||||||
|
SIDE_RESIZING_THRESHOLD,
|
||||||
|
type EditorInterface,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
|
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { Bounds } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import {
|
import {
|
||||||
getTransformHandlesFromCoords,
|
getTransformHandlesFromCoords,
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
getOmitSidesForDevice,
|
getOmitSidesForEditorInterface,
|
||||||
canResizeFromSides,
|
canResizeFromSides,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
|
||||||
import type {
|
import type {
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
TransformHandle,
|
TransformHandle,
|
||||||
@@ -51,7 +54,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
y: number,
|
y: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
device: Device,
|
editorInterface: EditorInterface,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!appState.selectedElementIds[element.id]) {
|
||||||
return false;
|
return false;
|
||||||
@@ -63,7 +66,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
zoom,
|
zoom,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
pointerType,
|
pointerType,
|
||||||
getOmitSidesForDevice(device),
|
getOmitSidesForEditorInterface(editorInterface),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -86,7 +89,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
return filter[0] as TransformHandleType;
|
return filter[0] as TransformHandleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canResizeFromSides(device)) {
|
if (canResizeFromSides(editorInterface)) {
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -132,7 +135,7 @@ export const getElementWithTransformHandleType = (
|
|||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
device: Device,
|
editorInterface: EditorInterface,
|
||||||
) => {
|
) => {
|
||||||
return elements.reduce((result, element) => {
|
return elements.reduce((result, element) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -146,7 +149,7 @@ export const getElementWithTransformHandleType = (
|
|||||||
scenePointerY,
|
scenePointerY,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
device,
|
editorInterface,
|
||||||
);
|
);
|
||||||
return transformHandleType ? { element, transformHandleType } : null;
|
return transformHandleType ? { element, transformHandleType } : null;
|
||||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||||
@@ -160,14 +163,14 @@ export const getTransformHandleTypeFromCoords = <
|
|||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
device: Device,
|
editorInterface: EditorInterface,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
const transformHandles = getTransformHandlesFromCoords(
|
const transformHandles = getTransformHandlesFromCoords(
|
||||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||||
0 as Radians,
|
0 as Radians,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
getOmitSidesForDevice(device),
|
getOmitSidesForEditorInterface(editorInterface),
|
||||||
);
|
);
|
||||||
|
|
||||||
const found = Object.keys(transformHandles).find((key) => {
|
const found = Object.keys(transformHandles).find((key) => {
|
||||||
@@ -183,7 +186,7 @@ export const getTransformHandleTypeFromCoords = <
|
|||||||
return found as MaybeTransformHandleType;
|
return found as MaybeTransformHandleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canResizeFromSides(device)) {
|
if (canResizeFromSides(editorInterface)) {
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
|
|
||||||
|
|||||||
@@ -169,25 +169,6 @@ export const isSomeElementSelected = (function () {
|
|||||||
return ret;
|
return ret;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns common attribute (picked by `getAttribute` callback) of selected
|
|
||||||
* elements. If elements don't share the same value, returns `null`.
|
|
||||||
*/
|
|
||||||
export const getCommonAttributeOfSelectedElements = <T>(
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
appState: Pick<AppState, "selectedElementIds">,
|
|
||||||
getAttribute: (element: ExcalidrawElement) => T,
|
|
||||||
): T | null => {
|
|
||||||
const attributes = Array.from(
|
|
||||||
new Set(
|
|
||||||
getSelectedElements(elements, appState).map((element) =>
|
|
||||||
getAttribute(element),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return attributes.length === 1 ? attributes[0] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSelectedElements = (
|
export const getSelectedElements = (
|
||||||
elements: ElementsMapOrArray,
|
elements: ElementsMapOrArray,
|
||||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||||
|
|||||||
@@ -1,26 +1,65 @@
|
|||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
|
|
||||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
import {
|
||||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
type GeometricShape,
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
} from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
|
import {
|
||||||
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
|
type LocalPoint,
|
||||||
|
pointRotateRads,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
import {
|
||||||
|
ROUGHNESS,
|
||||||
|
isTransparent,
|
||||||
|
assertNever,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
|
|
||||||
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
import type {
|
||||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
AppState,
|
||||||
|
EmbedsValidationStatus,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import type {
|
||||||
|
ElementShape,
|
||||||
|
ElementShapes,
|
||||||
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
|
import { elementWithCanvasCache } from "./renderElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canBecomePolygon,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isEmbeddableElement,
|
isEmbeddableElement,
|
||||||
isIframeElement,
|
isIframeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
import { getCornerRadius, isPathALoop } from "./utils";
|
||||||
import { headingForPointIsHorizontal } from "./heading";
|
import { headingForPointIsHorizontal } from "./heading";
|
||||||
|
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
import { generateFreeDrawShape } from "./renderElement";
|
import { generateFreeDrawShape } from "./renderElement";
|
||||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
import {
|
||||||
|
getArrowheadPoints,
|
||||||
|
getCenterForBounds,
|
||||||
|
getDiamondPoints,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
} from "./bounds";
|
||||||
|
import { shouldTestInside } from "./collision";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -28,12 +67,89 @@ import type {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
|
||||||
|
export class ShapeCache {
|
||||||
|
private static rg = new RoughGenerator();
|
||||||
|
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves shape from cache if available. Use this only if shape
|
||||||
|
* is optional and you have a fallback in case it's not cached.
|
||||||
|
*/
|
||||||
|
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||||
|
return ShapeCache.cache.get(
|
||||||
|
element,
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]] | undefined
|
||||||
|
: ElementShape | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
public static set = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
shape: T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable,
|
||||||
|
) => ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
public static delete = (element: ExcalidrawElement) =>
|
||||||
|
ShapeCache.cache.delete(element);
|
||||||
|
|
||||||
|
public static destroy = () => {
|
||||||
|
ShapeCache.cache = new WeakMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates & caches shape for element if not already cached, otherwise
|
||||||
|
* returns cached shape.
|
||||||
|
*/
|
||||||
|
public static generateElementShape = <
|
||||||
|
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
>(
|
||||||
|
element: T,
|
||||||
|
renderConfig: {
|
||||||
|
isExporting: boolean;
|
||||||
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
} | null,
|
||||||
|
) => {
|
||||||
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
|
const cachedShape = renderConfig?.isExporting
|
||||||
|
? undefined
|
||||||
|
: ShapeCache.get(element);
|
||||||
|
|
||||||
|
// `null` indicates no rc shape applicable for this element type,
|
||||||
|
// but it's considered a valid cache value (= do not regenerate)
|
||||||
|
if (cachedShape !== undefined) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
|
const shape = generateElementShape(
|
||||||
|
element,
|
||||||
|
ShapeCache.rg,
|
||||||
|
renderConfig || {
|
||||||
|
isExporting: false,
|
||||||
|
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
embedsValidationStatus: null,
|
||||||
|
},
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable | null;
|
||||||
|
|
||||||
|
ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||||
@@ -303,6 +419,182 @@ const getArrowheadShapes = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateLinearCollisionShape = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
) => {
|
||||||
|
const generator = new RoughGenerator();
|
||||||
|
const options: Options = {
|
||||||
|
seed: element.seed,
|
||||||
|
disableMultiStroke: true,
|
||||||
|
disableMultiStrokeFill: true,
|
||||||
|
roughness: 0,
|
||||||
|
preserveVertices: true,
|
||||||
|
};
|
||||||
|
const center = getCenterForBounds(
|
||||||
|
// Need a non-rotated center point
|
||||||
|
element.points.reduce(
|
||||||
|
(acc, point) => {
|
||||||
|
return [
|
||||||
|
Math.min(element.x + point[0], acc[0]),
|
||||||
|
Math.min(element.y + point[1], acc[1]),
|
||||||
|
Math.max(element.x + point[0], acc[2]),
|
||||||
|
Math.max(element.y + point[1], acc[3]),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[Infinity, Infinity, -Infinity, -Infinity],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case "line":
|
||||||
|
case "arrow": {
|
||||||
|
// points array can be empty in the beginning, so it is important to add
|
||||||
|
// initial position to it
|
||||||
|
const points = element.points.length
|
||||||
|
? element.points
|
||||||
|
: [pointFrom<LocalPoint>(0, 0)];
|
||||||
|
|
||||||
|
if (isElbowArrow(element)) {
|
||||||
|
return generator.path(generateElbowArrowShape(points, 16), options)
|
||||||
|
.sets[0].ops;
|
||||||
|
} else if (!element.roundness) {
|
||||||
|
return points.map((point, idx) => {
|
||||||
|
const p = pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: idx === 0 ? "move" : "lineTo",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(points as unknown as RoughPoint[], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "freedraw": {
|
||||||
|
if (element.points.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplifiedPoints = simplify(
|
||||||
|
element.points as Mutable<LocalPoint[]>,
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(simplifiedPoints as [number, number][], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the roughjs shape for given element.
|
* Generates the roughjs shape for given element.
|
||||||
*
|
*
|
||||||
@@ -310,7 +602,7 @@ const getArrowheadShapes = (
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
export const _generateElementShape = (
|
const generateElementShape = (
|
||||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
{
|
{
|
||||||
@@ -611,3 +903,103 @@ const generateElbowArrowShape = (
|
|||||||
|
|
||||||
return d.join(" ");
|
return d.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the pure geometric shape of an excalidraw elementw
|
||||||
|
* which is then used for hit detection
|
||||||
|
*/
|
||||||
|
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): GeometricShape<Point> => {
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "image":
|
||||||
|
case "iframe":
|
||||||
|
case "text":
|
||||||
|
case "selection":
|
||||||
|
return getPolygonShape(element);
|
||||||
|
case "arrow":
|
||||||
|
case "line": {
|
||||||
|
const roughShape =
|
||||||
|
ShapeCache.get(element)?.[0] ??
|
||||||
|
ShapeCache.generateElementShape(element, null)[0];
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
|
return shouldTestInside(element)
|
||||||
|
? getClosedCurveShape<Point>(
|
||||||
|
element,
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
)
|
||||||
|
: getCurveShape<Point>(
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ellipse":
|
||||||
|
return getEllipseShape(element);
|
||||||
|
|
||||||
|
case "freedraw": {
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
return getFreedrawShape(
|
||||||
|
element,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
shouldTestInside(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleLinePolygonState = (
|
||||||
|
element: ExcalidrawLineElement,
|
||||||
|
nextPolygonState: boolean,
|
||||||
|
): {
|
||||||
|
polygon: ExcalidrawLineElement["polygon"];
|
||||||
|
points: ExcalidrawLineElement["points"];
|
||||||
|
} | null => {
|
||||||
|
const updatedPoints = [...element.points];
|
||||||
|
|
||||||
|
if (nextPolygonState) {
|
||||||
|
if (!canBecomePolygon(element.points)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPoint = updatedPoints[0];
|
||||||
|
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||||
|
|
||||||
|
const distance = Math.hypot(
|
||||||
|
firstPoint[0] - lastPoint[0],
|
||||||
|
firstPoint[1] - lastPoint[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||||
|
updatedPoints.length < 4
|
||||||
|
) {
|
||||||
|
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||||
|
} else {
|
||||||
|
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||||
|
firstPoint[0],
|
||||||
|
firstPoint[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||||
|
const ret = {
|
||||||
|
polygon: nextPolygonState,
|
||||||
|
points: updatedPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import {
|
|
||||||
DEFAULT_ADAPTIVE_RADIUS,
|
|
||||||
DEFAULT_PROPORTIONAL_RADIUS,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
|
||||||
ROUNDNESS,
|
|
||||||
invariant,
|
|
||||||
elementCenterPoint,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
import {
|
|
||||||
isPoint,
|
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
|
||||||
pointsEqual,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
import {
|
|
||||||
getClosedCurveShape,
|
|
||||||
getCurvePathOps,
|
|
||||||
getCurveShape,
|
|
||||||
getEllipseShape,
|
|
||||||
getFreedrawShape,
|
|
||||||
getPolygonShape,
|
|
||||||
type GeometricShape,
|
|
||||||
} from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { shouldTestInside } from "./collision";
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
NonDeleted,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get the pure geometric shape of an excalidraw elementw
|
|
||||||
* which is then used for hit detection
|
|
||||||
*/
|
|
||||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> => {
|
|
||||||
switch (element.type) {
|
|
||||||
case "rectangle":
|
|
||||||
case "diamond":
|
|
||||||
case "frame":
|
|
||||||
case "magicframe":
|
|
||||||
case "embeddable":
|
|
||||||
case "image":
|
|
||||||
case "iframe":
|
|
||||||
case "text":
|
|
||||||
case "selection":
|
|
||||||
return getPolygonShape(element);
|
|
||||||
case "arrow":
|
|
||||||
case "line": {
|
|
||||||
const roughShape =
|
|
||||||
ShapeCache.get(element)?.[0] ??
|
|
||||||
ShapeCache.generateElementShape(element, null)[0];
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
|
|
||||||
return shouldTestInside(element)
|
|
||||||
? getClosedCurveShape<Point>(
|
|
||||||
element,
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
)
|
|
||||||
: getCurveShape<Point>(
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ellipse":
|
|
||||||
return getEllipseShape(element);
|
|
||||||
|
|
||||||
case "freedraw": {
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
return getFreedrawShape(
|
|
||||||
element,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
shouldTestInside(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> | null => {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
if (element.type === "arrow") {
|
|
||||||
return getElementShape(
|
|
||||||
{
|
|
||||||
...boundTextElement,
|
|
||||||
// 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,
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return getElementShape(boundTextElement, elementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getControlPointsForBezierCurve = <
|
|
||||||
P extends GlobalPoint | LocalPoint,
|
|
||||||
>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const shape = ShapeCache.generateElementShape(element, null);
|
|
||||||
if (!shape) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ops = getCurvePathOps(shape[0]);
|
|
||||||
let currentP = pointFrom<P>(0, 0);
|
|
||||||
let index = 0;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
let controlPoints: P[] | null = null;
|
|
||||||
|
|
||||||
while (index < ops.length) {
|
|
||||||
const { op, data } = ops[index];
|
|
||||||
if (op === "move") {
|
|
||||||
invariant(
|
|
||||||
isPoint(data),
|
|
||||||
"The returned ops is not compatible with a point",
|
|
||||||
);
|
|
||||||
currentP = pointFromPair(data);
|
|
||||||
}
|
|
||||||
if (op === "bcurveTo") {
|
|
||||||
const p0 = currentP;
|
|
||||||
const p1 = pointFrom<P>(data[0], data[1]);
|
|
||||||
const p2 = pointFrom<P>(data[2], data[3]);
|
|
||||||
const p3 = pointFrom<P>(data[4], data[5]);
|
|
||||||
const distance = pointDistance(p3, endPoint);
|
|
||||||
if (distance < minDistance) {
|
|
||||||
minDistance = distance;
|
|
||||||
controlPoints = [p0, p1, p2, p3];
|
|
||||||
}
|
|
||||||
currentP = p3;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return controlPoints;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p0: P,
|
|
||||||
p1: P,
|
|
||||||
p2: P,
|
|
||||||
p3: P,
|
|
||||||
t: number,
|
|
||||||
): P => {
|
|
||||||
const equation = (t: number, idx: number) =>
|
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
const tx = equation(t, 0);
|
|
||||||
const ty = equation(t, 1);
|
|
||||||
return pointFrom(tx, ty);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
|
||||||
if (!controlPoints) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const pointsOnCurve: P[] = [];
|
|
||||||
let t = 1;
|
|
||||||
// Take 20 points on curve for better accuracy
|
|
||||||
while (t > 0) {
|
|
||||||
const p = getBezierXY(
|
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
|
||||||
t -= 0.05;
|
|
||||||
}
|
|
||||||
if (pointsOnCurve.length) {
|
|
||||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
|
||||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pointsOnCurve;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths: number[] = [];
|
|
||||||
arcLengths[0] = 0;
|
|
||||||
const points = getPointsInBezierCurve(element, endPoint);
|
|
||||||
let index = 0;
|
|
||||||
let distance = 0;
|
|
||||||
while (index < points.length - 1) {
|
|
||||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
|
||||||
distance += segmentDistance;
|
|
||||||
arcLengths.push(distance);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return arcLengths;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
return arcLengths.at(-1) as number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
|
||||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
const pointsCount = arcLengths.length - 1;
|
|
||||||
const curveLength = arcLengths.at(-1) as number;
|
|
||||||
const targetLength = interval * curveLength;
|
|
||||||
let low = 0;
|
|
||||||
let high = pointsCount;
|
|
||||||
let index = 0;
|
|
||||||
// Doing a binary search to find the largest length that is less than the target length
|
|
||||||
while (low < high) {
|
|
||||||
index = Math.floor(low + (high - low) / 2);
|
|
||||||
if (arcLengths[index] < targetLength) {
|
|
||||||
low = index + 1;
|
|
||||||
} else {
|
|
||||||
high = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (arcLengths[index] > targetLength) {
|
|
||||||
index--;
|
|
||||||
}
|
|
||||||
if (arcLengths[index] === targetLength) {
|
|
||||||
return index / pointsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
1 -
|
|
||||||
(index +
|
|
||||||
(targetLength - arcLengths[index]) /
|
|
||||||
(arcLengths[index + 1] - arcLengths[index])) /
|
|
||||||
pointsCount
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the axis-aligned bounding box for a given element
|
|
||||||
*/
|
|
||||||
export const aabbForElement = (
|
|
||||||
element: Readonly<ExcalidrawElement>,
|
|
||||||
offset?: [number, number, number, number],
|
|
||||||
) => {
|
|
||||||
const bbox = {
|
|
||||||
minX: element.x,
|
|
||||||
minY: element.y,
|
|
||||||
maxX: element.x + element.width,
|
|
||||||
maxY: element.y + element.height,
|
|
||||||
midX: element.x + element.width / 2,
|
|
||||||
midY: element.y + element.height / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [topRightX, topRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bounds = [
|
|
||||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
if (offset) {
|
|
||||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
|
||||||
return [
|
|
||||||
bounds[0] - leftOffset,
|
|
||||||
bounds[1] - topOffset,
|
|
||||||
bounds[2] + rightOffset,
|
|
||||||
bounds[3] + downOffset,
|
|
||||||
] as Bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
return bounds;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p: P,
|
|
||||||
bounds: Bounds,
|
|
||||||
): boolean =>
|
|
||||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
|
||||||
|
|
||||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user