mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-05 23:30:01 +02:00
Compare commits
98 Commits
mtolmacs/f
...
ryan-di/fr
Author | SHA1 | Date | |
---|---|---|---|
![]() |
86605829c6 | ||
![]() |
c398af6c92 | ||
![]() |
973f2a464d | ||
![]() |
02cef5ea92 | ||
![]() |
678dff25ed | ||
![]() |
0cfa53b764 | ||
![]() |
d615c2cea1 | ||
![]() |
cde46793f8 | ||
![]() |
446f871536 | ||
![]() |
34bff557e3 | ||
![]() |
a0e54e3768 | ||
![]() |
d6ec1dc7e6 | ||
![]() |
2d127f8c22 | ||
![]() |
4eadb891f8 | ||
![]() |
258605d1d5 | ||
![]() |
62e20aa247 | ||
![]() |
0199c82e98 | ||
![]() |
3c07ff358a | ||
![]() |
d9c85ff18f | ||
![]() |
6d84fa21c5 | ||
![]() |
5666fd8199 | ||
![]() |
abdacf8239 | ||
![]() |
1068153b25 | ||
![]() |
09876aba6d | ||
![]() |
8ceb55dd02 | ||
![]() |
c141500400 | ||
![]() |
b1f3cc50ee | ||
![]() |
8e27de2cdc | ||
![]() |
0a19c93509 | ||
![]() |
958597dfaa | ||
![]() |
c72c47f0cd | ||
![]() |
058918f8e5 | ||
![]() |
3f194918e6 | ||
![]() |
93c92d13e9 | ||
![]() |
84e96e9393 | ||
![]() |
320af405e9 | ||
![]() |
60512f13d5 | ||
![]() |
37b75263f8 | ||
![]() |
f0458cc216 | ||
![]() |
9f3fdf5505 | ||
![]() |
f42e1ab64e | ||
![]() |
18808481fd | ||
![]() |
c08840358b | ||
![]() |
a7b64f02b3 | ||
![]() |
0d4abd1ddc | ||
![]() |
9e77373c81 | ||
![]() |
e99baaa6bb | ||
![]() |
d108053351 | ||
![]() |
a8857f2849 | ||
![]() |
d4e85a9480 | ||
![]() |
08cd4c4f9a | ||
![]() |
469caadb87 | ||
![]() |
ca1a4f25e7 | ||
![]() |
df1f9281b4 | ||
![]() |
c210b7b092 | ||
![]() |
660d21fe46 | ||
![]() |
56c05b3099 | ||
![]() |
c7780cb9cb | ||
![]() |
4e265629c3 | ||
![]() |
1c611d6c4f | ||
![]() |
ab6af41d33 | ||
![]() |
15dfe0cc7c | ||
![]() |
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 |
@@ -1,3 +1,5 @@
|
||||
MODE="development"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
|
@@ -1,3 +1,5 @@
|
||||
MODE="production"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
|
45
.github/copilot-instructions.md
vendored
Normal file
45
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Project coding standards
|
||||
|
||||
## Generic Communication Guidelines
|
||||
|
||||
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||
- Stop apologising if corrected, just provide the correct information or code
|
||||
- Prefer code unless asked for explanation
|
||||
- Stop summarizing what you've changed after modifications unless asked for
|
||||
|
||||
## TypeScript Guidelines
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Where possible, prefer implementations without allocation
|
||||
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||
- Prefer immutable data (const, readonly)
|
||||
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||
|
||||
## React Guidelines
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the React hooks rules (no conditional hooks)
|
||||
- Keep components small and focused
|
||||
- Use CSS modules for component styling
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Use PascalCase for component names, interfaces, and type aliases
|
||||
- Use camelCase for variables, functions, and methods
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use try/catch blocks for async operations
|
||||
- Implement proper error boundaries in React components
|
||||
- Always log errors with contextual information
|
||||
|
||||
## Testing
|
||||
|
||||
- Always attempt to fix #problems
|
||||
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||
|
||||
## Types
|
||||
|
||||
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
2
.github/workflows/autorelease-excalidraw.yml
vendored
2
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease
|
||||
yarn release --tag=next --non-interactive
|
||||
|
55
.github/workflows/autorelease-preview.yml
vendored
55
.github/workflows/autorelease-preview.yml
vendored
@@ -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 }}"
|
7
.github/workflows/publish-docker.yml
vendored
7
.github/workflows/publish-docker.yml
vendored
@@ -17,9 +17,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,4 +25,5 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
meta*.json
|
||||
.claude
|
||||
|
34
CLAUDE.md
Normal file
34
CLAUDE.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Structure
|
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features
|
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||
3. **Testing**: Always run `yarn test:update` before committing
|
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn test:typecheck # TypeScript type checking
|
||||
yarn test:update # Run all tests (with snapshot updates)
|
||||
yarn fix # Auto-fix formatting and linting issues
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Package System
|
||||
|
||||
- Uses Yarn workspaces for monorepo management
|
||||
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||
- Build system uses esbuild for packages, Vite for the app
|
||||
- TypeScript throughout with strict configuration
|
@@ -1,4 +1,4 @@
|
||||
FROM node:18 AS build
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,13 +6,14 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
@@ -34,6 +34,9 @@
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
||||
</a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
|
||||
</a>
|
||||
<a href="https://twitter.com/excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||
</a>
|
||||
@@ -63,7 +66,7 @@ The Excalidraw editor (npm package) supports:
|
||||
- 🏗️ Customizable.
|
||||
- 📷 Image support.
|
||||
- 😀 Shape libraries support.
|
||||
- 👅 Localization (i18n) support.
|
||||
- 🌐 Localization (i18n) support.
|
||||
- 🖼️ Export to PNG, SVG & clipboard.
|
||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||
|
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
```ts
|
||||
(
|
||||
tool: (
|
||||
| (
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| {
|
||||
type: Extract<ToolType, "image">;
|
||||
insertOnCanvasDirectly?: boolean;
|
||||
}
|
||||
)
|
||||
| { type: ToolType }
|
||||
| { type: "custom"; customType: string }
|
||||
) & { 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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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 |
|
||||
|
||||
## setCursor
|
||||
|
@@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
||||
|
||||
## Releasing
|
||||
|
||||
### Create a test release
|
||||
|
||||
You can create a test release by posting the below comment in your pull request:
|
||||
|
||||
```bash
|
||||
@excalibot trigger release
|
||||
```
|
||||
|
||||
Once the version is released `@excalibot` will post a comment with the release version.
|
||||
|
||||
### Creating a production release
|
||||
|
||||
To release the next stable version follow the below steps:
|
||||
|
||||
```bash
|
||||
yarn 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
||||
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||
{
|
||||
|
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
@@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
|
@@ -52,7 +52,7 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.excalidraw .panelColumn {
|
||||
.excalidraw .selected-shape-actions {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
@@ -17,6 +17,6 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"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",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
}
|
||||
|
@@ -47,10 +47,10 @@ import {
|
||||
share,
|
||||
youtubeIcon,
|
||||
} 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 { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
@@ -134,6 +134,7 @@ import DebugCanvas, {
|
||||
} from "./components/DebugCanvas";
|
||||
import { AIComponents } from "./components/AI";
|
||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
import { FreedrawDebugSliders } from "./components/FreedrawDebugSliders";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
@@ -1142,6 +1143,7 @@ const ExcalidrawWrapper = () => {
|
||||
ref={debugCanvasRef}
|
||||
/>
|
||||
)}
|
||||
{/* <FreedrawDebugSliders /> */}
|
||||
</Excalidraw>
|
||||
</div>
|
||||
);
|
||||
|
@@ -19,12 +19,9 @@ import {
|
||||
throttleRAF,
|
||||
} from "@excalidraw/common";
|
||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
|
@@ -18,10 +18,10 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
const renderLine = (
|
||||
|
@@ -12,7 +12,7 @@ import {
|
||||
generateEncryptionKey,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
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 type {
|
||||
|
150
excalidraw-app/components/FreedrawDebugSliders.tsx
Normal file
150
excalidraw-app/components/FreedrawDebugSliders.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { STROKE_OPTIONS, isFreeDrawElement } from "@excalidraw/element";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||
import { useExcalidrawElements } from "@excalidraw/excalidraw/components/App";
|
||||
|
||||
import { round } from "../../packages/math/src";
|
||||
|
||||
export const FreedrawDebugSliders = () => {
|
||||
const [streamline, setStreamline] = useState<number>(
|
||||
STROKE_OPTIONS.default.streamline,
|
||||
);
|
||||
const [simplify, setSimplify] = useState<number>(
|
||||
STROKE_OPTIONS.default.simplify,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.h) {
|
||||
window.h = {} as any;
|
||||
}
|
||||
if (!window.h.debugFreedraw) {
|
||||
window.h.debugFreedraw = {
|
||||
enabled: true,
|
||||
...STROKE_OPTIONS.default,
|
||||
};
|
||||
}
|
||||
|
||||
setStreamline(window.h.debugFreedraw.streamline);
|
||||
setSimplify(window.h.debugFreedraw.simplify);
|
||||
}, []);
|
||||
|
||||
const handleStreamlineChange = (value: number) => {
|
||||
setStreamline(value);
|
||||
if (window.h && window.h.debugFreedraw) {
|
||||
window.h.debugFreedraw.streamline = value;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSimplifyChange = (value: number) => {
|
||||
setSimplify(value);
|
||||
if (window.h && window.h.debugFreedraw) {
|
||||
window.h.debugFreedraw.simplify = value;
|
||||
}
|
||||
};
|
||||
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
window.h?.debugFreedraw?.enabled ?? true,
|
||||
);
|
||||
|
||||
// counter incrasing each 50ms
|
||||
const [, setCounter] = useState<number>(0);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCounter((prev) => prev + 1);
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const elements = useExcalidrawElements();
|
||||
const appState = useUIAppState();
|
||||
|
||||
const newFreedrawElement =
|
||||
appState.newElement && isFreeDrawElement(appState.newElement)
|
||||
? appState.newElement
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "70px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 9999,
|
||||
padding: "10px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ccc",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
fontSize: "12px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{newFreedrawElement && (
|
||||
<div>
|
||||
pressures:{" "}
|
||||
{newFreedrawElement.simulatePressure
|
||||
? "simulated"
|
||||
: JSON.stringify(
|
||||
newFreedrawElement.pressures
|
||||
.slice(-4)
|
||||
.map((x) => round(x, 2))
|
||||
.join(" ") || [],
|
||||
)}{" "}
|
||||
({round(window.__lastPressure__ || 0, 2) || "?"})
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>
|
||||
{" "}
|
||||
enabled
|
||||
<br />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => {
|
||||
if (window.h.debugFreedraw) {
|
||||
window.h.debugFreedraw.enabled = e.target.checked;
|
||||
setEnabled(e.target.checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Streamline: {streamline.toFixed(2)}
|
||||
<br />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={streamline}
|
||||
onChange={(e) => handleStreamlineChange(parseFloat(e.target.value))}
|
||||
style={{ width: "150px" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Simplify: {simplify.toFixed(2)}
|
||||
<br />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={simplify}
|
||||
onChange={(e) => handleSimplifyChange(parseFloat(e.target.value))}
|
||||
style={{ width: "150px" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
|
@@ -9,14 +9,14 @@ import {
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { bytesToHexString } from "@excalidraw/common";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
||||
import type { SceneBounds } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
|
@@ -1,34 +0,0 @@
|
||||
import { defaultLang } from "@excalidraw/excalidraw/i18n";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
describe("Test LanguageList", () => {
|
||||
it("rerenders UI on language change", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
// select rectangle tool to show properties menu
|
||||
UI.clickTool("rectangle");
|
||||
// english lang should display `thin` label
|
||||
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
|
||||
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
|
||||
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: "de-DE" },
|
||||
});
|
||||
// switching to german, `thin` label should no longer exist
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
|
||||
// reset language
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: defaultLang.code },
|
||||
});
|
||||
// switching back to English
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
|
||||
});
|
||||
});
|
@@ -3,11 +3,15 @@ import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} 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 { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { StoreIncrement } from "@excalidraw/element";
|
||||
|
||||
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
const { h } = window;
|
||||
@@ -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.
|
||||
*/
|
||||
describe("collaboration", () => {
|
||||
it("should emit two ephemeral increments even though updates get batched", async () => {
|
||||
const durableIncrements: DurableIncrement[] = [];
|
||||
const ephemeralIncrements: EphemeralIncrement[] = [];
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.store.onStoreIncrementEmitter.on((increment) => {
|
||||
if (StoreIncrement.isDurable(increment)) {
|
||||
durableIncrements.push(increment);
|
||||
} else {
|
||||
ephemeralIncrements.push(increment);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
expect(durableIncrements.length).toBe(0);
|
||||
expect(ephemeralIncrements.length).toBe(0);
|
||||
|
||||
const rectProps = {
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
height: 200,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as const;
|
||||
|
||||
const rect = API.createElement({ ...rectProps });
|
||||
|
||||
API.updateScene({
|
||||
elements: [rect],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(durableIncrements.length).toBe(1);
|
||||
});
|
||||
|
||||
// simulate two batched remote updates
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 100 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 200 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// we scheduled two micro actions,
|
||||
// which confirms they are going to be executed as part of one batched component update
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// altough the updates get batched,
|
||||
// we expect two ephemeral increments for each update,
|
||||
// and each such update should have the expected change
|
||||
expect(ephemeralIncrements.length).toBe(2);
|
||||
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 100 }),
|
||||
);
|
||||
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 200 }),
|
||||
);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
const rect1Props = {
|
||||
@@ -122,12 +199,13 @@ describe("collaboration", () => {
|
||||
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));
|
||||
|
||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||
@@ -154,7 +232,7 @@ describe("collaboration", () => {
|
||||
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));
|
||||
|
||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||
@@ -170,79 +248,5 @@ describe("collaboration", () => {
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// simulate local update
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// we expect to iterate the stack to the first visible change
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// snapshot was correctly updated and marked the element as deleted
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
20
package.json
20
package.json
@@ -33,6 +33,7 @@
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
@@ -51,13 +52,17 @@
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build: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": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"start:example": "yarn build: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:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@@ -75,11 +80,12 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||
"release": "node scripts/release.js",
|
||||
"release:test": "node scripts/release.js --tag=test",
|
||||
"release:next": "node scripts/release.js --tag=next",
|
||||
"release:latest": "node scripts/release.js --tag=latest",
|
||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.1.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,7 +13,10 @@
|
||||
"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": [
|
||||
@@ -50,7 +53,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -143,21 +144,52 @@ export const FONT_FAMILY = {
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"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 = {
|
||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||
[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 = (
|
||||
fontFamily: number,
|
||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||
|
||||
switch (fontFamily) {
|
||||
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:
|
||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +286,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
export const getExportSource = () =>
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
@@ -383,8 +415,9 @@ export const ROUGHNESS = {
|
||||
|
||||
export const STROKE_WIDTH = {
|
||||
thin: 1,
|
||||
bold: 2,
|
||||
extraBold: 4,
|
||||
medium: 2,
|
||||
bold: 4,
|
||||
extraBold: 8,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_ELEMENT_PROPS: {
|
||||
@@ -400,7 +433,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeWidth: STROKE_WIDTH.medium,
|
||||
strokeStyle: "solid",
|
||||
roughness: ROUGHNESS.artist,
|
||||
opacity: 100,
|
||||
@@ -475,3 +508,10 @@ export enum UserIdleState {
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
/**
|
||||
* distance at which we merge points instead of adding a new merge-point
|
||||
* when converting a line to a polygon (merge currently means overlaping
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import type { UnsubscribeCallback } from "./types";
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||
|
@@ -22,8 +22,10 @@ export interface FontMetadata {
|
||||
};
|
||||
/** flag to indicate a deprecated font */
|
||||
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 */
|
||||
local?: true;
|
||||
/** flag to indicate a fallback font */
|
||||
@@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 1011,
|
||||
descender: -353,
|
||||
lineHeight: 1.35,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
@@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
descender: -434,
|
||||
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]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 880,
|
||||
descender: -144,
|
||||
lineHeight: 1.15,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
fallback: true,
|
||||
},
|
||||
|
@@ -9,3 +9,4 @@ export * from "./promise-pool";
|
||||
export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
|
@@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
/** Strip all the methods or functions from a type */
|
||||
export type DTO<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||
? [K, V]
|
||||
: never;
|
||||
|
82
packages/common/src/utils.test.ts
Normal file
82
packages/common/src/utils.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
isTransparent,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
expect(isTransparent("#fff00000")).toEqual(true);
|
||||
expect(isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToCommonValue()", () => {
|
||||
it("should return the common value when all values are the same", () => {
|
||||
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||
expect(reduceToCommonValue([""])).toEqual("");
|
||||
expect(reduceToCommonValue([0])).toEqual(0);
|
||||
|
||||
const o = {};
|
||||
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||
|
||||
expect(
|
||||
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||
).toEqual(1);
|
||||
expect(
|
||||
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return `null` when values are different", () => {
|
||||
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return `null` when some values are nullable", () => {
|
||||
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFind()", () => {
|
||||
it("should return the first mapped non-null element", () => {
|
||||
{
|
||||
let counter = 0;
|
||||
|
||||
const result = mapFind(["a", "b", "c"], (value) => {
|
||||
counter++;
|
||||
return value === "b" ? 42 : null;
|
||||
});
|
||||
expect(result).toEqual(42);
|
||||
expect(counter).toBe(2);
|
||||
}
|
||||
|
||||
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||
expect(mapFind([1, 2], () => "")).toBe("");
|
||||
});
|
||||
|
||||
it("should return undefined if no mapped element is found", () => {
|
||||
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,10 +1,9 @@
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@@ -101,7 +100,6 @@ export const getFontFamilyString = ({
|
||||
}) => {
|
||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||
if (id === fontFamily) {
|
||||
// TODO: we should fallback first to generic family names first
|
||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||
.map((x) => `, ${x}`)
|
||||
.join("")}`;
|
||||
@@ -544,6 +542,20 @@ export const findLastIndex = <T>(
|
||||
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) => {
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
@@ -698,8 +710,8 @@ export const arrayToObject = <T>(
|
||||
array: readonly T[],
|
||||
groupBy?: (value: T) => string | number,
|
||||
) =>
|
||||
array.reduce((acc, value) => {
|
||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||
array.reduce((acc, value, idx) => {
|
||||
acc[groupBy ? groupBy(value) : idx] = value;
|
||||
return acc;
|
||||
}, {} as { [key: string]: T });
|
||||
|
||||
@@ -735,6 +747,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
||||
return acc;
|
||||
}, [] 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 isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||
@@ -1205,31 +1236,45 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const { x, y, width, height } = element;
|
||||
|
||||
const centerXPoint = x + width / 2 + xOffset;
|
||||
|
||||
const centerYPoint = y + height / 2 + yOffset;
|
||||
|
||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||
};
|
||||
|
||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
};
|
||||
|
||||
export const sizeOf = (
|
||||
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
|
||||
value:
|
||||
| readonly unknown[]
|
||||
| Readonly<Map<string, unknown>>
|
||||
| Readonly<Record<string, unknown>>
|
||||
| ReadonlySet<unknown>,
|
||||
): number => {
|
||||
return isReadonlyArray(value)
|
||||
? value.length
|
||||
: value instanceof Map
|
||||
: value instanceof Map || value instanceof Set
|
||||
? value.size
|
||||
: Object.keys(value).length;
|
||||
};
|
||||
|
||||
export const reduceToCommonValue = <T, R = T>(
|
||||
collection: readonly T[] | ReadonlySet<T>,
|
||||
getValue?: (item: T) => R,
|
||||
): R | null => {
|
||||
if (sizeOf(collection) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||
|
||||
let commonValue: R | null = null;
|
||||
|
||||
for (const item of collection) {
|
||||
const value = valueExtractor(item);
|
||||
if ((commonValue === null || commonValue === value) && value != null) {
|
||||
commonValue = value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.1.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,7 +13,10 @@
|
||||
"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": [
|
||||
@@ -50,7 +53,11 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0"
|
||||
}
|
||||
}
|
||||
|
@@ -6,25 +6,21 @@ import {
|
||||
toBrandedType,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
isReadonlyArray,
|
||||
toArray,
|
||||
} from "@excalidraw/common";
|
||||
import { isNonDeletedElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
orderByFractionalIndex,
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElements } from "@excalidraw/element/selection";
|
||||
import { getSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
mutateElement,
|
||||
type ElementUpdate,
|
||||
} from "@excalidraw/element/mutateElement";
|
||||
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -109,7 +105,7 @@ const hashSelectionOpts = (
|
||||
// in our codebase
|
||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||
|
||||
class Scene {
|
||||
export class Scene {
|
||||
// ---------------------------------------------------------------------------
|
||||
// instance methods/props
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -268,19 +264,13 @@ class Scene {
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
||||
if (!isReadonlyArray(nextElements)) {
|
||||
// need to order by fractional indices to get the correct order
|
||||
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[] = [];
|
||||
|
||||
validateIndicesThrottled(nextElements);
|
||||
validateIndicesThrottled(_nextElements);
|
||||
|
||||
this.elements = syncInvalidIndices(nextElements);
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
this.elements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
@@ -464,5 +454,3 @@ class Scene {
|
||||
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;
|
||||
};
|
||||
}
|
@@ -1,8 +1,10 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
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 { ExcalidrawElement } from "./types";
|
||||
@@ -16,11 +18,12 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
|
@@ -6,7 +6,6 @@ import {
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -27,28 +26,20 @@ import {
|
||||
PRECISION,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
getCenterForBounds,
|
||||
getElementBounds,
|
||||
doBoundsIntersect,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
headingForPoint,
|
||||
headingForPointFromElement,
|
||||
headingIsHorizontal,
|
||||
vectorToHeading,
|
||||
@@ -69,10 +60,10 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
||||
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@@ -90,6 +81,7 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
FixedPoint,
|
||||
FixedPointBinding,
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
export type SuggestedBinding =
|
||||
@@ -114,7 +106,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
||||
|
||||
export const FIXED_BINDING_DISTANCE = 5;
|
||||
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
||||
export const BINDING_HIGHLIGHT_OFFSET = 4;
|
||||
|
||||
const getNonDeletedElements = (
|
||||
scene: Scene,
|
||||
@@ -136,6 +127,7 @@ export const bindOrUnbindLinearElement = (
|
||||
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||
scene: Scene,
|
||||
): void => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||
bindOrUnbindLinearElementEdge(
|
||||
@@ -146,6 +138,7 @@ export const bindOrUnbindLinearElement = (
|
||||
boundToElementIds,
|
||||
unboundFromElementIds,
|
||||
scene,
|
||||
elementsMap,
|
||||
);
|
||||
bindOrUnbindLinearElementEdge(
|
||||
linearElement,
|
||||
@@ -155,6 +148,7 @@ export const bindOrUnbindLinearElement = (
|
||||
boundToElementIds,
|
||||
unboundFromElementIds,
|
||||
scene,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||
@@ -181,6 +175,7 @@ const bindOrUnbindLinearElementEdge = (
|
||||
// Is mutated
|
||||
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||
scene: Scene,
|
||||
elementsMap: ElementsMap,
|
||||
): void => {
|
||||
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
||||
if (bindableElement === "keep") {
|
||||
@@ -221,43 +216,29 @@ const bindOrUnbindLinearElementEdge = (
|
||||
}
|
||||
};
|
||||
|
||||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
edge: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawElement> | null => {
|
||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||
const elementId =
|
||||
edge === "start"
|
||||
? linearElement.startBinding?.elementId
|
||||
: linearElement.endBinding?.elementId;
|
||||
if (elementId) {
|
||||
const element = elementsMap.get(elementId);
|
||||
if (
|
||||
isBindableElement(element) &&
|
||||
bindingBorderTest(element, coors, elementsMap, zoom)
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||
["start", "end"].map((edge) =>
|
||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||
linearElement,
|
||||
edge as "start" | "end",
|
||||
elementsMap,
|
||||
zoom,
|
||||
),
|
||||
);
|
||||
(["start", "end"] as const).map((edge) => {
|
||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||
const elementId =
|
||||
edge === "start"
|
||||
? linearElement.startBinding?.elementId
|
||||
: linearElement.endBinding?.elementId;
|
||||
if (elementId) {
|
||||
const element = elementsMap.get(elementId);
|
||||
if (
|
||||
isBindableElement(element) &&
|
||||
bindingBorderTest(element, coors, elementsMap, zoom)
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -273,7 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||
const start = startDragged
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
? getEligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
@@ -284,7 +265,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
: "keep";
|
||||
const end = endDragged
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
? getEligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
@@ -316,7 +297,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
);
|
||||
const start = startIsClose
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
? getEligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
@@ -327,7 +308,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
: null;
|
||||
const end = endIsClose
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
? getEligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
@@ -403,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
|
||||
);
|
||||
};
|
||||
|
||||
export const maybeSuggestBindingsForLinearElementAtCoords = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
/** scene coords */
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[],
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
): ExcalidrawBindableElement[] =>
|
||||
Array.from(
|
||||
pointerCoords.reduce(
|
||||
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
coords,
|
||||
scene.getNonDeletedElements(),
|
||||
scene.getNonDeletedElementsMap(),
|
||||
zoom,
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
) {
|
||||
acc.add(hoveredBindableElement);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
|
||||
),
|
||||
);
|
||||
|
||||
export const maybeBindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
@@ -446,22 +469,13 @@ export const maybeBindLinearElement = (
|
||||
const normalizePointBinding = (
|
||||
binding: { focus: number; gap: number },
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
) => {
|
||||
let gap = binding.gap;
|
||||
const maxGap = maxBindingGap(
|
||||
hoveredElement,
|
||||
hoveredElement.width,
|
||||
hoveredElement.height,
|
||||
);
|
||||
|
||||
if (gap > maxGap) {
|
||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
||||
}
|
||||
return {
|
||||
...binding,
|
||||
gap,
|
||||
};
|
||||
};
|
||||
) => ({
|
||||
...binding,
|
||||
gap: Math.min(
|
||||
binding.gap,
|
||||
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
|
||||
),
|
||||
});
|
||||
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -493,6 +507,7 @@ export const bindLinearElement = (
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -540,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
||||
|
||||
const isLinearElementSimple = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
): boolean => linearElement.points.length < 3;
|
||||
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||
|
||||
const unbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -708,8 +723,13 @@ const calculateFocusAndGap = (
|
||||
);
|
||||
|
||||
return {
|
||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||
focus: determineFocusDistance(
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
adjacentPoint,
|
||||
edgePoint,
|
||||
),
|
||||
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -807,28 +827,22 @@ export const updateBoundElements = (
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (point) {
|
||||
return {
|
||||
index:
|
||||
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
||||
point,
|
||||
};
|
||||
return [
|
||||
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
||||
{ point },
|
||||
] as MapEntry<PointsPositionUpdates>;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
).filter(
|
||||
(
|
||||
update,
|
||||
): update is NonNullable<{
|
||||
index: number;
|
||||
point: LocalPoint;
|
||||
isDragging?: boolean;
|
||||
}> => update !== null,
|
||||
(update): update is MapEntry<PointsPositionUpdates> => update !== null,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(element, scene, updates, {
|
||||
LinearElementEditor.movePoints(element, scene, new Map(updates), {
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
@@ -885,6 +899,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||
bindableElement: ExcalidrawBindableElement | undefined | null,
|
||||
aabb: Bounds | undefined | null,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): Heading => {
|
||||
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
||||
@@ -893,11 +908,16 @@ export const getHeadingForElbowArrowSnap = (
|
||||
return otherPointHeading;
|
||||
}
|
||||
|
||||
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
|
||||
const distance = getDistanceForBinding(
|
||||
origPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
|
||||
if (!distance) {
|
||||
return vectorToHeading(
|
||||
vectorFromPoint(p, elementCenterPoint(bindableElement)),
|
||||
vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -907,9 +927,10 @@ export const getHeadingForElbowArrowSnap = (
|
||||
const getDistanceForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
const distance = distanceToBindableElement(bindableElement, point);
|
||||
const distance = distanceToElement(bindableElement, elementsMap, point);
|
||||
const bindDistance = maxBindingGap(
|
||||
bindableElement,
|
||||
bindableElement.width,
|
||||
@@ -924,12 +945,13 @@ export const bindPointToSnapToElementOutline = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
if (isDevEnv() || isTestEnv()) {
|
||||
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
||||
}
|
||||
|
||||
const aabb = aabbForElement(bindableElement);
|
||||
const aabb = aabbForElement(bindableElement, elementsMap);
|
||||
const localP =
|
||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||
const globalP = pointFrom<GlobalPoint>(
|
||||
@@ -937,7 +959,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
arrow.y + localP[1],
|
||||
);
|
||||
const edgePoint = isRectanguloidElement(bindableElement)
|
||||
? avoidRectangularCorner(bindableElement, globalP)
|
||||
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
|
||||
: globalP;
|
||||
const elbowed = isElbowArrow(arrow);
|
||||
const center = getCenterForBounds(aabb);
|
||||
@@ -956,26 +978,31 @@ export const bindPointToSnapToElementOutline = (
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||
);
|
||||
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
isHorizontal ? center[0] : edgePoint[0],
|
||||
!isHorizontal ? center[1] : edgePoint[1],
|
||||
isHorizontal ? center[0] : snapPoint[0],
|
||||
!isHorizontal ? center[1] : snapPoint[1],
|
||||
);
|
||||
const intersector = lineSegment(
|
||||
otherPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
otherPoint,
|
||||
),
|
||||
);
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
lineSegment(
|
||||
otherPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
otherPoint,
|
||||
),
|
||||
),
|
||||
)[0];
|
||||
elementsMap,
|
||||
intersector,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
).sort(pointDistanceSq)[0];
|
||||
} else {
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
lineSegment(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
@@ -1002,43 +1029,20 @@ export const bindPointToSnapToElementOutline = (
|
||||
return edgePoint;
|
||||
}
|
||||
|
||||
if (elbowed) {
|
||||
const scalar =
|
||||
pointDistanceSq(edgePoint, center) -
|
||||
pointDistanceSq(intersection, center) >
|
||||
0
|
||||
? FIXED_BINDING_DISTANCE
|
||||
: -FIXED_BINDING_DISTANCE;
|
||||
|
||||
return pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
|
||||
scalar,
|
||||
),
|
||||
intersection,
|
||||
);
|
||||
}
|
||||
|
||||
return edgePoint;
|
||||
return elbowed ? intersection : edgePoint;
|
||||
};
|
||||
|
||||
export const avoidRectangularCorner = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||
// Top left
|
||||
const heading = headingForPoint(
|
||||
nonRotatedPoint,
|
||||
pointFrom(element.x, element.y),
|
||||
);
|
||||
if (
|
||||
compareHeading(heading, HEADING_DOWN) ||
|
||||
compareHeading(heading, HEADING_LEFT)
|
||||
) {
|
||||
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
@@ -1055,14 +1059,7 @@ export const avoidRectangularCorner = (
|
||||
nonRotatedPoint[1] > element.y + element.height
|
||||
) {
|
||||
// Bottom left
|
||||
const heading = headingForPoint(
|
||||
nonRotatedPoint,
|
||||
pointFrom(element.x, element.y + element.height),
|
||||
);
|
||||
if (
|
||||
compareHeading(heading, HEADING_DOWN) ||
|
||||
compareHeading(heading, HEADING_RIGHT)
|
||||
) {
|
||||
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
||||
return pointRotateRads(
|
||||
pointFrom(
|
||||
element.x,
|
||||
@@ -1082,13 +1079,9 @@ export const avoidRectangularCorner = (
|
||||
nonRotatedPoint[1] > element.y + element.height
|
||||
) {
|
||||
// Bottom right
|
||||
const heading = headingForPoint(
|
||||
nonRotatedPoint,
|
||||
pointFrom(element.x + element.width, element.y + element.height),
|
||||
);
|
||||
if (
|
||||
compareHeading(heading, HEADING_DOWN) ||
|
||||
compareHeading(heading, HEADING_LEFT)
|
||||
nonRotatedPoint[0] - element.x <
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return pointRotateRads(
|
||||
pointFrom(
|
||||
@@ -1112,13 +1105,9 @@ export const avoidRectangularCorner = (
|
||||
nonRotatedPoint[1] < element.y
|
||||
) {
|
||||
// Top right
|
||||
const heading = headingForPoint(
|
||||
nonRotatedPoint,
|
||||
pointFrom(element.x + element.width, element.y),
|
||||
);
|
||||
if (
|
||||
compareHeading(heading, HEADING_UP) ||
|
||||
compareHeading(heading, HEADING_LEFT)
|
||||
nonRotatedPoint[0] - element.x <
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return pointRotateRads(
|
||||
pointFrom(
|
||||
@@ -1136,51 +1125,39 @@ export const avoidRectangularCorner = (
|
||||
);
|
||||
}
|
||||
|
||||
// Break up explicit border bindings to have better elbow arrow routing
|
||||
if (p[0] === element.x) {
|
||||
return pointFrom(p[0] - FIXED_BINDING_DISTANCE, p[1]);
|
||||
} else if (p[0] === element.x + element.width) {
|
||||
return pointFrom(p[0] + FIXED_BINDING_DISTANCE, p[1]);
|
||||
} else if (p[1] === element.y) {
|
||||
return pointFrom(p[0], p[1] - FIXED_BINDING_DISTANCE);
|
||||
} else if (p[1] === element.y + element.height) {
|
||||
return pointFrom(p[0], p[1] + FIXED_BINDING_DISTANCE);
|
||||
}
|
||||
|
||||
return p;
|
||||
};
|
||||
|
||||
export const snapToMid = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
tolerance: number = 0.05,
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
|
||||
const center = elementCenterPoint(element, -0.1, -0.1);
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
const verticalThrehsold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
|
||||
const verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads(
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
@@ -1190,8 +1167,8 @@ export const snapToMid = (
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
@@ -1201,8 +1178,8 @@ export const snapToMid = (
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
@@ -1210,6 +1187,49 @@ export const snapToMid = (
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (element.type === "diamond") {
|
||||
const distance = FIXED_BINDING_DISTANCE;
|
||||
const topLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const topRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const bottomLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
const bottomRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(topLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(topRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topRight, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomRight, center, angle);
|
||||
}
|
||||
}
|
||||
|
||||
return p;
|
||||
@@ -1241,8 +1261,9 @@ const updateBoundPoint = (
|
||||
linearElement,
|
||||
bindableElement,
|
||||
startOrEnd === "startBinding" ? "start" : "end",
|
||||
elementsMap,
|
||||
).fixedPoint;
|
||||
const globalMidPoint = elementCenterPoint(bindableElement);
|
||||
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
|
||||
const global = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||
@@ -1268,6 +1289,7 @@ const updateBoundPoint = (
|
||||
);
|
||||
const focusPointAbsolute = determineFocusPoint(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
binding.focus,
|
||||
adjacentPoint,
|
||||
);
|
||||
@@ -1286,7 +1308,7 @@ const updateBoundPoint = (
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center = elementCenterPoint(bindableElement);
|
||||
const center = elementCenterPoint(bindableElement, elementsMap);
|
||||
const interceptorLength =
|
||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||
pointDistance(adjacentPoint, center) +
|
||||
@@ -1294,6 +1316,7 @@ const updateBoundPoint = (
|
||||
const intersections = [
|
||||
...intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
lineSegment<GlobalPoint>(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
@@ -1344,6 +1367,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
const bounds = [
|
||||
hoveredElement.x,
|
||||
@@ -1355,6 +1379,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
@@ -1398,7 +1423,7 @@ const maybeCalculateNewGapWhenScaling = (
|
||||
return { ...currentBinding, gap: newGap };
|
||||
};
|
||||
|
||||
const getElligibleElementForBindingElement = (
|
||||
const getEligibleElementForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
@@ -1550,14 +1575,38 @@ export const bindingBorderTest = (
|
||||
zoom?: AppState["zoom"],
|
||||
fullShape?: boolean,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
||||
!isFrameLikeElement(element);
|
||||
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const bounds = [
|
||||
x - threshold,
|
||||
y - threshold,
|
||||
x + threshold,
|
||||
y + threshold,
|
||||
] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
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 <= threshold
|
||||
: intersections.length > 0 && distance <= threshold;
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
@@ -1577,7 +1626,7 @@ export const maxBindingGap = (
|
||||
// bigger bindable boundary for bigger elements
|
||||
Math.min(0.25 * smallerDimension, 32),
|
||||
// keep in sync with the zoomed highlight
|
||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1588,12 +1637,13 @@ export const maxBindingGap = (
|
||||
// of the element.
|
||||
const determineFocusDistance = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
// Point on the line, in absolute coordinates
|
||||
a: GlobalPoint,
|
||||
// Another point on the line, in absolute coordinates (closer to element)
|
||||
b: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (pointsEqual(a, b)) {
|
||||
return 0;
|
||||
@@ -1718,12 +1768,13 @@ const determineFocusDistance = (
|
||||
|
||||
const determineFocusPoint = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
// The oriented, relative distance from the center of `element` of the
|
||||
// returned focusPoint
|
||||
focus: number,
|
||||
adjacentPoint: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (focus === 0) {
|
||||
return center;
|
||||
@@ -2146,6 +2197,7 @@ export class BindableElement {
|
||||
export const getGlobalFixedPointForBindableElement = (
|
||||
fixedPointRatio: [number, number],
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||
|
||||
@@ -2154,7 +2206,7 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
element.x + element.width * fixedX,
|
||||
element.y + element.height * fixedY,
|
||||
),
|
||||
elementCenterPoint(element),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
element.angle,
|
||||
);
|
||||
};
|
||||
@@ -2178,6 +2230,7 @@ export const getGlobalFixedPoints = (
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
startElement as ExcalidrawBindableElement,
|
||||
elementsMap,
|
||||
)
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[0][0],
|
||||
@@ -2188,6 +2241,7 @@ export const getGlobalFixedPoints = (
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
endElement as ExcalidrawBindableElement,
|
||||
elementsMap,
|
||||
)
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||
|
@@ -5,6 +5,7 @@ import {
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
STROKE_WIDTH,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -33,8 +34,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shapes";
|
||||
import { getElementShape } from "./shape";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
@@ -102,9 +103,23 @@ export class ElementBounds {
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
private static nonRotatedBoundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
static getBounds(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
) {
|
||||
const cachedBounds =
|
||||
nonRotated && element.angle !== 0
|
||||
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||
: ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (
|
||||
cachedBounds?.version &&
|
||||
@@ -115,6 +130,23 @@ export class ElementBounds {
|
||||
) {
|
||||
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);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
@@ -553,7 +585,7 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
export const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
@@ -777,9 +809,15 @@ export const getArrowheadPoints = (
|
||||
// This value is selected by minimizing a minimum size with the last segment of the arrowhead
|
||||
const lengthMultiplier =
|
||||
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
||||
const minSize = Math.min(size, length * lengthMultiplier);
|
||||
const xs = x2 - nx * minSize;
|
||||
const ys = y2 - ny * minSize;
|
||||
// make arrowheads bigger for thick strokes
|
||||
const strokeWidthMultiplier =
|
||||
element.strokeWidth >= STROKE_WIDTH.extraBold ? 1.5 : 1;
|
||||
|
||||
const adjustedSize =
|
||||
Math.min(size, length * lengthMultiplier) * strokeWidthMultiplier;
|
||||
|
||||
const xs = x2 - nx * adjustedSize;
|
||||
const ys = y2 - ny * adjustedSize;
|
||||
|
||||
if (
|
||||
arrowhead === "dot" ||
|
||||
@@ -828,7 +866,7 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2 + adjustedSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
);
|
||||
@@ -839,7 +877,7 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2 - adjustedSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
);
|
||||
@@ -939,8 +977,9 @@ const getLinearElementRotatedBounds = (
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
): Bounds => {
|
||||
return ElementBounds.getBounds(element, elementsMap);
|
||||
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
@@ -1133,6 +1172,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
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 = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1146,3 +1250,14 @@ export const doBoundsIntersect = (
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
};
|
||||
|
@@ -1,52 +1,68 @@
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseLineIntersectionPoints,
|
||||
ellipseSegmentInterceptPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Polygon,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
type Bounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
@@ -72,45 +88,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
export type HitTestArgs = {
|
||||
point: GlobalPoint;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
element,
|
||||
shape,
|
||||
threshold = 10,
|
||||
threshold,
|
||||
elementsMap,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
}: HitTestArgs) => {
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||
point,
|
||||
pointFrom(
|
||||
frameNameBound.x + frameNameBound.width + threshold,
|
||||
frameNameBound.y + frameNameBound.height + threshold,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||
if (!hitBounds && !hitFrameName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hit;
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInElement(point, element, elementsMap) ||
|
||||
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||
|
||||
return hitElement || hitFrameName;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
x: number,
|
||||
y: number,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
@@ -120,37 +155,42 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
hitArgs: HitTestArgs,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(
|
||||
hitArgs.x,
|
||||
hitArgs.y,
|
||||
getBoundTextShape(hitArgs.element, elementsMap),
|
||||
) &&
|
||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
||||
);
|
||||
};
|
||||
) =>
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
export const hitElementBoundText = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,9 +203,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
*/
|
||||
export const intersectElementWithLineSegment = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
line: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// First check if the line intersects the element's axis-aligned bounding box
|
||||
// as it is much faster than checking intersection against the element's shape
|
||||
const intersectorBounds = [
|
||||
Math.min(line[0][0] - offset, line[1][0] - offset),
|
||||
Math.min(line[0][1] - offset, line[1][1] - offset),
|
||||
Math.max(line[0][0] + offset, line[1][0] + offset),
|
||||
Math.max(line[0][1] + offset, line[1][1] + offset),
|
||||
] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
@@ -173,67 +230,196 @@ export const intersectElementWithLineSegment = (
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "selection":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
return intersectRectanguloidWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
return intersectDiamondWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
return intersectEllipseWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
);
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(intersection);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
l[0],
|
||||
segment[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
l[1],
|
||||
segment[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
return (
|
||||
// Test intersection against the sides, keep only the valid
|
||||
// intersection points and rotate them back to scene space
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.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,
|
||||
)
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -245,43 +431,45 @@ const intersectRectanguloidWithLineSegment = (
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
return (
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.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,
|
||||
)
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -293,16 +481,76 @@ const intersectDiamondWithLineSegment = (
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseLineIntersectionPoints(
|
||||
return ellipseSegmentInterceptPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
line(rotatedA, rotatedB),
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given point is considered on the given shape's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @param tolerance
|
||||
* @returns
|
||||
*/
|
||||
const isPointOnElementOutline = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 1,
|
||||
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
||||
|
||||
/**
|
||||
* Check if the given point is considered inside the element's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
export const isPointInElement = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (
|
||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||
!isPathALoop(element.points)
|
||||
) {
|
||||
// There isn't any "inside" for a non-looping path
|
||||
return false;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const otherPoint = pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
||||
Math.max(element.width, element.height) * 2,
|
||||
),
|
||||
center,
|
||||
);
|
||||
const intersector = lineSegment(point, otherPoint);
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
intersector,
|
||||
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
@@ -14,9 +14,8 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
|
||||
|
||||
export const cropElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandle: TransformHandleType,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
@@ -63,7 +63,7 @@ export const cropElement = (
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
elementCenterPoint(element),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
-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 { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { elementCenterPoint } from "./bounds";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
export const distanceToElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectanguloidElement(element, p);
|
||||
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, p);
|
||||
return distanceToDiamondElement(element, elementsMap, p);
|
||||
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 = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
|
||||
*/
|
||||
const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
@@ -108,12 +120,24 @@ const distanceToDiamondElement = (
|
||||
*/
|
||||
const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
return ellipseDistanceFromPoint(
|
||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||
pointRotateRads(p, center, -element.angle as Radians),
|
||||
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 { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
@@ -14,6 +16,7 @@ export const distributeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -21,7 +24,11 @@ export const distributeElements = (
|
||||
: (["minY", "midY", "maxY", "height"] as const);
|
||||
|
||||
const bounds = getCommonBoundingBox(selectedElements);
|
||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
||||
const groups = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState,
|
||||
)
|
||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||
|
||||
|
@@ -26,7 +26,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
@@ -20,6 +20,7 @@ import {
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -29,10 +30,9 @@ import {
|
||||
FIXED_BINDING_DISTANCE,
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
snapToMid,
|
||||
getHoveredElementForBinding,
|
||||
} from "./binding";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
flipHeading,
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, aabbForPoints, pointInsideBounds } from "./shapes";
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
@@ -65,8 +65,6 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import { debugDrawBounds } from "@excalidraw/utils/visualdebug";
|
||||
|
||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||
|
||||
type Node = {
|
||||
@@ -108,32 +106,8 @@ type ElbowArrowData = {
|
||||
hoveredEndElement: ExcalidrawBindableElement | null;
|
||||
};
|
||||
|
||||
const calculateDedupTreshhold = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
) => 1 + pointDistance(a, b) / 300;
|
||||
|
||||
const calculatePadding = (
|
||||
aabb: Bounds,
|
||||
startBoundingBox: Bounds,
|
||||
endBoundingBox: Bounds,
|
||||
) => {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
Math.hypot(
|
||||
startBoundingBox[2] - startBoundingBox[0],
|
||||
startBoundingBox[3] - startBoundingBox[1],
|
||||
) / 4,
|
||||
Math.hypot(
|
||||
endBoundingBox[2] - endBoundingBox[0],
|
||||
endBoundingBox[3] - endBoundingBox[1],
|
||||
) / 4,
|
||||
Math.hypot(aabb[2] - aabb[0], aabb[3] - aabb[1]) / 4,
|
||||
40,
|
||||
),
|
||||
30,
|
||||
);
|
||||
};
|
||||
const DEDUP_TRESHOLD = 1;
|
||||
export const BASE_PADDING = 40;
|
||||
|
||||
const handleSegmentRenormalization = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
@@ -209,11 +183,7 @@ const handleSegmentRenormalization = (
|
||||
|
||||
if (
|
||||
// Remove segments that are too short
|
||||
pointDistance(points[i - 2], points[i - 1]) <
|
||||
calculateDedupTreshhold(
|
||||
points[i - 3] ?? points[i - 3],
|
||||
points[i] ?? points[i - 1],
|
||||
)
|
||||
pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD
|
||||
) {
|
||||
const prevPrevSegmentIdx =
|
||||
nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
|
||||
@@ -389,10 +359,6 @@ const handleSegmentRelease = (
|
||||
null,
|
||||
);
|
||||
|
||||
if (!restoredPoints) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextPoints: GlobalPoint[] = [];
|
||||
|
||||
// First part of the arrow are the old points
|
||||
@@ -497,13 +463,6 @@ const handleSegmentMove = (
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
const BASE_PADDING = calculatePadding(
|
||||
aabbForElement(arrow),
|
||||
hoveredStartElement
|
||||
? aabbForElement(hoveredStartElement)
|
||||
: [10, 10, 10, 10],
|
||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
|
||||
);
|
||||
const activelyModifiedSegmentIdx = fixedSegments
|
||||
.map((segment, i) => {
|
||||
if (
|
||||
@@ -748,13 +707,6 @@ const handleEndpointDrag = (
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||
) => {
|
||||
const BASE_PADDING = calculatePadding(
|
||||
aabbForPoints([startGlobalPoint, endGlobalPoint]),
|
||||
hoveredStartElement
|
||||
? aabbForElement(hoveredStartElement)
|
||||
: [10, 10, 10, 10],
|
||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
|
||||
);
|
||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||
@@ -789,7 +741,6 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second point connection and add the start point
|
||||
{
|
||||
startIsSpecial = arrow.startIsSpecial && globalUpdatedPoints.length > 2;
|
||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||
@@ -850,7 +801,6 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second to last point connection
|
||||
{
|
||||
endIsSpecial = arrow.endIsSpecial && globalUpdatedPoints.length > 2;
|
||||
const secondToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||
const thirdToLastPoint =
|
||||
@@ -948,50 +898,6 @@ export const updateElbowArrowPoints = (
|
||||
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) {
|
||||
invariant(
|
||||
!updates.points || updates.points.length >= 2,
|
||||
@@ -1024,6 +930,25 @@ export const updateElbowArrowPoints = (
|
||||
),
|
||||
"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 ?? [];
|
||||
@@ -1304,6 +1229,7 @@ const getElbowArrowData = (
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
@@ -1317,6 +1243,7 @@ const getElbowArrowData = (
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
@@ -1324,12 +1251,14 @@ const getElbowArrowData = (
|
||||
endGlobalPoint,
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1343,28 +1272,31 @@ const getElbowArrowData = (
|
||||
endGlobalPoint[0] + 2,
|
||||
endGlobalPoint[1] + 2,
|
||||
] as Bounds;
|
||||
const BASE_PADDING = calculatePadding(
|
||||
aabbForPoints([startGlobalPoint, endGlobalPoint]),
|
||||
hoveredStartElement
|
||||
? aabbForElement(hoveredStartElement)
|
||||
: [10, 10, 10, 10],
|
||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
|
||||
);
|
||||
const startOffsets = offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
|
||||
1,
|
||||
);
|
||||
const endOffsets = offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
|
||||
1,
|
||||
);
|
||||
const startElementBounds = hoveredStartElement
|
||||
? aabbForElement(hoveredStartElement, startOffsets)
|
||||
? aabbForElement(
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
: startPointBounds;
|
||||
const endElementBounds = hoveredEndElement
|
||||
? aabbForElement(hoveredEndElement, endOffsets)
|
||||
? aabbForElement(
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
: endPointBounds;
|
||||
const boundsOverlap =
|
||||
pointInsideBounds(
|
||||
@@ -1372,6 +1304,7 @@ const getElbowArrowData = (
|
||||
hoveredEndElement
|
||||
? aabbForElement(
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
||||
)
|
||||
: endPointBounds,
|
||||
@@ -1381,6 +1314,7 @@ const getElbowArrowData = (
|
||||
hoveredStartElement
|
||||
? aabbForElement(
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
||||
)
|
||||
: startPointBounds,
|
||||
@@ -1407,7 +1341,7 @@ const getElbowArrowData = (
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE),
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1423,29 +1357,13 @@ const getElbowArrowData = (
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE),
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
hoveredStartElement
|
||||
? aabbForElement(hoveredStartElement)
|
||||
: startPointBounds,
|
||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : endPointBounds,
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
|
||||
);
|
||||
|
||||
debugDrawBounds(startElementBounds, {
|
||||
permanent: false,
|
||||
color: "red",
|
||||
});
|
||||
debugDrawBounds(endElementBounds, {
|
||||
permanent: false,
|
||||
color: "green",
|
||||
});
|
||||
debugDrawBounds(dynamicAABBs, {
|
||||
permanent: false,
|
||||
color: "blue",
|
||||
});
|
||||
|
||||
const startDonglePosition = getDonglePosition(
|
||||
dynamicAABBs[0],
|
||||
startHeading,
|
||||
@@ -1716,11 +1634,11 @@ const generateDynamicAABBs = (
|
||||
a: Bounds,
|
||||
b: Bounds,
|
||||
common: Bounds,
|
||||
startDifference: [number, number, number, number],
|
||||
endDifference: [number, number, number, number],
|
||||
disableSideHack: boolean,
|
||||
startElementBounds: Bounds,
|
||||
endElementBounds: Bounds,
|
||||
startDifference?: [number, number, number, number],
|
||||
endDifference?: [number, number, number, number],
|
||||
disableSideHack?: boolean,
|
||||
startElementBounds?: Bounds | null,
|
||||
endElementBounds?: Bounds | null,
|
||||
): Bounds[] => {
|
||||
const startEl = startElementBounds ?? a;
|
||||
const endEl = endElementBounds ?? b;
|
||||
@@ -1800,24 +1718,15 @@ const generateDynamicAABBs = (
|
||||
(second[0] + second[2]) / 2,
|
||||
(second[1] + second[3]) / 2,
|
||||
];
|
||||
if (
|
||||
endElementBounds[0] > startElementBounds[2] &&
|
||||
startElementBounds[1] > endElementBounds[3]
|
||||
) {
|
||||
if (b[0] > a[2] && a[1] > b[3]) {
|
||||
// BOTTOM LEFT
|
||||
const cX = first[2] + (second[0] - first[2]) / 2;
|
||||
const cY = second[3] + (first[1] - second[3]) / 2;
|
||||
|
||||
if (
|
||||
vectorCross(
|
||||
vector(
|
||||
startElementBounds[2] - endCenterX,
|
||||
startElementBounds[1] - endCenterY,
|
||||
),
|
||||
vector(
|
||||
startElementBounds[0] - endCenterX,
|
||||
startElementBounds[3] - endCenterY,
|
||||
),
|
||||
vector(a[2] - endCenterX, a[1] - endCenterY),
|
||||
vector(a[0] - endCenterX, a[3] - endCenterY),
|
||||
) > 0
|
||||
) {
|
||||
return [
|
||||
@@ -1830,24 +1739,15 @@ const generateDynamicAABBs = (
|
||||
[first[0], cY, first[2], first[3]],
|
||||
[second[0], second[1], second[2], cY],
|
||||
];
|
||||
} else if (
|
||||
startElementBounds[2] < endElementBounds[0] &&
|
||||
startElementBounds[3] < endElementBounds[1]
|
||||
) {
|
||||
} else if (a[2] < b[0] && a[3] < b[1]) {
|
||||
// TOP LEFT
|
||||
const cX = first[2] + (second[0] - first[2]) / 2;
|
||||
const cY = first[3] + (second[1] - first[3]) / 2;
|
||||
|
||||
if (
|
||||
vectorCross(
|
||||
vector(
|
||||
startElementBounds[0] - endCenterX,
|
||||
startElementBounds[1] - endCenterY,
|
||||
),
|
||||
vector(
|
||||
startElementBounds[2] - endCenterX,
|
||||
startElementBounds[3] - endCenterY,
|
||||
),
|
||||
vector(a[0] - endCenterX, a[1] - endCenterY),
|
||||
vector(a[2] - endCenterX, a[3] - endCenterY),
|
||||
) > 0
|
||||
) {
|
||||
return [
|
||||
@@ -1860,24 +1760,15 @@ const generateDynamicAABBs = (
|
||||
[first[0], first[1], cX, first[3]],
|
||||
[cX, second[1], second[2], second[3]],
|
||||
];
|
||||
} else if (
|
||||
startElementBounds[0] > endElementBounds[2] &&
|
||||
startElementBounds[3] < endElementBounds[1]
|
||||
) {
|
||||
} else if (a[0] > b[2] && a[3] < b[1]) {
|
||||
// TOP RIGHT
|
||||
const cX = second[2] + (first[0] - second[2]) / 2;
|
||||
const cY = first[3] + (second[1] - first[3]) / 2;
|
||||
|
||||
if (
|
||||
vectorCross(
|
||||
vector(
|
||||
startElementBounds[2] - endCenterX,
|
||||
startElementBounds[1] - endCenterY,
|
||||
),
|
||||
vector(
|
||||
startElementBounds[0] - endCenterX,
|
||||
startElementBounds[3] - endCenterY,
|
||||
),
|
||||
vector(a[2] - endCenterX, a[1] - endCenterY),
|
||||
vector(a[0] - endCenterX, a[3] - endCenterY),
|
||||
) > 0
|
||||
) {
|
||||
return [
|
||||
@@ -1890,24 +1781,15 @@ const generateDynamicAABBs = (
|
||||
[first[0], first[1], first[2], cY],
|
||||
[second[0], cY, second[2], second[3]],
|
||||
];
|
||||
} else if (
|
||||
startElementBounds[0] > endElementBounds[2] &&
|
||||
startElementBounds[1] > endElementBounds[3]
|
||||
) {
|
||||
} else if (a[0] > b[2] && a[1] > b[3]) {
|
||||
// BOTTOM RIGHT
|
||||
const cX = second[2] + (first[0] - second[2]) / 2;
|
||||
const cY = second[3] + (first[1] - second[3]) / 2;
|
||||
|
||||
if (
|
||||
vectorCross(
|
||||
vector(
|
||||
startElementBounds[0] - endCenterX,
|
||||
startElementBounds[1] - endCenterY,
|
||||
),
|
||||
vector(
|
||||
startElementBounds[2] - endCenterX,
|
||||
startElementBounds[3] - endCenterY,
|
||||
),
|
||||
vector(a[0] - endCenterX, a[1] - endCenterY),
|
||||
vector(a[2] - endCenterX, a[3] - endCenterY),
|
||||
) > 0
|
||||
) {
|
||||
return [
|
||||
@@ -2189,11 +2071,16 @@ const normalizeArrowElementUpdate = (
|
||||
nextFixedSegments: readonly FixedSegment[] | null,
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
if (global.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
): {
|
||||
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 offsetY = global[0][1];
|
||||
let points = global.map((p) =>
|
||||
@@ -2281,10 +2168,7 @@ const removeElbowArrowShortSegments = (
|
||||
|
||||
const prev = points[idx - 1];
|
||||
const prevDist = pointDistance(prev, p);
|
||||
return (
|
||||
prevDist >
|
||||
calculateDedupTreshhold(points[idx - 2] ?? prev, points[idx + 1] ?? p)
|
||||
);
|
||||
return prevDist > DEDUP_TRESHOLD;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2309,35 +2193,28 @@ const getGlobalPoint = (
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (element) {
|
||||
const snapPoint = bindPointToSnapToElementOutline(
|
||||
if (element && elementsMap) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return snapToMid(element, snapPoint);
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||
return getGlobalFixedPointForBindableElement(
|
||||
fixedPointRatio || [0, 0],
|
||||
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;
|
||||
@@ -2348,6 +2225,7 @@ const getBindPointHeading = (
|
||||
otherPoint: GlobalPoint,
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): Heading =>
|
||||
getHeadingForElbowArrowSnap(
|
||||
p,
|
||||
@@ -2356,7 +2234,8 @@ const getBindPointHeading = (
|
||||
hoveredElement &&
|
||||
aabbForElement(
|
||||
hoveredElement,
|
||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
||||
elementsMap,
|
||||
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
@@ -2364,6 +2243,7 @@ const getBindPointHeading = (
|
||||
],
|
||||
),
|
||||
origPoint,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const getHoveredElement = (
|
||||
@@ -2387,16 +2267,13 @@ const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||
|
||||
export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
|
||||
points: readonly P[],
|
||||
tolerance?: number,
|
||||
tolerance: number = DEDUP_TRESHOLD,
|
||||
) =>
|
||||
points
|
||||
.slice(1)
|
||||
.map((p, i) => {
|
||||
const t =
|
||||
tolerance ??
|
||||
calculateDedupTreshhold(points[i - 1] ?? points[i], points[i + 2] ?? p);
|
||||
return (
|
||||
Math.abs(p[0] - points[i][0]) < t || Math.abs(p[1] - points[i][1]) < t
|
||||
);
|
||||
})
|
||||
.map(
|
||||
(p, i) =>
|
||||
Math.abs(p[0] - points[i][0]) < tolerance ||
|
||||
Math.abs(p[1] - points[i][1]) < tolerance,
|
||||
)
|
||||
.every(Boolean);
|
||||
|
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
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 =
|
||||
/^(?: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 =
|
||||
/^<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
|
||||
const RE_TWITTER =
|
||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||
@@ -54,6 +56,35 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<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([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -69,6 +100,7 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
@@ -82,6 +114,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
export const createSrcDoc = (body: string) => {
|
||||
@@ -109,7 +142,8 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
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");
|
||||
type = "video";
|
||||
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)) {
|
||||
const postId = link.match(RE_TWITTER)![1];
|
||||
// the embed srcdoc still supports twitter.com domain only.
|
||||
|
@@ -21,7 +21,7 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
@@ -95,10 +95,11 @@ const getNodeRelatives = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
] as Readonly<GlobalPoint>);
|
||||
const heading = headingForPointFromElement(
|
||||
node,
|
||||
aabbForElement(node, elementsMap),
|
||||
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||
);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
@@ -462,12 +463,18 @@ const createBindingArrow = (
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(bindingArrow, scene, [
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const update = updateElbowArrowPoints(
|
||||
bindingArrow,
|
||||
|
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
export class InvalidFractionalIndexError extends Error {
|
||||
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
|
||||
|
||||
// try generatating indices, throws on invalid movedElements
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
const elementsCandidates = elements.map((x) =>
|
||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
||||
);
|
||||
const elementsCandidates = elements.map((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
|
||||
validateFractionalIndices(
|
||||
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
|
||||
);
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
}
|
||||
} catch (e) {
|
||||
// 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.
|
||||
*/
|
||||
@@ -200,13 +207,32 @@ export const syncInvalidIndices = (
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
@@ -905,13 +905,16 @@ export const shouldApplyFrameClip = (
|
||||
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
|
||||
return element.name === null
|
||||
? isFrameElement(element)
|
||||
? "Frame"
|
||||
: "AI Frame"
|
||||
: element.name;
|
||||
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
|
||||
};
|
||||
|
||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
return element.name === null ? getDefaultFrameName(element) : element.name;
|
||||
};
|
||||
|
||||
export const getElementsOverlappingFrame = (
|
||||
|
278
packages/element/src/freedraw.ts
Normal file
278
packages/element/src/freedraw.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { LaserPointer, type Point } from "@excalidraw/laser-pointer";
|
||||
|
||||
import { clamp, round, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import getStroke from "perfect-freehand";
|
||||
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
|
||||
import type { ExcalidrawFreeDrawElement, PointerType } from "./types";
|
||||
|
||||
export const STROKE_OPTIONS: Record<
|
||||
PointerType | "default",
|
||||
{ streamline: number; simplify: number }
|
||||
> = {
|
||||
default: {
|
||||
streamline: 0.35,
|
||||
simplify: 0.1,
|
||||
},
|
||||
mouse: {
|
||||
streamline: 0.6,
|
||||
simplify: 0.1,
|
||||
},
|
||||
pen: {
|
||||
// for optimal performance, we use a lower streamline and simplify
|
||||
streamline: 0.2,
|
||||
simplify: 0.1,
|
||||
},
|
||||
touch: {
|
||||
streamline: 0.65,
|
||||
simplify: 0.1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getFreedrawConfig = (eventType: string | null | undefined) => {
|
||||
return (
|
||||
STROKE_OPTIONS[(eventType as PointerType | null) || "default"] ||
|
||||
STROKE_OPTIONS.default
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates simulated pressure based on velocity between consecutive points.
|
||||
* Fast movement (large distances) -> lower pressure
|
||||
* Slow movement (small distances) -> higher pressure
|
||||
*/
|
||||
const calculateVelocityBasedPressure = (
|
||||
points: readonly LocalPoint[],
|
||||
index: number,
|
||||
fixedStrokeWidth: boolean | undefined,
|
||||
maxDistance = 8, // Maximum expected distance for normalization
|
||||
): number => {
|
||||
if (fixedStrokeWidth) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// First point gets highest pressure
|
||||
// This avoid "a dot followed by a line" effect, •== when first stroke is "slow"
|
||||
if (index === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const [x1, y1] = points[index - 1];
|
||||
const [x2, y2] = points[index];
|
||||
|
||||
// Calculate distance between consecutive points
|
||||
const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
|
||||
// Normalize distance and invert for pressure (0 = fast/low pressure, 1 = slow/high pressure)
|
||||
const normalizedDistance = Math.min(distance / maxDistance, 1);
|
||||
const basePressure = Math.max(0.1, 1 - normalizedDistance * 0.7); // Range: 0.1 to 1.0
|
||||
|
||||
const constantPressure = 0.5;
|
||||
const pressure = constantPressure + (basePressure - constantPressure);
|
||||
|
||||
return Math.max(0.1, Math.min(1.0, pressure));
|
||||
};
|
||||
|
||||
export const getFreedrawStroke = (element: ExcalidrawFreeDrawElement) => {
|
||||
// Compose points as [x, y, pressure]
|
||||
let points: [number, number, number][];
|
||||
if (element.freedrawOptions?.fixedStrokeWidth) {
|
||||
points = element.points.map(
|
||||
([x, y]: LocalPoint): [number, number, number] => [x, y, 1],
|
||||
);
|
||||
} else if (element.simulatePressure) {
|
||||
// Simulate pressure based on velocity between consecutive points
|
||||
points = element.points.map(([x, y]: LocalPoint, i) => [
|
||||
x,
|
||||
y,
|
||||
calculateVelocityBasedPressure(
|
||||
element.points,
|
||||
i,
|
||||
element.freedrawOptions?.fixedStrokeWidth,
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
points = element.points.map(([x, y]: LocalPoint, i) => {
|
||||
const rawPressure = element.pressures?.[i] ?? 0.5;
|
||||
|
||||
const amplifiedPressure = Math.pow(rawPressure, 0.6);
|
||||
const adjustedPressure = amplifiedPressure;
|
||||
|
||||
return [x, y, clamp(adjustedPressure, 0.1, 1.0)];
|
||||
});
|
||||
}
|
||||
|
||||
const streamline =
|
||||
element.freedrawOptions?.streamline ?? STROKE_OPTIONS.default.streamline;
|
||||
const simplify =
|
||||
element.freedrawOptions?.simplify ?? STROKE_OPTIONS.default.simplify;
|
||||
|
||||
const laser = new LaserPointer({
|
||||
size: element.strokeWidth,
|
||||
streamline,
|
||||
simplify,
|
||||
sizeMapping: ({ pressure: t }) => {
|
||||
if (element.freedrawOptions?.fixedStrokeWidth) {
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
if (element.simulatePressure) {
|
||||
return 0.2 + t * 0.6;
|
||||
}
|
||||
|
||||
return 0.2 + t * 0.8;
|
||||
},
|
||||
});
|
||||
|
||||
for (const pt of points) {
|
||||
laser.addPoint(pt);
|
||||
}
|
||||
laser.close();
|
||||
|
||||
return laser.getStrokeOutline();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an SVG path for a freedraw element using LaserPointer logic.
|
||||
* Uses actual pressure data if available, otherwise simulates pressure based on velocity.
|
||||
* No streamline, smoothing, or simulation is performed.
|
||||
*/
|
||||
export const getFreeDrawSvgPath = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): string => {
|
||||
// legacy, for backwards compatibility
|
||||
if (element.freedrawOptions === null) {
|
||||
return _legacy_getFreeDrawSvgPath(element);
|
||||
}
|
||||
|
||||
return _transition_getFreeDrawSvgPath(element);
|
||||
|
||||
// return getSvgPathFromStroke(getFreedrawStroke(element));
|
||||
};
|
||||
|
||||
const roundPoint = (A: Point): string => {
|
||||
return `${round(A[0], 4, "round")},${round(A[1], 4, "round")} `;
|
||||
};
|
||||
|
||||
const average = (A: Point, B: Point): string => {
|
||||
return `${round((A[0] + B[0]) / 2, 4, "round")},${round(
|
||||
(A[1] + B[1]) / 2,
|
||||
4,
|
||||
"round",
|
||||
)} `;
|
||||
};
|
||||
|
||||
export const getSvgPathFromStroke = (points: Point[]): string => {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 2) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
|
||||
if (len === 2) {
|
||||
return `M${roundPoint(a)}L${roundPoint(b)}`;
|
||||
}
|
||||
|
||||
let result = "";
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += average(a, b);
|
||||
}
|
||||
|
||||
return `M${roundPoint(points[0])}Q${roundPoint(points[1])}${average(
|
||||
points[1],
|
||||
points[2],
|
||||
)}${points.length > 3 ? "T" : ""}${result}L${roundPoint(points[len - 1])}`;
|
||||
};
|
||||
|
||||
function _transition_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
// Consider changing the options for simulated pressure vs real pressure
|
||||
const options: StrokeOptions = {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => {
|
||||
if (element.freedrawOptions?.fixedStrokeWidth) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
return Math.sin((t * Math.PI) / 2) * 0.65;
|
||||
}, // https://easings.net/#easeOutSine
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
};
|
||||
|
||||
return _legacy_getSvgPathFromStroke(
|
||||
getStroke(inputPoints as number[][], options),
|
||||
);
|
||||
}
|
||||
|
||||
function _legacy_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
// Consider changing the options for simulated pressure vs real pressure
|
||||
const options: StrokeOptions = {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
};
|
||||
|
||||
return _legacy_getSvgPathFromStroke(
|
||||
getStroke(inputPoints as number[][], options),
|
||||
);
|
||||
}
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
const _legacy_getSvgPathFromStroke = (points: number[][]): string => {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
};
|
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
|
||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||
|
||||
import type {
|
||||
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
||||
|
||||
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,3 +1,5 @@
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
@@ -5,6 +7,7 @@ import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ElementsMapOrArray,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
@@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
/**
|
||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||
*/
|
||||
export const hashElementsVersion = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): number => {
|
||||
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
||||
for (const element of toIterable(elements)) {
|
||||
hash = (hash << 5) + hash + element.versionNonce;
|
||||
}
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
@@ -71,3 +72,46 @@ export const clearElementsForExport = (
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./collision";
|
||||
export * from "./comparisons";
|
||||
export * from "./containerCache";
|
||||
export * from "./cropElement";
|
||||
export * from "./delta";
|
||||
export * from "./distance";
|
||||
export * from "./distribute";
|
||||
export * from "./dragElements";
|
||||
export * from "./duplicate";
|
||||
export * from "./elbowArrow";
|
||||
export * from "./elementLink";
|
||||
export * from "./embeddable";
|
||||
export * from "./flowchart";
|
||||
export * from "./fractionalIndex";
|
||||
export * from "./frame";
|
||||
export * from "./freedraw";
|
||||
export * from "./groups";
|
||||
export * from "./heading";
|
||||
export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
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 "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
|
@@ -7,6 +7,8 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -18,9 +20,14 @@ import {
|
||||
getGridPoint,
|
||||
invariant,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Store } from "@excalidraw/excalidraw/store";
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
isPathALoop,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
@@ -39,6 +46,7 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
maybeSuggestBindingsForLinearElementAtCoords,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -55,19 +63,13 @@ import {
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import {
|
||||
isPathALoop,
|
||||
getBezierCurveLength,
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
} from "./shapes";
|
||||
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
@@ -82,13 +84,38 @@ import type {
|
||||
FixedPointBinding,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
points: (GlobalPoint | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
/**
|
||||
* Normalizes line points so that the start point is at [0,0]. This is
|
||||
* expected in various parts of the codebase.
|
||||
*
|
||||
* Also returns the offsets - [0,0] if no normalization needed.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
const getNormalizedPoints = ({
|
||||
points,
|
||||
}: {
|
||||
points: ExcalidrawLinearElement["points"];
|
||||
}): {
|
||||
points: LocalPoint[];
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} => {
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
|
||||
return {
|
||||
points: points.map((p) => {
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -121,6 +148,7 @@ export class LinearElementEditor {
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
public readonly customLineAngle: number | null;
|
||||
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -131,7 +159,11 @@ export class LinearElementEditor {
|
||||
};
|
||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
LinearElementEditor.normalizePoints(element, elementsMap);
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
|
||||
);
|
||||
}
|
||||
this.selectedPointsIndices = null;
|
||||
this.lastUncommittedPoint = null;
|
||||
@@ -154,6 +186,7 @@ export class LinearElementEditor {
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -244,19 +277,15 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
): Pick<AppState, keyof AppState> | null {
|
||||
if (!linearElementEditor) {
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
let customLineAngle = linearElementEditor.customLineAngle;
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
@@ -297,6 +326,12 @@ export class LinearElementEditor {
|
||||
const selectedIndex = selectedPointsIndices[0];
|
||||
const referencePoint =
|
||||
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||
customLineAngle =
|
||||
linearElementEditor.customLineAngle ??
|
||||
Math.atan2(
|
||||
element.points[selectedIndex][1] - referencePoint[1],
|
||||
element.points[selectedIndex][0] - referencePoint[0],
|
||||
);
|
||||
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
@@ -304,18 +339,25 @@ export class LinearElementEditor {
|
||||
referencePoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(element, scene, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
selectedIndex,
|
||||
{
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@@ -330,72 +372,91 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
)
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
app.scene,
|
||||
new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
)
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
return [
|
||||
pointIndex,
|
||||
{
|
||||
point: newPointPosition,
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, scene, false);
|
||||
handleBindTextResize(element, app.scene, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
let suggestedBindings: ExcalidrawBindableElement[] = [];
|
||||
if (isBindingElement(element, false)) {
|
||||
const firstSelectedIndex = selectedPointsIndices[0] === 0;
|
||||
const lastSelectedIndex =
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1] ===
|
||||
element.points.length - 1;
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
|
||||
const firstSelectedIndex = selectedPointsIndices[0];
|
||||
if (firstSelectedIndex === 0) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||
} else {
|
||||
if (firstSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const lastSelectedIndex =
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
||||
if (lastSelectedIndex === element.points.length - 1) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[lastSelectedIndex],
|
||||
elementsMap,
|
||||
if (lastSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||
],
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (coords.length) {
|
||||
maybeSuggestBinding(element, coords);
|
||||
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||
element,
|
||||
coords,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const newLinearElementEditor = {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords:
|
||||
@@ -413,6 +474,16 @@ export class LinearElementEditor {
|
||||
? lastClickedPoint
|
||||
: -1,
|
||||
isDragging: true,
|
||||
customLineAngle,
|
||||
};
|
||||
|
||||
return {
|
||||
...app.state,
|
||||
editingLinearElement: app.state.editingLinearElement
|
||||
? newLinearElementEditor
|
||||
: null,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBindings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,6 +498,7 @@ export class LinearElementEditor {
|
||||
): LinearElementEditor {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||
|
||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||
editingLinearElement;
|
||||
@@ -451,26 +523,46 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
LinearElementEditor.movePoints(element, scene, [
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
]);
|
||||
if (isLineElement(element)) {
|
||||
scene.mutateElement(
|
||||
element,
|
||||
{
|
||||
...toggleLinePolygonState(element, true),
|
||||
},
|
||||
{
|
||||
informMutation: false,
|
||||
isDragging: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
selectedPoint,
|
||||
{
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
? getHoveredElementForBinding(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
(selectedPointsIndices?.length ?? 0) > 1
|
||||
? tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
)
|
||||
: pointerCoords,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
@@ -489,6 +581,8 @@ export class LinearElementEditor {
|
||||
return {
|
||||
...editingLinearElement,
|
||||
...bindings,
|
||||
segmentMidPointHoveredCoords: null,
|
||||
hoverPointIndex: -1,
|
||||
// if clicking without previously dragging a point(s), and not holding
|
||||
// shift, deselect all points except the one clicked. If holding shift,
|
||||
// toggle the point.
|
||||
@@ -510,6 +604,7 @@ export class LinearElementEditor {
|
||||
: selectedPointsIndices,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
customLineAngle: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,7 +612,7 @@ export class LinearElementEditor {
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): typeof editorMidPointsCache["points"] => {
|
||||
): (GlobalPoint | null)[] => {
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
@@ -529,25 +624,7 @@ export class LinearElementEditor {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
editorMidPointsCache.version === element.version &&
|
||||
editorMidPointsCache.zoom === appState.zoom.value
|
||||
) {
|
||||
return editorMidPointsCache.points;
|
||||
}
|
||||
LinearElementEditor.updateEditorMidPointsCache(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
);
|
||||
return editorMidPointsCache.points!;
|
||||
};
|
||||
|
||||
static updateEditorMidPointsCache = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
@@ -571,17 +648,13 @@ export class LinearElementEditor {
|
||||
}
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
}
|
||||
editorMidPointsCache.points = midpoints;
|
||||
editorMidPointsCache.version = element.version;
|
||||
editorMidPointsCache.zoom = appState.zoom.value;
|
||||
|
||||
return midpoints;
|
||||
};
|
||||
|
||||
static getSegmentMidpointHitCoords = (
|
||||
@@ -635,8 +708,11 @@ export class LinearElementEditor {
|
||||
}
|
||||
}
|
||||
let index = 0;
|
||||
const midPoints: typeof editorMidPointsCache["points"] =
|
||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
);
|
||||
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
@@ -674,7 +750,18 @@ export class LinearElementEditor {
|
||||
|
||||
let distance = pointDistance(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
|
||||
invariant(
|
||||
lines.length === 0 && curves.length > 0,
|
||||
"Only linears built out of curves are supported",
|
||||
);
|
||||
invariant(
|
||||
lines.length + curves.length >= index,
|
||||
"Invalid segment index while calculating mid point",
|
||||
);
|
||||
|
||||
distance = curveLength<GlobalPoint>(curves[index]);
|
||||
}
|
||||
|
||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||
@@ -682,39 +769,42 @@ export class LinearElementEditor {
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: GlobalPoint,
|
||||
endPoint: GlobalPoint,
|
||||
endPointIndex: number,
|
||||
elementsMap: ElementsMap,
|
||||
index: number,
|
||||
): GlobalPoint {
|
||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const controlPoints = getControlPointsForBezierCurve(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
element.points.length >= index,
|
||||
"Invalid segment index while calculating elbow arrow mid point",
|
||||
);
|
||||
if (controlPoints) {
|
||||
const t = mapIntervalToBezierT(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
0.5,
|
||||
);
|
||||
|
||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
}
|
||||
|
||||
return segmentMidPoint;
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
|
||||
invariant(
|
||||
(lines.length === 0 && curves.length > 0) ||
|
||||
(lines.length > 0 && curves.length === 0),
|
||||
"Only linears built out of either segments or curves are supported",
|
||||
);
|
||||
invariant(
|
||||
lines.length + curves.length >= index,
|
||||
"Invalid segment index while calculating mid point",
|
||||
);
|
||||
|
||||
if (lines.length) {
|
||||
const segment = lines[index - 1];
|
||||
return pointCenter(segment[0], segment[1]);
|
||||
}
|
||||
|
||||
if (curves.length) {
|
||||
const segment = curves[index - 1];
|
||||
return curvePointAtLength(segment, 0.5);
|
||||
}
|
||||
|
||||
invariant(false, "Invalid segment type while calculating mid point");
|
||||
}
|
||||
|
||||
static getSegmentMidPointIndex(
|
||||
@@ -807,7 +897,7 @@ export class LinearElementEditor {
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
store.shouldCaptureIncrement();
|
||||
store.scheduleCapture();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
@@ -948,9 +1038,7 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app.scene, [
|
||||
points.length - 1,
|
||||
]);
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -988,14 +1076,20 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(element, app.scene, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{
|
||||
point: newPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1138,40 +1232,23 @@ export class LinearElementEditor {
|
||||
|
||||
/**
|
||||
* Normalizes line points so that the start point is at [0,0]. This is
|
||||
* expected in various parts of the codebase. Also returns new x/y to account
|
||||
* for the potential normalization.
|
||||
* expected in various parts of the codebase.
|
||||
*
|
||||
* Also returns normalized x and y coords to account for the normalization
|
||||
* of the points.
|
||||
*/
|
||||
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const { points } = element;
|
||||
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
|
||||
const { points, offsetX, offsetY } = getNormalizedPoints(element);
|
||||
|
||||
return {
|
||||
points: points.map((p) => {
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
points,
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
// element-mutating methods
|
||||
// ---------------------------------------------------------------------------
|
||||
static normalizePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
LinearElementEditor.getNormalizedPoints(element),
|
||||
);
|
||||
}
|
||||
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
@@ -1227,12 +1304,16 @@ export class LinearElementEditor {
|
||||
// potentially expanding the bounding box
|
||||
if (pointAddedToEnd) {
|
||||
const lastPoint = element.points[element.points.length - 1];
|
||||
LinearElementEditor.movePoints(element, scene, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1246,41 +1327,42 @@ export class LinearElementEditor {
|
||||
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
app: AppClassProperties,
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
const isUncommittedPoint =
|
||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
|
||||
const isDeletingOriginPoint = pointIndices.includes(0);
|
||||
const nextPoints = element.points.filter((_, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
});
|
||||
|
||||
// if deleting first point, make the next to be [0,0] and recalculate
|
||||
// positions of the rest with respect to it
|
||||
if (isDeletingOriginPoint) {
|
||||
const firstNonDeletedPoint = element.points.find((point, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
});
|
||||
if (firstNonDeletedPoint) {
|
||||
offsetX = firstNonDeletedPoint[0];
|
||||
offsetY = firstNonDeletedPoint[1];
|
||||
}
|
||||
const isPolygon = isLineElement(element) && element.polygon;
|
||||
|
||||
// keep polygon intact if deleting start/end point or uncommitted point
|
||||
if (
|
||||
isPolygon &&
|
||||
(isUncommittedPoint ||
|
||||
pointIndices.includes(0) ||
|
||||
pointIndices.includes(element.points.length - 1))
|
||||
) {
|
||||
nextPoints[0] = pointFrom(
|
||||
nextPoints[nextPoints.length - 1][0],
|
||||
nextPoints[nextPoints.length - 1][1],
|
||||
);
|
||||
}
|
||||
|
||||
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
||||
if (!pointIndices.includes(idx)) {
|
||||
acc.push(
|
||||
!acc.length
|
||||
? pointFrom(0, 0)
|
||||
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const {
|
||||
points: normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
} = getNormalizedPoints({ points: nextPoints });
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
nextPoints,
|
||||
app.scene,
|
||||
normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -1289,16 +1371,27 @@ export class LinearElementEditor {
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
addedPoints: LocalPoint[],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
const nextPoints = [...element.points, ...addedPoints];
|
||||
|
||||
if (isLineElement(element) && element.polygon) {
|
||||
nextPoints[0] = pointFrom(
|
||||
nextPoints[nextPoints.length - 1][0],
|
||||
nextPoints[nextPoints.length - 1][1],
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
points: normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
} = getNormalizedPoints({ points: nextPoints });
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
nextPoints,
|
||||
normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -1307,7 +1400,7 @@ export class LinearElementEditor {
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||
pointUpdates: PointsPositionUpdates,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
@@ -1315,27 +1408,46 @@ export class LinearElementEditor {
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
// if polygon, move start and end points together
|
||||
if (isLineElement(element) && element.polygon) {
|
||||
const firstPointUpdate = pointUpdates.get(0);
|
||||
const lastPointUpdate = pointUpdates.get(points.length - 1);
|
||||
|
||||
if (firstPointUpdate) {
|
||||
pointUpdates.set(points.length - 1, {
|
||||
point: pointFrom(
|
||||
firstPointUpdate.point[0],
|
||||
firstPointUpdate.point[1],
|
||||
),
|
||||
isDragging: firstPointUpdate.isDragging,
|
||||
});
|
||||
} else if (lastPointUpdate) {
|
||||
pointUpdates.set(0, {
|
||||
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
|
||||
isDragging: lastPointUpdate.isDragging,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// in case we're moving start point, instead of modifying its position
|
||||
// which would break the invariant of it being at [0,0], we move
|
||||
// all the other points in the opposite direction by delta to
|
||||
// offset it. We do the same with actual element.x/y position, so
|
||||
// this hacks are completely transparent to the user.
|
||||
const [deltaX, deltaY] =
|
||||
targetPoints.find(({ index }) => index === 0)?.point ??
|
||||
pointFrom<LocalPoint>(0, 0);
|
||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||
deltaX - points[0][0],
|
||||
deltaY - points[0][1],
|
||||
);
|
||||
|
||||
const updatedOriginPoint =
|
||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||
|
||||
const [offsetX, offsetY] = updatedOriginPoint;
|
||||
|
||||
const nextPoints = isElbowArrow(element)
|
||||
? [
|
||||
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
||||
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
||||
pointUpdates.get(0)?.point ?? points[0],
|
||||
pointUpdates.get(points.length - 1)?.point ??
|
||||
points[points.length - 1],
|
||||
]
|
||||
: points.map((p, idx) => {
|
||||
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
||||
const current = pointUpdates.get(idx)?.point ?? p;
|
||||
|
||||
return pointFrom<LocalPoint>(
|
||||
current[0] - offsetX,
|
||||
@@ -1351,11 +1463,7 @@ export class LinearElementEditor {
|
||||
offsetY,
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: targetPoints.reduce(
|
||||
(dragging, targetPoint): boolean =>
|
||||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1500,6 +1608,7 @@ export class LinearElementEditor {
|
||||
isDragging: options?.isDragging ?? false,
|
||||
});
|
||||
} else {
|
||||
// TODO do we need to get precise coords here just to calc centers?
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
||||
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
||||
@@ -1508,7 +1617,7 @@ export class LinearElementEditor {
|
||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
const dY = prevCenterY - nextCenterY;
|
||||
const rotated = pointRotateRads(
|
||||
const rotatedOffset = pointRotateRads(
|
||||
pointFrom(offsetX, offsetY),
|
||||
pointFrom(dX, dY),
|
||||
element.angle,
|
||||
@@ -1516,8 +1625,8 @@ export class LinearElementEditor {
|
||||
scene.mutateElement(element, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
x: element.x + rotated[0],
|
||||
y: element.y + rotated[1],
|
||||
x: element.x + rotatedOffset[0],
|
||||
y: element.y + rotatedOffset[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1528,6 +1637,7 @@ export class LinearElementEditor {
|
||||
referencePoint: LocalPoint,
|
||||
scenePointer: GlobalPoint,
|
||||
gridSize: NullableGridSize,
|
||||
customLineAngle?: number,
|
||||
) {
|
||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
@@ -1553,6 +1663,7 @@ export class LinearElementEditor {
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
return pointRotateRads(
|
||||
@@ -1587,23 +1698,11 @@ export class LinearElementEditor {
|
||||
y = midPoint[1] - boundTextElement.height / 2;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
);
|
||||
|
||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
||||
if (element.points.length === 2) {
|
||||
midSegmentMidpoint = pointCenter(points[0], points[1]);
|
||||
}
|
||||
if (
|
||||
!midSegmentMidpoint ||
|
||||
editorMidPointsCache.version !== element.version
|
||||
) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "version" | "versionNonce" | "updated"
|
||||
"id" | "updated"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.version = updates.version ?? element.version + 1;
|
||||
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
return element;
|
||||
@@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
version: updates.version ?? element.version + 1,
|
||||
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||
updated: getUpdatedTimestamp(),
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
@@ -45,6 +47,7 @@ import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -442,6 +445,7 @@ export const newFreeDrawElement = (
|
||||
points?: ExcalidrawFreeDrawElement["points"];
|
||||
simulatePressure: boolean;
|
||||
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||
strokeOptions?: ExcalidrawFreeDrawElement["freedrawOptions"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
return {
|
||||
@@ -450,6 +454,11 @@ export const newFreeDrawElement = (
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
freedrawOptions: opts.strokeOptions || {
|
||||
fixedStrokeWidth: true,
|
||||
streamline: 0.25,
|
||||
simplify: 0.1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -457,9 +466,10 @@ export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
polygon?: ExcalidrawLineElement["polygon"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
return {
|
||||
const element = {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
@@ -468,6 +478,17 @@ export const newLinearElement = (
|
||||
startArrowhead: 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>(
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import { isRightAngleRads } from "@excalidraw/math";
|
||||
|
||||
@@ -54,9 +53,11 @@ import {
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { getFreeDrawSvgPath } from "./freedraw";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -70,7 +71,6 @@ import type {
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
@@ -351,12 +351,20 @@ const generateElementCanvas = (
|
||||
|
||||
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(
|
||||
`<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(
|
||||
`<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>`,
|
||||
)}`;
|
||||
@@ -1024,57 +1032,3 @@ export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
||||
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
||||
return pathsCache.get(element);
|
||||
}
|
||||
|
||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
// Consider changing the options for simulated pressure vs real pressure
|
||||
const options: StrokeOptions = {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
};
|
||||
|
||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
||||
}
|
||||
|
||||
function med(A: number[], B: number[]) {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
}
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
function getSvgPathFromStroke(points: number[][]): string {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@ import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
@@ -57,7 +56,7 @@ import {
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type {
|
||||
@@ -104,18 +103,6 @@ export const transformElements = (
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
element,
|
||||
scene,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
return true;
|
||||
} else if (transformHandleType) {
|
||||
const elementId = selectedElements[0].id;
|
||||
const latestElement = elementsMap.get(elementId);
|
||||
@@ -150,6 +137,9 @@ export const transformElements = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTextElement(element)) {
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
return true;
|
||||
} else if (selectedElements.length > 1) {
|
||||
if (transformHandleType === "rotation") {
|
||||
@@ -282,151 +272,50 @@ export const measureFontSizeFromWidth = (
|
||||
};
|
||||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
export const resizeSingleTextElement = (
|
||||
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
scene: Scene,
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
) => {
|
||||
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") {
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("n")) {
|
||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||
}
|
||||
if (transformHandleType.includes("s")) {
|
||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||
}
|
||||
const metricsWidth = element.width * (nextHeight / element.height);
|
||||
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTopLeft = [x1, y1];
|
||||
const startBottomRight = [x2, y2];
|
||||
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 newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
metricsWidth,
|
||||
nextHeight,
|
||||
origElement.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
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, {
|
||||
fontSize: metrics.size,
|
||||
width: nextWidth,
|
||||
width: metricsWidth,
|
||||
height: nextHeight,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
getFontString({
|
||||
fontSize: element.fontSize,
|
||||
@@ -435,17 +324,7 @@ const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
|
||||
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 newWidth = Math.max(minWidth, nextWidth);
|
||||
|
||||
const text = wrapText(
|
||||
element.originalText,
|
||||
@@ -458,49 +337,27 @@ const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const eleNewHeight = metrics.height;
|
||||
const newHeight = metrics.height;
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
newWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startTopLeft[1],
|
||||
];
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
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 newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
newWidth,
|
||||
newHeight,
|
||||
element.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
|
||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
text,
|
||||
autoResize: false,
|
||||
};
|
||||
@@ -821,6 +678,18 @@ export const resizeSingleElement = (
|
||||
shouldInformMutation?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||
return resizeSingleTextElement(
|
||||
origElement,
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldResizeFromCenter,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
@@ -962,11 +831,6 @@ export const resizeSingleElement = (
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
updateBoundElements(latestElement, scene, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
@@ -978,6 +842,11 @@ export const resizeSingleElement = (
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1518,11 +1387,7 @@ export const resizeMultipleElements = (
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
scene.mutateElement(element, update, {
|
||||
informMutation: true,
|
||||
// needed for the fixed binding point udpate to take effect
|
||||
isDragging: true,
|
||||
});
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
|
@@ -169,25 +169,6 @@ export const isSomeElementSelected = (function () {
|
||||
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 = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||
|
@@ -1,26 +1,66 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
import {
|
||||
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,
|
||||
STROKE_WIDTH,
|
||||
} 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 { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
||||
import {
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { shouldTestInside } from "./collision";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -28,12 +68,89 @@ import type {
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
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 getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
@@ -86,7 +203,7 @@ export const generateRoughOptions = (
|
||||
// hachureGap because if not specified, roughjs uses strokeWidth to
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
hachureGap: Math.min(element.strokeWidth, STROKE_WIDTH.bold) * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices:
|
||||
@@ -303,6 +420,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.
|
||||
*
|
||||
@@ -310,7 +603,7 @@ const getArrowheadShapes = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const _generateElementShape = (
|
||||
const generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
@@ -514,15 +807,21 @@ export const _generateElementShape = (
|
||||
generateFreeDrawShape(element);
|
||||
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
const points =
|
||||
element.freedrawOptions === null
|
||||
? simplify(element.points as LocalPoint[], 0.75)
|
||||
: simplify(element.points as LocalPoint[], 1.5);
|
||||
|
||||
shape =
|
||||
element.freedrawOptions === null
|
||||
? generator.curve(points, {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
})
|
||||
: generator.polygon(points, {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
}
|
||||
@@ -611,3 +910,103 @@ const generateElbowArrowShape = (
|
||||
|
||||
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,407 +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
|
||||
);
|
||||
};
|
||||
|
||||
export const aabbForPoints = <Point extends GlobalPoint | LocalPoint>(
|
||||
points: Point[],
|
||||
): Bounds => [
|
||||
Math.min(...points.map((point) => point[0])),
|
||||
Math.min(...points.map((point) => point[1])),
|
||||
Math.max(...points.map((point) => point[0])),
|
||||
Math.max(...points.map((point) => point[1])),
|
||||
];
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
@@ -2,14 +2,28 @@ import {
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
normalizeRadians,
|
||||
radiansBetweenAngles,
|
||||
radiansDifference,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||
// - could also be part of `_clearElements`
|
||||
@@ -17,8 +31,18 @@ export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
return element.points.length < 2;
|
||||
return (
|
||||
element.points.length < 2 ||
|
||||
(element.points.length === 2 &&
|
||||
isArrowElement(element) &&
|
||||
pointsEqual(
|
||||
element.points[0],
|
||||
element.points[element.points.length - 1],
|
||||
INVISIBLY_SMALL_ELEMENT_SIZE,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
@@ -134,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
customAngle?: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
const angle = Math.atan2(height, width) as Radians;
|
||||
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE) as Radians;
|
||||
|
||||
if (customAngle) {
|
||||
// If custom angle is provided, we check if the angle is close to the
|
||||
// custom angle, snap to that if close engough, otherwise snap to the
|
||||
// higher or lower angle depending on the current angle vs custom angle.
|
||||
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE) as Radians;
|
||||
if (
|
||||
radiansBetweenAngles(
|
||||
angle,
|
||||
lower,
|
||||
(lower + SHIFT_LOCKING_ANGLE) as Radians,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
radiansDifference(angle, customAngle as Radians) <
|
||||
SHIFT_LOCKING_ANGLE / 6
|
||||
) {
|
||||
lockedAngle = customAngle as Radians;
|
||||
} else if (
|
||||
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
|
||||
) {
|
||||
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
|
||||
} else {
|
||||
lockedAngle = lower;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
|
1014
packages/element/src/store.ts
Normal file
1014
packages/element/src/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
@@ -326,10 +326,7 @@ export const getContainerCenter = (
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||
@@ -25,9 +27,11 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
@@ -107,6 +111,12 @@ export const isLinearElement = (
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isLineElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLineElement => {
|
||||
return element != null && element.type === "line";
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
@@ -119,6 +129,15 @@ export const isElbowArrow = (
|
||||
return isArrowElement(element) && element.elbowed;
|
||||
};
|
||||
|
||||
/**
|
||||
* sharp or curved arrow, but not elbow
|
||||
*/
|
||||
export const isSimpleArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return isArrowElement(element) && !element.elbowed;
|
||||
};
|
||||
|
||||
export const isSharpArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
@@ -356,3 +375,41 @@ export const isBounds = (box: unknown): box is Bounds =>
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
|
||||
export const getLinearElementSubType = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): ExcalidrawLinearElementSubType => {
|
||||
if (isSharpArrow(element)) {
|
||||
return "sharpArrow";
|
||||
}
|
||||
if (isCurvedArrow(element)) {
|
||||
return "curvedArrow";
|
||||
}
|
||||
if (isElbowArrow(element)) {
|
||||
return "elbowArrow";
|
||||
}
|
||||
return "line";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if current element points meet all the conditions for polygon=true
|
||||
* (this isn't a element type check, for that use isLineElement).
|
||||
*
|
||||
* If you want to check if points *can* be turned into a polygon, use
|
||||
* canBecomePolygon(points).
|
||||
*/
|
||||
export const isValidPolygon = (
|
||||
points: ExcalidrawLineElement["points"],
|
||||
): boolean => {
|
||||
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
|
||||
};
|
||||
|
||||
export const canBecomePolygon = (
|
||||
points: ExcalidrawLineElement["points"],
|
||||
): boolean => {
|
||||
return (
|
||||
points.length > 3 ||
|
||||
// 3-point polygons can't have all points in a single line
|
||||
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||
);
|
||||
};
|
||||
|
@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawSelectionElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
@@ -296,6 +297,13 @@ export type FixedPointBinding = Merge<
|
||||
}
|
||||
>;
|
||||
|
||||
type Index = number;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
Index,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
@@ -321,10 +329,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "line";
|
||||
polygon: boolean;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: number;
|
||||
index: Index;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
@@ -366,6 +380,11 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
freedrawOptions: {
|
||||
streamline?: number;
|
||||
simplify?: number;
|
||||
fixedStrokeWidth?: boolean;
|
||||
} | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
@@ -413,10 +432,12 @@ export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
||||
|
||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||
export type ConvertibleLinearTypes =
|
||||
export type ExcalidrawLinearElementSubType =
|
||||
| "line"
|
||||
| "sharpArrow"
|
||||
| "curvedArrow"
|
||||
| "elbowArrow";
|
||||
|
||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
|
||||
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
||||
|
@@ -1,259 +1,346 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointFromArray,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
||||
>();
|
||||
|
||||
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
offset: number,
|
||||
): ElementShape | undefined => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.delete(element);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return shapes.get(offset);
|
||||
};
|
||||
|
||||
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: ElementShape,
|
||||
offset: number,
|
||||
) => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
shapes.set(offset, shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||
*
|
||||
* @param element The linear element to deconstruct
|
||||
* @returns The rotated in components.
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
const prevPoint =
|
||||
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
||||
switch (op.op) {
|
||||
case "move":
|
||||
continue;
|
||||
case "lineTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
case "bcurveTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
curves.push(
|
||||
curve<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
default: {
|
||||
console.error("Unknown op type", op.op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves.
|
||||
* line segments and curves **unrotated**.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const roundness = getCornerRadius(
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (roundness <= 0) {
|
||||
const r = rectangle(
|
||||
pointFrom(element.x - offset, element.y - offset),
|
||||
pointFrom(
|
||||
element.x + element.width + offset,
|
||||
element.y + element.height + offset,
|
||||
),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
const sides = [top, right, bottom, left];
|
||||
|
||||
return [sides, []];
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
}
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const r = rectangle(
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width, element.y + element.height),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||
);
|
||||
|
||||
const offsets = [
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), // TOP LEFT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), //TOP RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners = [
|
||||
const baseCorners = [
|
||||
curve(
|
||||
pointFromVector(offsets[0], left[1]),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
left[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
pointFromVector(offsets[0], top[0]),
|
||||
top[0],
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
pointFromVector(offsets[1], top[1]),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
),
|
||||
top[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
pointFromVector(offsets[1], right[0]),
|
||||
right[0],
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[2], right[1]),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
right[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
pointFromVector(offsets[2], bottom[1]),
|
||||
bottom[1],
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[3], bottom[0]),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
bottom[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
pointFromVector(offsets[3], left[0]),
|
||||
left[0],
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
];
|
||||
const shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a diamond element in the form of
|
||||
* line segments and curves as a tuple, in this order.
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (element.roundness?.type == null) {
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY - offset),
|
||||
pointFrom(element.x + rightX + offset, element.y + rightY),
|
||||
pointFrom(element.x + bottomX, element.y + bottomY + offset),
|
||||
pointFrom(element.x + leftX - offset, element.y + leftY),
|
||||
];
|
||||
|
||||
// Create the line segment parts of the diamond
|
||||
// NOTE: Horizontal and vertical seems to be flipped here
|
||||
const topRight = lineSegment<GlobalPoint>(
|
||||
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
|
||||
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
|
||||
);
|
||||
const bottomRight = lineSegment<GlobalPoint>(
|
||||
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
|
||||
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
|
||||
);
|
||||
const bottomLeft = lineSegment<GlobalPoint>(
|
||||
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
|
||||
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
|
||||
);
|
||||
const topLeft = lineSegment<GlobalPoint>(
|
||||
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
|
||||
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
|
||||
);
|
||||
|
||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||
: (topX - leftX) * 0.01;
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
@@ -262,94 +349,135 @@ export function deconstructDiamondElement(
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const offsets = [
|
||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
||||
];
|
||||
|
||||
const corners = [
|
||||
const baseCorners = [
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
right,
|
||||
right,
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
), // RIGHT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
bottom,
|
||||
bottom,
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
), // BOTTOM
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
left,
|
||||
left,
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
), // LEFT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
top,
|
||||
top,
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
const shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
/** supply if you want the loop detection to account for current zoom */
|
||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [first, last] = [points[0], points[points.length - 1]];
|
||||
const distance = pointDistance(first, last);
|
||||
|
||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||
// really close we make the threshold smaller, and vice versa.
|
||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||
element.roundness?.type === ROUNDNESS.LEGACY
|
||||
) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||
|
||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||
|
||||
if (x <= CUTOFF_SIZE) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
return fixedRadiusSize;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
@@ -10,7 +10,7 @@ import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
|
||||
|
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
@@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -202,6 +204,7 @@ describe("aligning", () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@@ -215,6 +218,7 @@ describe("aligning", () => {
|
||||
// Add the created group to the current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -316,6 +320,7 @@ describe("aligning", () => {
|
||||
// The second rectangle is already selected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@@ -330,7 +335,7 @@ describe("aligning", () => {
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
mouse.restorePosition(200, 200);
|
||||
mouse.restorePosition(210, 200);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@@ -341,6 +346,7 @@ describe("aligning", () => {
|
||||
// The second group is already selected because it was the last group created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -454,6 +460,7 @@ describe("aligning", () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@@ -466,7 +473,7 @@ describe("aligning", () => {
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
mouse.restorePosition(10, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@@ -482,6 +489,7 @@ describe("aligning", () => {
|
||||
// Select the nested group, the rectangle is already selected
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -581,4 +589,424 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||
});
|
||||
|
||||
const createGroupAndSelectInEditGroupMode = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
mouse.reset();
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.doubleClick();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns elements within a group while in group edit mode correctly to the top", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the left", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the right", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
});
|
||||
|
||||
const createNestedGroupAndSelectInEditGroupMode = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(200, 200);
|
||||
// create third element
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// third element is already selected, select the initial group and group together
|
||||
mouse.reset();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
// double click to enter edit mode
|
||||
mouse.doubleClick();
|
||||
|
||||
// select nested group and other element within the group
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(200, 200);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns element and nested group while in group edit mode correctly to the top", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the left", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the right", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
|
||||
const createAndSelectSingleGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
};
|
||||
|
||||
it("aligns elements within a single-selected group correctly to the top", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the bottom", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the left", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the right", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the vertical center", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
});
|
||||
|
||||
const createAndSelectSingleGroupWithNestedGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(10, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create the nested group
|
||||
API.executeAction(actionGroup);
|
||||
};
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
});
|
||||
|
@@ -11,6 +11,10 @@ import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { getTransformHandles } from "../src/transformHandles";
|
||||
import {
|
||||
getTextEditor,
|
||||
TEXT_EDITOR_SELECTOR,
|
||||
} from "../../excalidraw/tests/queries/dom";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -172,12 +176,12 @@ describe("element binding", () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
size: 49,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.downAt(49, 49);
|
||||
mouse.moveTo(51, 0);
|
||||
mouse.up(0, 0);
|
||||
|
||||
@@ -244,18 +248,12 @@ describe("element binding", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
const editor = await getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
@@ -285,18 +283,14 @@ describe("element binding", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = await getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
|
||||
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
});
|
||||
|
||||
|
38
packages/element/tests/collision.test.tsx
Normal file
38
packages/element/tests/collision.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { hitElementItself } from "../src/collision";
|
||||
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("arrow", () => {
|
||||
UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 124,
|
||||
height: 302,
|
||||
angle: 1.8700426423973724,
|
||||
points: [
|
||||
[0, 0],
|
||||
[120, -198],
|
||||
[-4, -302],
|
||||
] as LocalPoint[],
|
||||
});
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
threshold: 10,
|
||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||
});
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
@@ -3,21 +3,30 @@ import { vi } from "vitest";
|
||||
|
||||
import { KEYS, cloneJSON } from "@excalidraw/common";
|
||||
|
||||
import { duplicateElement } from "@excalidraw/element/duplicate";
|
||||
import {
|
||||
Excalidraw,
|
||||
exportToCanvas,
|
||||
exportToSvg,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import {
|
||||
actionFlipHorizontal,
|
||||
actionFlipVertical,
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import type {
|
||||
ExcalidrawImageElement,
|
||||
ImageCrop,
|
||||
} from "@excalidraw/element/types";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
GlobalTestState,
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { Excalidraw, exportToCanvas, exportToSvg } from "..";
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
||||
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { act, GlobalTestState, render, unmountComponent } from "./test-utils";
|
||||
import { duplicateElement } from "../src/duplicate";
|
||||
|
||||
import type { NormalizedZoomValue } from "../types";
|
||||
import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
149
packages/element/tests/delta.test.tsx
Normal file
149
packages/element/tests/delta.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
|
||||
it("should maintain stable order for selectedElementIds", () => {
|
||||
const commonAppState = {
|
||||
name: "",
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: { id5: true, id2: true, id4: true },
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: {
|
||||
id1: true,
|
||||
id2: true,
|
||||
id3: true,
|
||||
},
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: { id4: true, id2: true, id5: true },
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: {
|
||||
id3: true,
|
||||
id2: true,
|
||||
id1: true,
|
||||
},
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
|
||||
it("should maintain stable order for selectedGroupIds", () => {
|
||||
const commonAppState = {
|
||||
name: "",
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: {
|
||||
id0: true,
|
||||
id1: true,
|
||||
id2: false,
|
||||
id3: true,
|
||||
},
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: {
|
||||
id3: true,
|
||||
id2: false,
|
||||
id1: true,
|
||||
id0: true,
|
||||
},
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
});
|
@@ -505,8 +505,6 @@ describe("group-related duplication", () => {
|
||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||
});
|
||||
|
||||
// console.log(h.elements);
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle1.id, frameId: frame.id },
|
||||
|
@@ -22,7 +22,7 @@ import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { bindLinearElement } from "../src/binding";
|
||||
|
||||
import Scene from "../src/Scene";
|
||||
import { Scene } from "../src/Scene";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
@@ -195,7 +195,7 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
scene.mutateElement(arrow, {
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||
});
|
||||
|
||||
@@ -295,11 +295,11 @@ describe("elbow arrow ui", () => {
|
||||
) as HTMLInputElement;
|
||||
UI.updateInput(inputAngle, String("40"));
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[34.7791, 0],
|
||||
[34.7791, 164.67],
|
||||
[102.931, 164.67],
|
||||
[35, 0],
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
|
153
packages/element/tests/embeddable.test.ts
Normal file
153
packages/element/tests/embeddable.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { getEmbedLink } from "../src/embeddable";
|
||||
|
||||
describe("YouTube timestamp parsing", () => {
|
||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
||||
expectedStart: 90,
|
||||
},
|
||||
{
|
||||
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
||||
expectedStart: 120,
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
||||
expectedStart: 150,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse YouTube URLs with timestamp in time format", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
||||
expectedStart: 90, // 1*60 + 30
|
||||
},
|
||||
{
|
||||
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
||||
expectedStart: 165, // 2*60 + 45
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
||||
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
||||
expectedStart: 45,
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
||||
expectedStart: 300, // 5*60
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
||||
expectedStart: 7200, // 2*3600
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle YouTube URLs without timestamps", () => {
|
||||
const testCases = [
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
];
|
||||
|
||||
testCases.forEach((url) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).not.toContain("start=");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle YouTube shorts URLs with timestamps", () => {
|
||||
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=30");
|
||||
}
|
||||
// Shorts should have portrait aspect ratio
|
||||
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
||||
});
|
||||
|
||||
it("should handle playlist URLs with timestamps", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=60");
|
||||
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle malformed or edge case timestamps", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
||||
expectedStart: 0, // Invalid timestamp should default to 0
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
||||
expectedStart: 0, // Empty timestamp should default to 0
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
||||
expectedStart: 0, // Zero timestamp should be handled
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
if (expectedStart === 0) {
|
||||
expect(result.link).not.toContain("start=");
|
||||
} else {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve other URL parameters", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=90");
|
||||
expect(result.link).toContain("enablejsapi=1");
|
||||
}
|
||||
});
|
||||
});
|
@@ -7,9 +7,9 @@ import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
|
@@ -1,8 +1,5 @@
|
||||
import { newArrowElement } from "@excalidraw/element/newElement";
|
||||
|
||||
import { pointCenter, pointFrom } from "@excalidraw/math";
|
||||
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -13,36 +10,39 @@ import {
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import {
|
||||
getBoundTextElementPosition,
|
||||
getBoundTextMaxWidth,
|
||||
} from "@excalidraw/element/textElement";
|
||||
import * as textElementUtils from "@excalidraw/element/textElement";
|
||||
import { wrapText } from "@excalidraw/element/textWrapping";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import * as InteractiveCanvas from "@excalidraw/excalidraw/renderer/interactiveScene";
|
||||
import * as StaticScene from "@excalidraw/excalidraw/renderer/staticScene";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import * as InteractiveCanvas from "../renderer/interactiveScene";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
import { API } from "../tests/helpers/api";
|
||||
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
screen,
|
||||
render,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
unmountComponent,
|
||||
} from "./test-utils";
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { wrapText } from "../src";
|
||||
import * as textElementUtils from "../src/textElement";
|
||||
import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
|
||||
import { LinearElementEditor } from "../src";
|
||||
import { newArrowElement } from "../src";
|
||||
|
||||
import {
|
||||
getTextEditor,
|
||||
TEXT_EDITOR_SELECTOR,
|
||||
} from "../../excalidraw/tests/queries/dom";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
} from "../src/types";
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(
|
||||
InteractiveCanvas,
|
||||
@@ -256,7 +256,49 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor when using double clicked with ctrl key", () => {
|
||||
it("should enter line editor via enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
// ctrl+enter alias (to align with arrows)
|
||||
it("should enter line editor via ctrl+enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
@@ -266,6 +308,37 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement).toEqual(null);
|
||||
await getTextEditor();
|
||||
});
|
||||
|
||||
it("shouldn't create text element on double click in line editor (arrow)", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
enterLineEditingMode(arrow);
|
||||
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
});
|
||||
|
||||
describe("Inside editor", () => {
|
||||
it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
@@ -350,12 +423,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -415,12 +488,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"105.96978",
|
||||
"67.44233",
|
||||
"104.27552",
|
||||
"66.16120",
|
||||
],
|
||||
[
|
||||
"126.08587",
|
||||
"63.29417",
|
||||
"126.95494",
|
||||
"64.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -731,12 +804,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"31.88408",
|
||||
"23.13276",
|
||||
"29.28349",
|
||||
"20.91105",
|
||||
],
|
||||
[
|
||||
"77.74793",
|
||||
"44.57841",
|
||||
"78.86048",
|
||||
"46.12277",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -820,12 +893,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -987,19 +1060,17 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": "85.82202",
|
||||
"y": "75.63461",
|
||||
"x": "86.17305",
|
||||
"y": "76.11251",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should match styles for text editor", () => {
|
||||
it("should match styles for text editor", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = await getTextEditor();
|
||||
expect(editor).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1016,9 +1087,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(arrow.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = await getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: DEFAULT_TEXT },
|
||||
@@ -1046,9 +1115,7 @@ describe("Test Linear Elements", () => {
|
||||
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(textElement.type).toBe("text");
|
||||
expect(textElement.containerId).toBe(arrow.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = await getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: DEFAULT_TEXT },
|
||||
@@ -1067,13 +1134,7 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
mouse.doubleClickAt(line.x, line.y);
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBeNull();
|
||||
expect(line.boundElements).toBeNull();
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
// TODO fix #7029 and rewrite this test
|
||||
@@ -1238,9 +1299,7 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = await getTextEditor();
|
||||
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
@@ -1266,7 +1325,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.downAt(rect.x, rect.y);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(204, 0);
|
||||
expect(arrow.width).toBeCloseTo(200, 0);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
@@ -1384,19 +1443,30 @@ describe("Test Linear Elements", () => {
|
||||
const [origStartX, origStartY] = [line.x, line.y];
|
||||
|
||||
act(() => {
|
||||
LinearElementEditor.movePoints(line, h.app.scene, [
|
||||
{
|
||||
index: 0,
|
||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
||||
},
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: pointFrom(
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
),
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
line,
|
||||
h.app.scene,
|
||||
new Map([
|
||||
[
|
||||
0,
|
||||
{
|
||||
point: pointFrom(
|
||||
line.points[0][0] + 10,
|
||||
line.points[0][1] + 10,
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
line.points.length - 1,
|
||||
{
|
||||
point: pointFrom(
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
expect(line.x).toBe(origStartX + 10);
|
||||
expect(line.y).toBe(origStartY + 10);
|
||||
@@ -1404,5 +1474,55 @@ describe("Test Linear Elements", () => {
|
||||
expect(line.points[line.points.length - 1][0]).toBe(20);
|
||||
expect(line.points[line.points.length - 1][1]).toBe(-20);
|
||||
});
|
||||
|
||||
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||
enterLineEditingMode(line);
|
||||
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Calculate original angle between first and last point
|
||||
const originalAngle = Math.atan2(
|
||||
points[1][1] - points[0][1],
|
||||
points[1][0] - points[0][0],
|
||||
);
|
||||
|
||||
// Drag the second point (endpoint) with SHIFT key pressed
|
||||
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
|
||||
const endPoint = pointFrom<GlobalPoint>(
|
||||
startPoint[0] + 4,
|
||||
startPoint[1] + 4,
|
||||
);
|
||||
|
||||
// Perform drag with SHIFT key modifier
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.downAt(startPoint[0], startPoint[1]);
|
||||
mouse.moveTo(endPoint[0], endPoint[1]);
|
||||
mouse.upAt(endPoint[0], endPoint[1]);
|
||||
});
|
||||
|
||||
// Get updated points after drag
|
||||
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Calculate new angle
|
||||
const newAngle = Math.atan2(
|
||||
updatedPoints[1][1] - updatedPoints[0][1],
|
||||
updatedPoints[1][0] - updatedPoints[0][0],
|
||||
);
|
||||
|
||||
// The angle should be preserved (within a small tolerance for floating point precision)
|
||||
const angleDifference = Math.abs(newAngle - originalAngle);
|
||||
const tolerance = 0.01; // Small tolerance for floating point precision
|
||||
|
||||
expect(angleDifference).toBeLessThan(tolerance);
|
||||
});
|
||||
});
|
||||
});
|
@@ -819,7 +819,7 @@ describe("image element", () => {
|
||||
|
||||
UI.resize(image, "ne", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
|
||||
const imageWidth = image.width;
|
||||
const scale = 20 / image.height;
|
||||
@@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import * as constants from "@excalidraw/common";
|
||||
|
||||
import { getPerfectElementSize } from "../src/sizeHelpers";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
@@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
|
||||
import { normalizeElementOrder } from "../src/sortElements";
|
||||
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -1,16 +1,20 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { alignElements } from "@excalidraw/element/align";
|
||||
import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element/align";
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
@@ -25,7 +29,6 @@ import {
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -37,7 +40,11 @@ export const alignActionsPredicate = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -51,7 +58,12 @@ const alignSelectedElements = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||
const updatedElements = alignElements(
|
||||
selectedElements,
|
||||
alignment,
|
||||
app.scene,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
|
@@ -10,14 +10,14 @@ import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "@excalidraw/element/containerCache";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element/textElement";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@@ -25,13 +25,15 @@ import {
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { newElement } from "@excalidraw/element/newElement";
|
||||
import { newElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
@@ -14,8 +14,10 @@ import {
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@@ -44,7 +46,6 @@ import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||
import { getTextFromElements } from "@excalidraw/element/textElement";
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
import { getTextFromElements } from "@excalidraw/element";
|
||||
|
||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
@@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -1,27 +1,28 @@
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { getContainerElement } from "@excalidraw/element/textElement";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getContainerElement } from "@excalidraw/element";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getElementsInGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element/groups";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
@@ -257,11 +258,7 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
app.scene,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
@@ -1,16 +1,20 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { distributeElements } from "@excalidraw/element/distribute";
|
||||
import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element/distribute";
|
||||
import type { Distribution } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
@@ -21,7 +25,6 @@ import {
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -30,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 2 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -48,6 +55,7 @@ const distributeSelectedElements = (
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
distribution,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
@@ -7,23 +7,24 @@ import {
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getSelectedElements,
|
||||
getSelectionStateForElements,
|
||||
} from "@excalidraw/element/selection";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||
import { duplicateElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -2,13 +2,14 @@ import {
|
||||
canCreateLinkFromElements,
|
||||
defaultGetElementLinkFromSelection,
|
||||
getLinkIdAndTypeFromSelection,
|
||||
} from "@excalidraw/element/elementLink";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -1,18 +1,23 @@
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
elementsAreInSameGroup,
|
||||
newElementWith,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
|
||||
@@ -23,15 +28,10 @@ export const actionToggleElementLock = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
? "labels.elementLock.lock"
|
||||
: "labels.elementLock.unlock";
|
||||
},
|
||||
icon: (appState, elements) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
@@ -58,19 +58,84 @@ export const actionToggleElementLock = register({
|
||||
|
||||
const nextLockState = shouldLock(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: nextLockState });
|
||||
}),
|
||||
const isAGroup =
|
||||
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
|
||||
const isASingleUnit = selectedElements.length === 1 || isAGroup;
|
||||
const newGroupId = isASingleUnit ? null : randomId();
|
||||
|
||||
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
|
||||
|
||||
if (nextLockState) {
|
||||
nextLockedMultiSelections = {
|
||||
...appState.lockedMultiSelections,
|
||||
...(newGroupId ? { [newGroupId]: true } : {}),
|
||||
};
|
||||
} else if (isAGroup) {
|
||||
const groupId = selectedElements[0].groupIds.at(-1)!;
|
||||
delete nextLockedMultiSelections[groupId];
|
||||
}
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
let nextGroupIds = element.groupIds;
|
||||
|
||||
// if locking together, add to group
|
||||
// if unlocking, remove the temporary group
|
||||
if (nextLockState) {
|
||||
if (newGroupId) {
|
||||
nextGroupIds = [...nextGroupIds, newGroupId];
|
||||
}
|
||||
} else {
|
||||
nextGroupIds = nextGroupIds.filter(
|
||||
(groupId) => !appState.lockedMultiSelections[groupId],
|
||||
);
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: nextLockState,
|
||||
// do not recreate the array unncessarily
|
||||
groupIds:
|
||||
nextGroupIds.length !== element.groupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
|
||||
? {}
|
||||
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
|
||||
const unlockedSelectedElements = selectedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
const nextSelectedGroupIds = nextLockState
|
||||
? {}
|
||||
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
|
||||
|
||||
const activeLockedId = nextLockState
|
||||
? newGroupId
|
||||
? newGroupId
|
||||
: isAGroup
|
||||
? selectedElements[0].groupIds.at(-1)!
|
||||
: selectedElements[0].id
|
||||
: null;
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: nextSelectedElementIds,
|
||||
selectedGroupIds: nextSelectedGroupIds,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
lockedMultiSelections: nextLockedMultiSelections,
|
||||
activeLockedId,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -103,18 +168,44 @@ export const actionUnlockAllElements = register({
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
if (element.locked) {
|
||||
// remove the temporary groupId if it exists
|
||||
const nextGroupIds = element.groupIds.filter(
|
||||
(gid) => !appState.lockedMultiSelections[gid],
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: false,
|
||||
groupIds:
|
||||
// do not recreate the array unncessarily
|
||||
element.groupIds.length !== nextGroupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
|
||||
const unlockedElements = lockedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (element.locked) {
|
||||
return newElementWith(element, { locked: false });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: Object.fromEntries(
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
selectedGroupIds: selectGroupsFromGivenElements(
|
||||
unlockedElements,
|
||||
appState,
|
||||
),
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@@ -7,6 +7,8 @@ import {
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useDevice } from "../components/App";
|
||||
@@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
|
||||
|
@@ -3,24 +3,40 @@ import { pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element/shapes";
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
tupleToCoors,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { done } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -30,11 +46,54 @@ export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: (elements, appState, data, app) => {
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
|
||||
const { event, sceneCoords } =
|
||||
(data as {
|
||||
event?: PointerEvent;
|
||||
sceneCoords?: { x: number; y: number };
|
||||
}) ?? {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (event && appState.selectedLinearElement) {
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
event,
|
||||
appState.selectedLinearElement,
|
||||
appState,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||
const element = app.scene.getElement(linearElementEditor.elementId);
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
app.scene,
|
||||
);
|
||||
}
|
||||
|
||||
if (linearElementEditor !== appState.selectedLinearElement) {
|
||||
let newElements = elements;
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
selectedLinearElement: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
},
|
||||
suggestedBindings: [],
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
@@ -49,6 +108,12 @@ export const actionFinalize = register({
|
||||
scene,
|
||||
);
|
||||
}
|
||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
@@ -66,91 +131,107 @@ export const actionFinalize = register({
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
const pendingImageElement =
|
||||
appState.pendingImageElementId &&
|
||||
scene.getElement(appState.pendingImageElementId);
|
||||
|
||||
if (pendingImageElement) {
|
||||
scene.mutateElement(
|
||||
pendingImageElement,
|
||||
{ isDeleted: true },
|
||||
{ informMutation: false, isDragging: false },
|
||||
);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
}
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.newElement?.type === "freedraw"
|
||||
? appState.newElement
|
||||
: null;
|
||||
let element: NonDeleted<ExcalidrawElement> | null = null;
|
||||
if (appState.multiElement) {
|
||||
element = appState.multiElement;
|
||||
} else if (
|
||||
appState.newElement?.type === "freedraw" ||
|
||||
isBindingElement(appState.newElement)
|
||||
) {
|
||||
element = appState.newElement;
|
||||
} else if (Object.keys(appState.selectedElementIds).length === 1) {
|
||||
const candidate = elementsMap.get(
|
||||
Object.keys(appState.selectedElementIds)[0],
|
||||
) as NonDeleted<ExcalidrawLinearElement> | undefined;
|
||||
if (candidate) {
|
||||
element = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (multiPointElement) {
|
||||
if (element) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
const { points, lastCommittedPoint } = element;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
) {
|
||||
scene.mutateElement(multiPointElement, {
|
||||
points: multiPointElement.points.slice(0, -1),
|
||||
scene.mutateElement(element, {
|
||||
points: element.points.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
}
|
||||
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "freedraw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(element.points, appState.zoom.value);
|
||||
|
||||
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
|
||||
const linePoints = element.points;
|
||||
const firstPoint = linePoints[0];
|
||||
scene.mutateElement(multiPointElement, {
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
const points: LocalPoint[] = linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
);
|
||||
if (isLineElement(element)) {
|
||||
scene.mutateElement(element, {
|
||||
points,
|
||||
polygon: true,
|
||||
});
|
||||
} else {
|
||||
scene.mutateElement(element, {
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBindingElement(multiPointElement) &&
|
||||
!isLoop &&
|
||||
multiPointElement.points.length > 1
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
!isLoop &&
|
||||
element.points.length > 1 &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
const coords =
|
||||
sceneCoords ??
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
),
|
||||
);
|
||||
|
||||
maybeBindLinearElement(element, appState, coords, scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
!element
|
||||
) {
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
@@ -177,7 +258,7 @@ export const actionFinalize = register({
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
multiPointElement
|
||||
element
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
@@ -188,23 +269,19 @@ export const actionFinalize = register({
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
element &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
[element.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(
|
||||
multiPointElement,
|
||||
arrayToMap(newElements),
|
||||
)
|
||||
element && isLinearElement(element)
|
||||
? new LinearElementEditor(element, arrayToMap(newElements))
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user