Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bcd5b622c |
@@ -1,5 +1,3 @@
|
||||
MODE="development"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
MODE="production"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Project coding standards
|
||||
|
||||
## Generic Communication Guidelines
|
||||
|
||||
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||
- Stop apologising if corrected, just provide the correct information or code
|
||||
- Prefer code unless asked for explanation
|
||||
- Stop summarizing what you've changed after modifications unless asked for
|
||||
|
||||
## TypeScript Guidelines
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Where possible, prefer implementations without allocation
|
||||
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||
- Prefer immutable data (const, readonly)
|
||||
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||
|
||||
## React Guidelines
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the React hooks rules (no conditional hooks)
|
||||
- Keep components small and focused
|
||||
- Use CSS modules for component styling
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Use PascalCase for component names, interfaces, and type aliases
|
||||
- Use camelCase for variables, functions, and methods
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use try/catch blocks for async operations
|
||||
- Implement proper error boundaries in React components
|
||||
- Always log errors with contextual information
|
||||
|
||||
## Testing
|
||||
|
||||
- Always attempt to fix #problems
|
||||
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||
|
||||
## Types
|
||||
|
||||
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn release --tag=next --non-interactive
|
||||
yarn autorelease
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Auto release excalidraw preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: "+1"
|
||||
- name: Get PR SHA
|
||||
id: sha
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const pr = await github.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
});
|
||||
return pr.data.head.sha
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ steps.sha.outputs.result }}
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release preview
|
||||
id: "autorelease"
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease preview ${{ github.event.issue.number }}
|
||||
- name: Post comment post release
|
||||
if: always()
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
||||
@@ -17,14 +17,9 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
+1
-2
@@ -25,5 +25,4 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
meta*.json
|
||||
@@ -1,34 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Structure
|
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features
|
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||
3. **Testing**: Always run `yarn test:update` before committing
|
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn test:typecheck # TypeScript type checking
|
||||
yarn test:update # Run all tests (with snapshot updates)
|
||||
yarn fix # Auto-fix formatting and linting issues
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Package System
|
||||
|
||||
- Uses Yarn workspaces for monorepo management
|
||||
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||
- Build system uses esbuild for packages, Vite for the app
|
||||
- TypeScript throughout with strict configuration
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,14 +6,13 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
RUN yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<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>
|
||||
<img alt"CodeRabbit Reviews" src="https://img.shields.io/coderabbit/prs/github/excalidraw/excalidraw?utm_source=oss&utm_medium=github&utm_campaign=excalidraw%2Fexcalidraw&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews"/>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -363,7 +363,13 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
```ts
|
||||
(
|
||||
tool: (
|
||||
| { type: ToolType }
|
||||
| (
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| {
|
||||
type: Extract<ToolType, "image">;
|
||||
insertOnCanvasDirectly?: boolean;
|
||||
}
|
||||
)
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean },
|
||||
) => {};
|
||||
@@ -371,7 +377,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
|
||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
|
||||
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
||||
|
||||
## setCursor
|
||||
|
||||
@@ -28,12 +28,32 @@ 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 release --tag=latest --version=0.19.0
|
||||
yarn prerelease:excalidraw
|
||||
```
|
||||
|
||||
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
|
||||
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
|
||||
|
||||
The next step is to run the `release` script:
|
||||
|
||||
```bash
|
||||
yarn release:excalidraw
|
||||
```
|
||||
|
||||
This will publish the package.
|
||||
|
||||
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
|
||||
|
||||
@@ -38,8 +38,6 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
||||
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||
{
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:packages": "yarn --cwd ../../ build:packages",
|
||||
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"build:preview": "yarn build && yarn preview",
|
||||
"build:packages": "yarn --cwd ../../ build:packages"
|
||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
|
||||
@@ -205,7 +205,6 @@ describe("collaboration", () => {
|
||||
// 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 }),
|
||||
@@ -248,5 +247,79 @@ describe("collaboration", () => {
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// simulate local update
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// we expect to iterate the stack to the first visible change
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// snapshot was correctly updated and marked the element as deleted
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-10
@@ -52,17 +52,13 @@
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build:common": "yarn --cwd ./packages/common build:esm",
|
||||
"build:element": "yarn --cwd ./packages/element build:esm",
|
||||
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:math": "yarn --cwd ./packages/math build:esm",
|
||||
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@@ -80,10 +76,9 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"release": "node scripts/release.js",
|
||||
"release:test": "node scripts/release.js --tag=test",
|
||||
"release:next": "node scripts/release.js --tag=next",
|
||||
"release:latest": "node scripts/release.js --tag=latest",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.18.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,10 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/common/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
"types": "./dist/types/common/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -147,49 +147,19 @@ export const FONT_FAMILY = {
|
||||
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,
|
||||
genericFallbackFont,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
];
|
||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
default:
|
||||
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -507,10 +477,3 @@ 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,9 +1,10 @@
|
||||
import { average } from "@excalidraw/math";
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@@ -100,6 +101,7 @@ 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("")}`;
|
||||
@@ -710,8 +712,8 @@ export const arrayToObject = <T>(
|
||||
array: readonly T[],
|
||||
groupBy?: (value: T) => string | number,
|
||||
) =>
|
||||
array.reduce((acc, value, idx) => {
|
||||
acc[groupBy ? groupBy(value) : idx] = value;
|
||||
array.reduce((acc, value) => {
|
||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||
return acc;
|
||||
}, {} as { [key: string]: T });
|
||||
|
||||
@@ -1236,6 +1238,20 @@ 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.18.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,10 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/element/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
"types": "./dist/types/element/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -55,9 +52,5 @@
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,26 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
|
||||
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,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import {
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { shouldTestInside } from "./collision";
|
||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -67,89 +28,12 @@ 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];
|
||||
@@ -419,182 +303,6 @@ 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.
|
||||
*
|
||||
@@ -602,7 +310,7 @@ export const generateLinearCollisionShape = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
const generateElementShape = (
|
||||
export const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
@@ -903,103 +611,3 @@ 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;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
+130
-186
@@ -6,6 +6,7 @@ import {
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,8 @@ 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";
|
||||
@@ -33,12 +36,12 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
getCenterForBounds,
|
||||
getElementBounds,
|
||||
doBoundsIntersect,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { distanceToElement } from "./distance";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import {
|
||||
headingForPointFromElement,
|
||||
headingIsHorizontal,
|
||||
@@ -60,7 +63,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
@@ -106,6 +109,7 @@ 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,
|
||||
@@ -127,7 +131,6 @@ 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(
|
||||
@@ -138,7 +141,6 @@ export const bindOrUnbindLinearElement = (
|
||||
boundToElementIds,
|
||||
unboundFromElementIds,
|
||||
scene,
|
||||
elementsMap,
|
||||
);
|
||||
bindOrUnbindLinearElementEdge(
|
||||
linearElement,
|
||||
@@ -148,7 +150,6 @@ export const bindOrUnbindLinearElement = (
|
||||
boundToElementIds,
|
||||
unboundFromElementIds,
|
||||
scene,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||
@@ -175,7 +176,6 @@ 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") {
|
||||
@@ -216,29 +216,43 @@ 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"] 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;
|
||||
});
|
||||
["start", "end"].map((edge) =>
|
||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||
linearElement,
|
||||
edge as "start" | "end",
|
||||
elementsMap,
|
||||
zoom,
|
||||
),
|
||||
);
|
||||
|
||||
const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -254,7 +268,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||
const start = startDragged
|
||||
? isBindingEnabled
|
||||
? getEligibleElementForBindingElement(
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
@@ -265,7 +279,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
: "keep";
|
||||
const end = endDragged
|
||||
? isBindingEnabled
|
||||
? getEligibleElementForBindingElement(
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
@@ -297,7 +311,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
);
|
||||
const start = startIsClose
|
||||
? isBindingEnabled
|
||||
? getEligibleElementForBindingElement(
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
@@ -308,7 +322,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
: null;
|
||||
const end = endIsClose
|
||||
? isBindingEnabled
|
||||
? getEligibleElementForBindingElement(
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
@@ -384,48 +398,6 @@ 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,
|
||||
@@ -469,13 +441,22 @@ export const maybeBindLinearElement = (
|
||||
const normalizePointBinding = (
|
||||
binding: { focus: number; gap: number },
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
) => ({
|
||||
...binding,
|
||||
gap: Math.min(
|
||||
binding.gap,
|
||||
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
|
||||
),
|
||||
});
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -507,7 +488,6 @@ export const bindLinearElement = (
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -555,7 +535,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
||||
|
||||
const isLinearElementSimple = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||
): boolean => linearElement.points.length < 3;
|
||||
|
||||
const unbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@@ -723,13 +703,8 @@ const calculateFocusAndGap = (
|
||||
);
|
||||
|
||||
return {
|
||||
focus: determineFocusDistance(
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
adjacentPoint,
|
||||
edgePoint,
|
||||
),
|
||||
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
|
||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -899,7 +874,6 @@ 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));
|
||||
@@ -908,16 +882,11 @@ export const getHeadingForElbowArrowSnap = (
|
||||
return otherPointHeading;
|
||||
}
|
||||
|
||||
const distance = getDistanceForBinding(
|
||||
origPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
|
||||
|
||||
if (!distance) {
|
||||
return vectorToHeading(
|
||||
vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
|
||||
vectorFromPoint(p, elementCenterPoint(bindableElement)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -927,10 +896,9 @@ export const getHeadingForElbowArrowSnap = (
|
||||
const getDistanceForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
const distance = distanceToElement(bindableElement, elementsMap, point);
|
||||
const distance = distanceToBindableElement(bindableElement, point);
|
||||
const bindDistance = maxBindingGap(
|
||||
bindableElement,
|
||||
bindableElement.width,
|
||||
@@ -945,13 +913,12 @@ 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, elementsMap);
|
||||
const aabb = aabbForElement(bindableElement);
|
||||
const localP =
|
||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||
const globalP = pointFrom<GlobalPoint>(
|
||||
@@ -959,7 +926,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
arrow.y + localP[1],
|
||||
);
|
||||
const edgePoint = isRectanguloidElement(bindableElement)
|
||||
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
|
||||
? avoidRectangularCorner(bindableElement, globalP)
|
||||
: globalP;
|
||||
const elbowed = isElbowArrow(arrow);
|
||||
const center = getCenterForBounds(aabb);
|
||||
@@ -978,31 +945,26 @@ export const bindPointToSnapToElementOutline = (
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||
);
|
||||
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
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,
|
||||
),
|
||||
isHorizontal ? center[0] : edgePoint[0],
|
||||
!isHorizontal ? center[1] : edgePoint[1],
|
||||
);
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
intersector,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
).sort(pointDistanceSq)[0];
|
||||
lineSegment(
|
||||
otherPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
otherPoint,
|
||||
),
|
||||
),
|
||||
)[0];
|
||||
} else {
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
lineSegment(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
@@ -1029,15 +991,31 @@ export const bindPointToSnapToElementOutline = (
|
||||
return edgePoint;
|
||||
}
|
||||
|
||||
return elbowed ? intersection : 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;
|
||||
};
|
||||
|
||||
export const avoidRectangularCorner = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||
@@ -1130,34 +1108,35 @@ export const avoidRectangularCorner = (
|
||||
|
||||
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, elementsMap, -0.1, -0.1);
|
||||
|
||||
const center = elementCenterPoint(element, -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 verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||
const verticalThrehsold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
return pointRotateRads(
|
||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
@@ -1167,8 +1146,8 @@ export const snapToMid = (
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
@@ -1178,8 +1157,8 @@ export const snapToMid = (
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
@@ -1188,7 +1167,7 @@ export const snapToMid = (
|
||||
angle,
|
||||
);
|
||||
} else if (element.type === "diamond") {
|
||||
const distance = FIXED_BINDING_DISTANCE;
|
||||
const distance = FIXED_BINDING_DISTANCE - 1;
|
||||
const topLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + height / 4 - distance,
|
||||
@@ -1205,28 +1184,27 @@ export const snapToMid = (
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(topLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
) {
|
||||
return pointRotateRads(topLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(topRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
) {
|
||||
return pointRotateRads(topRight, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
) {
|
||||
return pointRotateRads(bottomLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
) {
|
||||
return pointRotateRads(bottomRight, center, angle);
|
||||
}
|
||||
@@ -1261,9 +1239,8 @@ const updateBoundPoint = (
|
||||
linearElement,
|
||||
bindableElement,
|
||||
startOrEnd === "startBinding" ? "start" : "end",
|
||||
elementsMap,
|
||||
).fixedPoint;
|
||||
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
|
||||
const globalMidPoint = elementCenterPoint(bindableElement);
|
||||
const global = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||
@@ -1289,7 +1266,6 @@ const updateBoundPoint = (
|
||||
);
|
||||
const focusPointAbsolute = determineFocusPoint(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
binding.focus,
|
||||
adjacentPoint,
|
||||
);
|
||||
@@ -1308,7 +1284,7 @@ const updateBoundPoint = (
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center = elementCenterPoint(bindableElement, elementsMap);
|
||||
const center = elementCenterPoint(bindableElement);
|
||||
const interceptorLength =
|
||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||
pointDistance(adjacentPoint, center) +
|
||||
@@ -1316,7 +1292,6 @@ const updateBoundPoint = (
|
||||
const intersections = [
|
||||
...intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
lineSegment<GlobalPoint>(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
@@ -1367,7 +1342,6 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
const bounds = [
|
||||
hoveredElement.x,
|
||||
@@ -1379,7 +1353,6 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
@@ -1423,7 +1396,7 @@ const maybeCalculateNewGapWhenScaling = (
|
||||
return { ...currentBinding, gap: newGap };
|
||||
};
|
||||
|
||||
const getEligibleElementForBindingElement = (
|
||||
const getElligibleElementForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
@@ -1575,38 +1548,14 @@ 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);
|
||||
|
||||
// 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 shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= threshold
|
||||
: intersections.length > 0 && distance <= threshold;
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
@@ -1626,7 +1575,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 + FIXED_BINDING_DISTANCE,
|
||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1637,13 +1586,12 @@ 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, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
if (pointsEqual(a, b)) {
|
||||
return 0;
|
||||
@@ -1768,13 +1716,12 @@ 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, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
if (focus === 0) {
|
||||
return center;
|
||||
@@ -2197,7 +2144,6 @@ export class BindableElement {
|
||||
export const getGlobalFixedPointForBindableElement = (
|
||||
fixedPointRatio: [number, number],
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||
|
||||
@@ -2206,7 +2152,7 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
element.x + element.width * fixedX,
|
||||
element.y + element.height * fixedY,
|
||||
),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
elementCenterPoint(element),
|
||||
element.angle,
|
||||
);
|
||||
};
|
||||
@@ -2230,7 +2176,6 @@ export const getGlobalFixedPoints = (
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
startElement as ExcalidrawBindableElement,
|
||||
elementsMap,
|
||||
)
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[0][0],
|
||||
@@ -2241,7 +2186,6 @@ export const getGlobalFixedPoints = (
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
endElement as ExcalidrawBindableElement,
|
||||
elementsMap,
|
||||
)
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||
|
||||
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shape";
|
||||
import { getElementShape } from "./shapes";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
@@ -102,23 +102,9 @@ export class ElementBounds {
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
private static nonRotatedBoundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
) {
|
||||
const cachedBounds =
|
||||
nonRotated && element.angle !== 0
|
||||
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||
: ElementBounds.boundsCache.get(element);
|
||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (
|
||||
cachedBounds?.version &&
|
||||
@@ -129,23 +115,6 @@ 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, {
|
||||
@@ -584,7 +553,7 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
export const getCubicBezierCurveBound = (
|
||||
const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
@@ -970,9 +939,8 @@ const getLinearElementRotatedBounds = (
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
): Bounds => {
|
||||
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||
return ElementBounds.getBounds(element, elementsMap);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
@@ -1165,71 +1133,6 @@ 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,
|
||||
@@ -1243,14 +1146,3 @@ 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);
|
||||
};
|
||||
|
||||
+129
-377
@@ -1,68 +1,52 @@
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseSegmentInterceptPoints,
|
||||
ellipseLineIntersectionPoints,
|
||||
} 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 { isPathALoop } from "./utils";
|
||||
import {
|
||||
type Bounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
@@ -88,64 +72,45 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs = {
|
||||
point: GlobalPoint;
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
element: ExcalidrawElement;
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
threshold,
|
||||
elementsMap,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs) => {
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||
point,
|
||||
pointFrom(
|
||||
frameNameBound.x + frameNameBound.width + threshold,
|
||||
frameNameBound.y + frameNameBound.height + threshold,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||
if (!hitBounds && !hitFrameName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = shouldTestInside(element)
|
||||
}: 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"
|
||||
isPointInElement(point, element, elementsMap) ||
|
||||
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
|
||||
return hitElement || hitFrameName;
|
||||
// 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>,
|
||||
});
|
||||
}
|
||||
|
||||
return hit;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
point: GlobalPoint,
|
||||
x: number,
|
||||
y: number,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
@@ -155,42 +120,37 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
hitArgs: HitTestArgs,
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||
) => {
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundText = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -203,26 +163,9 @@ export const hitElementBoundText = (
|
||||
*/
|
||||
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":
|
||||
@@ -230,196 +173,67 @@ export const intersectElementWithLineSegment = (
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "selection":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
);
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(intersection);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
segment[0],
|
||||
l[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
segment[1],
|
||||
l[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
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,
|
||||
)
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -431,45 +245,43 @@ const intersectRectanguloidWithLineSegment = (
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
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,
|
||||
)
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -481,76 +293,16 @@ const intersectDiamondWithLineSegment = (
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseSegmentInterceptPoints(
|
||||
return ellipseLineIntersectionPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
line(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,8 +14,9 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -33,7 +34,6 @@ 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, elementsMap),
|
||||
elementCenterPoint(element),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
|
||||
+93
-262
@@ -5,12 +5,11 @@ import {
|
||||
isDevEnv,
|
||||
isShallowEqual,
|
||||
isTestEnv,
|
||||
randomInteger,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
@@ -19,12 +18,7 @@ import type {
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
DTO,
|
||||
Mutable,
|
||||
SubtypeOf,
|
||||
ValueOf,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -57,8 +51,6 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@@ -81,20 +73,13 @@ export class Delta<T> {
|
||||
public static create<T>(
|
||||
deleted: Partial<T>,
|
||||
inserted: Partial<T>,
|
||||
modifier?: (
|
||||
delta: Partial<T>,
|
||||
partialType: "deleted" | "inserted",
|
||||
) => Partial<T>,
|
||||
modifierOptions?: "deleted" | "inserted" | "both",
|
||||
modifier?: (delta: Partial<T>) => Partial<T>,
|
||||
modifierOptions?: "deleted" | "inserted",
|
||||
) {
|
||||
const modifiedDeleted =
|
||||
modifier && modifierOptions !== "inserted"
|
||||
? modifier(deleted, "deleted")
|
||||
: deleted;
|
||||
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
|
||||
const modifiedInserted =
|
||||
modifier && modifierOptions !== "deleted"
|
||||
? modifier(inserted, "inserted")
|
||||
: inserted;
|
||||
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
|
||||
|
||||
return new Delta(modifiedDeleted, modifiedInserted);
|
||||
}
|
||||
@@ -128,7 +113,11 @@ export class Delta<T> {
|
||||
// - we do this only on previously detected changed elements
|
||||
// - we do shallow compare only on the first level of properties (not going any deeper)
|
||||
// - # of properties is reasonably small
|
||||
for (const key of this.getDifferences(prevObject, nextObject)) {
|
||||
for (const key of this.distinctKeysIterator(
|
||||
"full",
|
||||
prevObject,
|
||||
nextObject,
|
||||
)) {
|
||||
deleted[key as keyof T] = prevObject[key];
|
||||
inserted[key as keyof T] = nextObject[key];
|
||||
}
|
||||
@@ -267,14 +256,12 @@ export class Delta<T> {
|
||||
arrayToObject(deletedArray, groupBy),
|
||||
arrayToObject(insertedArray, groupBy),
|
||||
),
|
||||
(x) => x,
|
||||
);
|
||||
const insertedDifferences = arrayToObject(
|
||||
Delta.getRightDifferences(
|
||||
arrayToObject(deletedArray, groupBy),
|
||||
arrayToObject(insertedArray, groupBy),
|
||||
),
|
||||
(x) => x,
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -333,42 +320,6 @@ export class Delta<T> {
|
||||
return !!anyDistinctKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares if shared properties of object1 and object2 contain any different value (aka inner join).
|
||||
*/
|
||||
public static isInnerDifferent<T extends {}>(
|
||||
object1: T,
|
||||
object2: T,
|
||||
skipShallowCompare = false,
|
||||
): boolean {
|
||||
const anyDistinctKey = !!this.distinctKeysIterator(
|
||||
"inner",
|
||||
object1,
|
||||
object2,
|
||||
skipShallowCompare,
|
||||
).next().value;
|
||||
|
||||
return !!anyDistinctKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares if any properties of object1 and object2 contain any different value (aka full join).
|
||||
*/
|
||||
public static isDifferent<T extends {}>(
|
||||
object1: T,
|
||||
object2: T,
|
||||
skipShallowCompare = false,
|
||||
): boolean {
|
||||
const anyDistinctKey = !!this.distinctKeysIterator(
|
||||
"full",
|
||||
object1,
|
||||
object2,
|
||||
skipShallowCompare,
|
||||
).next().value;
|
||||
|
||||
return !!anyDistinctKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted object1 keys that have distinct values.
|
||||
*/
|
||||
@@ -395,32 +346,6 @@ export class Delta<T> {
|
||||
).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted keys of shared object1 and object2 properties that have distinct values (aka inner join).
|
||||
*/
|
||||
public static getInnerDifferences<T extends {}>(
|
||||
object1: T,
|
||||
object2: T,
|
||||
skipShallowCompare = false,
|
||||
) {
|
||||
return Array.from(
|
||||
this.distinctKeysIterator("inner", object1, object2, skipShallowCompare),
|
||||
).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted keys that have distinct values between object1 and object2 (aka full join).
|
||||
*/
|
||||
public static getDifferences<T extends {}>(
|
||||
object1: T,
|
||||
object2: T,
|
||||
skipShallowCompare = false,
|
||||
) {
|
||||
return Array.from(
|
||||
this.distinctKeysIterator("full", object1, object2, skipShallowCompare),
|
||||
).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator comparing values of object properties based on the passed joining strategy.
|
||||
*
|
||||
@@ -429,7 +354,7 @@ export class Delta<T> {
|
||||
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
|
||||
*/
|
||||
private static *distinctKeysIterator<T extends {}>(
|
||||
join: "left" | "right" | "inner" | "full",
|
||||
join: "left" | "right" | "full",
|
||||
object1: T,
|
||||
object2: T,
|
||||
skipShallowCompare = false,
|
||||
@@ -444,8 +369,6 @@ export class Delta<T> {
|
||||
keys = Object.keys(object1);
|
||||
} else if (join === "right") {
|
||||
keys = Object.keys(object2);
|
||||
} else if (join === "inner") {
|
||||
keys = Object.keys(object1).filter((key) => key in object2);
|
||||
} else if (join === "full") {
|
||||
keys = Array.from(
|
||||
new Set([...Object.keys(object1), ...Object.keys(object2)]),
|
||||
@@ -459,17 +382,17 @@ export class Delta<T> {
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value1 = object1[key as keyof T];
|
||||
const value2 = object2[key as keyof T];
|
||||
const object1Value = object1[key as keyof T];
|
||||
const object2Value = object2[key as keyof T];
|
||||
|
||||
if (value1 !== value2) {
|
||||
if (object1Value !== object2Value) {
|
||||
if (
|
||||
!skipShallowCompare &&
|
||||
typeof value1 === "object" &&
|
||||
typeof value2 === "object" &&
|
||||
value1 !== null &&
|
||||
value2 !== null &&
|
||||
isShallowEqual(value1, value2)
|
||||
typeof object1Value === "object" &&
|
||||
typeof object2Value === "object" &&
|
||||
object1Value !== null &&
|
||||
object2Value !== null &&
|
||||
isShallowEqual(object1Value, object2Value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -694,20 +617,14 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
break;
|
||||
case "croppingElementId": {
|
||||
const croppingElementId = nextAppState[key];
|
||||
const element =
|
||||
croppingElementId && nextElements.get(croppingElementId);
|
||||
|
||||
if (!croppingElementId) {
|
||||
// previously there was a croppingElementId (assuming visible), now there is none
|
||||
if (element && !element.isDeleted) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(croppingElementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "editingGroupId":
|
||||
@@ -941,17 +858,10 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
};
|
||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||
ElementUpdate<Ordered<T>>,
|
||||
"seed"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Elements change is a low level primitive to capture a change between two sets of elements.
|
||||
@@ -1034,33 +944,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
inserted,
|
||||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||
|
||||
private static satisfiesCommmonInvariants = ({
|
||||
deleted,
|
||||
inserted,
|
||||
}: Delta<ElementPartial>) =>
|
||||
!!(
|
||||
deleted.version &&
|
||||
inserted.version &&
|
||||
// versions are required integers
|
||||
Number.isInteger(deleted.version) &&
|
||||
Number.isInteger(inserted.version) &&
|
||||
// versions should be positive, zero included
|
||||
deleted.version >= 0 &&
|
||||
inserted.version >= 0 &&
|
||||
// versions should never be the same
|
||||
deleted.version !== inserted.version
|
||||
);
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
satifiesSpecialInvariants: (delta: Delta<ElementPartial>) => boolean,
|
||||
satifies: (delta: Delta<ElementPartial>) => boolean,
|
||||
) {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
if (!satifies(delta)) {
|
||||
console.error(
|
||||
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||
delta,
|
||||
@@ -1096,12 +986,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
version: prevElement.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
} as ElementPartial;
|
||||
const inserted = { isDeleted: true } as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
deleted,
|
||||
@@ -1117,12 +1002,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const prevElement = prevElements.get(nextElement.id);
|
||||
|
||||
if (!prevElement) {
|
||||
const deleted = {
|
||||
isDeleted: true,
|
||||
version: nextElement.version - 1,
|
||||
versionNonce: randomInteger(),
|
||||
} as ElementPartial;
|
||||
|
||||
const deleted = { isDeleted: true } as ElementPartial;
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
@@ -1207,40 +1087,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
/**
|
||||
* Update delta/s based on the existing elements.
|
||||
*
|
||||
* @param nextElements current elements
|
||||
* @param elements current elements
|
||||
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||
* @returns new instance with modified delta/s
|
||||
*/
|
||||
public applyLatestChanges(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
modifierOptions?: "deleted" | "inserted",
|
||||
elements: SceneElementsMap,
|
||||
modifierOptions: "deleted" | "inserted",
|
||||
): ElementsDelta {
|
||||
const modifier =
|
||||
(
|
||||
prevElement: OrderedExcalidrawElement | undefined,
|
||||
nextElement: OrderedExcalidrawElement | undefined,
|
||||
) =>
|
||||
(partial: ElementPartial, partialType: "deleted" | "inserted") => {
|
||||
let element: OrderedExcalidrawElement | undefined;
|
||||
|
||||
switch (partialType) {
|
||||
case "deleted":
|
||||
element = prevElement;
|
||||
break;
|
||||
case "inserted":
|
||||
element = nextElement;
|
||||
break;
|
||||
}
|
||||
|
||||
// the element wasn't found -> don't update the partial
|
||||
if (!element) {
|
||||
console.error(
|
||||
`Element not found when trying to apply latest changes`,
|
||||
);
|
||||
return partial;
|
||||
}
|
||||
|
||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||
const latestPartial: { [key: string]: unknown } = {};
|
||||
|
||||
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
|
||||
@@ -1264,25 +1120,19 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
const prevElement = prevElements.get(id);
|
||||
const nextElement = nextElements.get(id);
|
||||
const existingElement = elements.get(id);
|
||||
|
||||
let latestDelta: Delta<ElementPartial> | null = null;
|
||||
|
||||
if (prevElement || nextElement) {
|
||||
latestDelta = Delta.create(
|
||||
if (existingElement) {
|
||||
const modifiedDelta = Delta.create(
|
||||
delta.deleted,
|
||||
delta.inserted,
|
||||
modifier(prevElement, nextElement),
|
||||
modifier(existingElement),
|
||||
modifierOptions,
|
||||
);
|
||||
} else {
|
||||
latestDelta = delta;
|
||||
}
|
||||
|
||||
// it might happen that after applying latest changes the delta itself does not contain any changes
|
||||
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
|
||||
modifiedDeltas[id] = latestDelta;
|
||||
modifiedDeltas[id] = modifiedDelta;
|
||||
} else {
|
||||
modifiedDeltas[id] = delta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,15 +1150,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
|
||||
const flags: ApplyToFlags = {
|
||||
const flags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
};
|
||||
@@ -1317,14 +1164,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
nextElements,
|
||||
snapshot,
|
||||
options,
|
||||
elementsSnapshot,
|
||||
flags,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
const addedElements = applyDeltas("added", this.added);
|
||||
const removedElements = applyDeltas("removed", this.removed);
|
||||
const updatedElements = applyDeltas("updated", this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
|
||||
@@ -1383,12 +1229,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static createApplier =
|
||||
(
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
flags: {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
},
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
(
|
||||
type: "added" | "removed" | "updated",
|
||||
deltas: Record<string, Delta<ElementPartial>>,
|
||||
) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
type,
|
||||
nextElements,
|
||||
snapshot,
|
||||
flags,
|
||||
@@ -1398,13 +1250,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
options,
|
||||
flags,
|
||||
);
|
||||
|
||||
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
}
|
||||
@@ -1415,9 +1261,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
private static createGetter =
|
||||
(
|
||||
type: "added" | "removed" | "updated",
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
flags: ApplyToFlags,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
flags: {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
},
|
||||
) =>
|
||||
(id: string, partial: ElementPartial) => {
|
||||
let element = elements.get(id);
|
||||
@@ -1431,7 +1281,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
flags.containsZindexDifference = true;
|
||||
|
||||
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
|
||||
if (
|
||||
partial.isDeleted === false ||
|
||||
(partial.isDeleted !== true && element.isDeleted === false)
|
||||
) {
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
} else {
|
||||
@@ -1451,28 +1304,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
flags: {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
} = {
|
||||
// by default we don't care about about the flags
|
||||
containsVisibleDifference: true,
|
||||
containsZindexDifference: true,
|
||||
},
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
// some properties are not directly applicable, such as:
|
||||
// - boundElements which contains only diff)
|
||||
// - version & versionNonce, if we don't want to return to previous versions
|
||||
for (const key of Object.keys(delta.inserted) as Array<
|
||||
keyof typeof delta.inserted
|
||||
>) {
|
||||
if (key === "boundElements") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.excludedProperties.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = delta.inserted[key];
|
||||
Reflect.set(directlyApplicablePartial, key, value);
|
||||
}
|
||||
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
|
||||
|
||||
if (
|
||||
delta.deleted.boundElements?.length ||
|
||||
@@ -1490,6 +1331,19 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: this looks wrong, shouldn't be here
|
||||
if (element.type === "image") {
|
||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||
// we want to override `crop` only if modified so that we don't reset
|
||||
// when undoing/redoing unrelated change
|
||||
if (_delta.deleted.crop || _delta.inserted.crop) {
|
||||
Object.assign(directlyApplicablePartial, {
|
||||
// apply change verbatim
|
||||
crop: _delta.inserted.crop ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
@@ -1796,29 +1650,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
): [ElementPartial, ElementPartial] {
|
||||
try {
|
||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||
|
||||
// don't diff the points as:
|
||||
// - we can't ensure the multiplayer order consistency without fractional index on each point
|
||||
// - we prefer to not merge the points, as it might just lead to unexpected / incosistent results
|
||||
const deletedPoints =
|
||||
(
|
||||
deleted as ElementPartial<
|
||||
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
|
||||
>
|
||||
).points ?? [];
|
||||
|
||||
const insertedPoints =
|
||||
(
|
||||
inserted as ElementPartial<
|
||||
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
|
||||
>
|
||||
).points ?? [];
|
||||
|
||||
if (!Delta.isDifferent(deletedPoints, insertedPoints)) {
|
||||
// delete the points from delta if there is no difference, otherwise leave them as they were captured due to consistency
|
||||
Reflect.deleteProperty(deleted, "points");
|
||||
Reflect.deleteProperty(inserted, "points");
|
||||
}
|
||||
} catch (e) {
|
||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess elements delta.`);
|
||||
@@ -1834,7 +1665,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static stripIrrelevantProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): ElementPartial {
|
||||
const { id, updated, ...strippedPartial } = partial;
|
||||
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
|
||||
@@ -6,33 +6,27 @@ 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 {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
@@ -40,15 +34,11 @@ export const distanceToElement = (
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||
return distanceToRectanguloidElement(element, p);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, elementsMap, p);
|
||||
return distanceToDiamondElement(element, p);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, elementsMap, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
return distanceToEllipseElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,10 +52,9 @@ export const distanceToElement = (
|
||||
*/
|
||||
const distanceToRectanguloidElement = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
// 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);
|
||||
@@ -91,10 +80,9 @@ const distanceToRectanguloidElement = (
|
||||
*/
|
||||
const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
@@ -120,24 +108,12 @@ const distanceToDiamondElement = (
|
||||
*/
|
||||
const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
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)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -30,9 +29,10 @@ import {
|
||||
FIXED_BINDING_DISTANCE,
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
snapToMid,
|
||||
getHoveredElementForBinding,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
flipHeading,
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
@@ -898,6 +898,50 @@ 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,
|
||||
@@ -1229,7 +1273,6 @@ const getElbowArrowData = (
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
@@ -1243,7 +1286,6 @@ const getElbowArrowData = (
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
@@ -1251,14 +1293,12 @@ const getElbowArrowData = (
|
||||
endGlobalPoint,
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1275,7 +1315,6 @@ const getElbowArrowData = (
|
||||
const startElementBounds = hoveredStartElement
|
||||
? aabbForElement(
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
@@ -1288,7 +1327,6 @@ const getElbowArrowData = (
|
||||
const endElementBounds = hoveredEndElement
|
||||
? aabbForElement(
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
@@ -1304,7 +1342,6 @@ const getElbowArrowData = (
|
||||
hoveredEndElement
|
||||
? aabbForElement(
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
||||
)
|
||||
: endPointBounds,
|
||||
@@ -1314,7 +1351,6 @@ const getElbowArrowData = (
|
||||
hoveredStartElement
|
||||
? aabbForElement(
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
||||
)
|
||||
: startPointBounds,
|
||||
@@ -1361,8 +1397,8 @@ const getElbowArrowData = (
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
||||
);
|
||||
const startDonglePosition = getDonglePosition(
|
||||
dynamicAABBs[0],
|
||||
@@ -2193,28 +2229,35 @@ const getGlobalPoint = (
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (element && elementsMap) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
if (element) {
|
||||
const snapPoint = bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return snapToMid(element, snapPoint);
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
return getGlobalFixedPointForBindableElement(
|
||||
const fixedGlobalPoint = 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;
|
||||
@@ -2225,7 +2268,6 @@ const getBindPointHeading = (
|
||||
otherPoint: GlobalPoint,
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): Heading =>
|
||||
getHeadingForElbowArrowSnap(
|
||||
p,
|
||||
@@ -2234,8 +2276,7 @@ const getBindPointHeading = (
|
||||
hoveredElement &&
|
||||
aabbForElement(
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
|
||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
@@ -2243,7 +2284,6 @@ const getBindPointHeading = (
|
||||
],
|
||||
),
|
||||
origPoint,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const getHoveredElement = (
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
@@ -95,11 +95,10 @@ const getNodeRelatives = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(
|
||||
node,
|
||||
aabbForElement(node, elementsMap),
|
||||
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||
);
|
||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
] as Readonly<GlobalPoint>);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
export class InvalidFractionalIndexError extends Error {
|
||||
@@ -162,15 +161,9 @@ export const syncMovedIndices = (
|
||||
|
||||
// try generatating indices, throws on invalid movedElements
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
const elementsCandidates = elements.map((x) => {
|
||||
const elementUpdates = elementsUpdates.get(x);
|
||||
|
||||
if (elementUpdates) {
|
||||
return { ...x, index: elementUpdates.index };
|
||||
}
|
||||
|
||||
return x;
|
||||
});
|
||||
const elementsCandidates = elements.map((x) =>
|
||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
||||
);
|
||||
|
||||
// ensure next indices are valid before mutation, throws on invalid ones
|
||||
validateFractionalIndices(
|
||||
@@ -184,8 +177,8 @@ export const syncMovedIndices = (
|
||||
);
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to default sync
|
||||
@@ -196,7 +189,7 @@ export const syncMovedIndices = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@@ -207,32 +200,13 @@ export const syncInvalidIndices = (
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -102,7 +102,9 @@ export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./shape";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -20,14 +18,9 @@ import {
|
||||
getGridPoint,
|
||||
invariant,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
isPathALoop,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
import type { Store } from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
@@ -46,7 +39,6 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
maybeSuggestBindingsForLinearElementAtCoords,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -63,12 +55,18 @@ import {
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import {
|
||||
isPathALoop,
|
||||
getBezierCurveLength,
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
} from "./shapes";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
@@ -87,35 +85,6 @@ import type {
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
@@ -148,7 +117,6 @@ 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>,
|
||||
@@ -159,11 +127,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
|
||||
);
|
||||
LinearElementEditor.normalizePoints(element, elementsMap);
|
||||
}
|
||||
this.selectedPointsIndices = null;
|
||||
this.lastUncommittedPoint = null;
|
||||
@@ -186,7 +150,6 @@ export class LinearElementEditor {
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -277,15 +240,19 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): Pick<AppState, keyof AppState> | null {
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
if (!linearElementEditor) {
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
let customLineAngle = linearElementEditor.customLineAngle;
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
@@ -326,12 +293,6 @@ 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,
|
||||
@@ -339,12 +300,11 @@ export class LinearElementEditor {
|
||||
referencePoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
selectedIndex,
|
||||
@@ -372,7 +332,7 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
scene,
|
||||
new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
@@ -404,59 +364,46 @@ export class LinearElementEditor {
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, app.scene, false);
|
||||
handleBindTextResize(element, 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 }[] = [];
|
||||
|
||||
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||
} else {
|
||||
if (firstSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
),
|
||||
const firstSelectedIndex = selectedPointsIndices[0];
|
||||
if (firstSelectedIndex === 0) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (lastSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||
],
|
||||
elementsMap,
|
||||
),
|
||||
const lastSelectedIndex =
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
||||
if (lastSelectedIndex === element.points.length - 1) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[lastSelectedIndex],
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (coords.length) {
|
||||
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||
element,
|
||||
coords,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
);
|
||||
maybeSuggestBinding(element, coords);
|
||||
}
|
||||
}
|
||||
|
||||
const newLinearElementEditor = {
|
||||
return {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords:
|
||||
@@ -474,16 +421,6 @@ export class LinearElementEditor {
|
||||
? lastClickedPoint
|
||||
: -1,
|
||||
isDragging: true,
|
||||
customLineAngle,
|
||||
};
|
||||
|
||||
return {
|
||||
...app.state,
|
||||
editingLinearElement: app.state.editingLinearElement
|
||||
? newLinearElementEditor
|
||||
: null,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBindings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -498,7 +435,6 @@ export class LinearElementEditor {
|
||||
): LinearElementEditor {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||
|
||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||
editingLinearElement;
|
||||
@@ -523,18 +459,6 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
if (isLineElement(element)) {
|
||||
scene.mutateElement(
|
||||
element,
|
||||
{
|
||||
...toggleLinePolygonState(element, true),
|
||||
},
|
||||
{
|
||||
informMutation: false,
|
||||
isDragging: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
@@ -554,15 +478,13 @@ export class LinearElementEditor {
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
? getHoveredElementForBinding(
|
||||
(selectedPointsIndices?.length ?? 0) > 1
|
||||
? tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
)
|
||||
: pointerCoords,
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
@@ -581,8 +503,6 @@ 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.
|
||||
@@ -604,7 +524,6 @@ export class LinearElementEditor {
|
||||
: selectedPointsIndices,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
customLineAngle: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -648,7 +567,10 @@ export class LinearElementEditor {
|
||||
}
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
@@ -750,18 +672,7 @@ export class LinearElementEditor {
|
||||
|
||||
let distance = pointDistance(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
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]);
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
}
|
||||
|
||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||
@@ -769,42 +680,39 @@ export class LinearElementEditor {
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
index: number,
|
||||
startPoint: GlobalPoint,
|
||||
endPoint: GlobalPoint,
|
||||
endPointIndex: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint {
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
element.points.length >= index,
|
||||
"Invalid segment index while calculating elbow arrow mid point",
|
||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const controlPoints = getControlPointsForBezierCurve(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
);
|
||||
if (controlPoints) {
|
||||
const t = mapIntervalToBezierT(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
0.5,
|
||||
);
|
||||
|
||||
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
return segmentMidPoint;
|
||||
}
|
||||
|
||||
static getSegmentMidPointIndex(
|
||||
@@ -1038,7 +946,9 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
LinearElementEditor.deletePoints(element, app.scene, [
|
||||
points.length - 1,
|
||||
]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1089,7 +999,7 @@ export class LinearElementEditor {
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1232,23 +1142,40 @@ 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 normalized x and y coords to account for the normalization
|
||||
* of the points.
|
||||
* expected in various parts of the codebase. Also returns new x/y to account
|
||||
* for the potential normalization.
|
||||
*/
|
||||
static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
|
||||
const { points, offsetX, offsetY } = getNormalizedPoints(element);
|
||||
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const { points } = element;
|
||||
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
|
||||
return {
|
||||
points,
|
||||
points: points.map((p) => {
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
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,
|
||||
@@ -1327,42 +1254,41 @@ export class LinearElementEditor {
|
||||
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
app: AppClassProperties,
|
||||
scene: Scene,
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
const isUncommittedPoint =
|
||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
const nextPoints = element.points.filter((_, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
});
|
||||
const isDeletingOriginPoint = pointIndices.includes(0);
|
||||
|
||||
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],
|
||||
);
|
||||
// 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 {
|
||||
points: normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
} = getNormalizedPoints({ points: nextPoints });
|
||||
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;
|
||||
}, []);
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
app.scene,
|
||||
normalizedPoints,
|
||||
scene,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -1371,27 +1297,16 @@ export class LinearElementEditor {
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
addedPoints: LocalPoint[],
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
) {
|
||||
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 offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
normalizedPoints,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -1408,37 +1323,17 @@ 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 updatedOriginPoint =
|
||||
const [deltaX, deltaY] =
|
||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||
|
||||
const [offsetX, offsetY] = updatedOriginPoint;
|
||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||
deltaX - points[0][0],
|
||||
deltaY - points[0][1],
|
||||
);
|
||||
|
||||
const nextPoints = isElbowArrow(element)
|
||||
? [
|
||||
@@ -1608,7 +1503,6 @@ 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;
|
||||
@@ -1617,7 +1511,7 @@ export class LinearElementEditor {
|
||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
const dY = prevCenterY - nextCenterY;
|
||||
const rotatedOffset = pointRotateRads(
|
||||
const rotated = pointRotateRads(
|
||||
pointFrom(offsetX, offsetY),
|
||||
pointFrom(dX, dY),
|
||||
element.angle,
|
||||
@@ -1625,8 +1519,8 @@ export class LinearElementEditor {
|
||||
scene.mutateElement(element, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
x: element.x + rotatedOffset[0],
|
||||
y: element.y + rotatedOffset[1],
|
||||
x: element.x + rotated[0],
|
||||
y: element.y + rotated[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1637,7 +1531,6 @@ export class LinearElementEditor {
|
||||
referencePoint: LocalPoint,
|
||||
scenePointer: GlobalPoint,
|
||||
gridSize: NullableGridSize,
|
||||
customLineAngle?: number,
|
||||
) {
|
||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
@@ -1663,7 +1556,6 @@ export class LinearElementEditor {
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
return pointRotateRads(
|
||||
@@ -1700,7 +1592,10 @@ export class LinearElementEditor {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "updated"
|
||||
"id" | "version" | "versionNonce" | "updated"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version = updates.version ?? element.version + 1;
|
||||
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||
element.version++;
|
||||
element.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,8 +25,6 @@ import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
@@ -47,7 +45,6 @@ import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -460,10 +457,9 @@ export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
polygon?: ExcalidrawLineElement["polygon"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
const element = {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
@@ -472,17 +468,6 @@ 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>(
|
||||
|
||||
@@ -54,9 +54,9 @@ import {
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./utils";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
@@ -103,6 +104,18 @@ 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);
|
||||
@@ -137,9 +150,6 @@ export const transformElements = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTextElement(element)) {
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
return true;
|
||||
} else if (selectedElements.length > 1) {
|
||||
if (transformHandleType === "rotation") {
|
||||
@@ -272,50 +282,151 @@ export const measureFontSizeFromWidth = (
|
||||
};
|
||||
};
|
||||
|
||||
export const resizeSingleTextElement = (
|
||||
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
scene: Scene,
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
pointerX: number,
|
||||
pointerY: 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;
|
||||
|
||||
const metricsWidth = element.width * (nextHeight / element.height);
|
||||
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
|
||||
const newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
metricsWidth,
|
||||
nextHeight,
|
||||
origElement.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
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 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: metricsWidth,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
});
|
||||
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,
|
||||
@@ -324,7 +435,17 @@ export const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const newWidth = Math.max(minWidth, nextWidth);
|
||||
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 text = wrapText(
|
||||
element.originalText,
|
||||
@@ -337,27 +458,49 @@ export const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const newHeight = metrics.height;
|
||||
const eleNewHeight = metrics.height;
|
||||
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
newWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
const newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
newWidth,
|
||||
newHeight,
|
||||
element.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
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 resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
text,
|
||||
autoResize: false,
|
||||
};
|
||||
@@ -678,18 +821,6 @@ 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);
|
||||
@@ -1387,7 +1518,11 @@ export const resizeMultipleElements = (
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
scene.mutateElement(element, update, {
|
||||
informMutation: true,
|
||||
// needed for the fixed binding point udpate to take effect
|
||||
isDragging: true,
|
||||
});
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
getCurvePathOps,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
type GeometricShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { shouldTestInside } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* get the pure geometric shape of an excalidraw elementw
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "iframe":
|
||||
case "text":
|
||||
case "selection":
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
);
|
||||
}
|
||||
|
||||
case "ellipse":
|
||||
return getEllipseShape(element);
|
||||
|
||||
case "freedraw": {
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> | null => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
if (element.type === "arrow") {
|
||||
return getElementShape(
|
||||
{
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return getElementShape(boundTextElement, elementsMap);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getControlPointsForBezierCurve = <
|
||||
P extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const shape = ShapeCache.generateElementShape(element, null);
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
let currentP = pointFrom<P>(0, 0);
|
||||
let index = 0;
|
||||
let minDistance = Infinity;
|
||||
let controlPoints: P[] | null = null;
|
||||
|
||||
while (index < ops.length) {
|
||||
const { op, data } = ops[index];
|
||||
if (op === "move") {
|
||||
invariant(
|
||||
isPoint(data),
|
||||
"The returned ops is not compatible with a point",
|
||||
);
|
||||
currentP = pointFromPair(data);
|
||||
}
|
||||
if (op === "bcurveTo") {
|
||||
const p0 = currentP;
|
||||
const p1 = pointFrom<P>(data[0], data[1]);
|
||||
const p2 = pointFrom<P>(data[2], data[3]);
|
||||
const p3 = pointFrom<P>(data[4], data[5]);
|
||||
const distance = pointDistance(p3, endPoint);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
controlPoints = [p0, p1, p2, p3];
|
||||
}
|
||||
currentP = p3;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return controlPoints;
|
||||
};
|
||||
|
||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
||||
p0: P,
|
||||
p1: P,
|
||||
p2: P,
|
||||
p3: P,
|
||||
t: number,
|
||||
): P => {
|
||||
const equation = (t: number, idx: number) =>
|
||||
Math.pow(1 - t, 3) * p3[idx] +
|
||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
const tx = equation(t, 0);
|
||||
const ty = equation(t, 1);
|
||||
return pointFrom(tx, ty);
|
||||
};
|
||||
|
||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
||||
if (!controlPoints) {
|
||||
return [];
|
||||
}
|
||||
const pointsOnCurve: P[] = [];
|
||||
let t = 1;
|
||||
// Take 20 points on curve for better accuracy
|
||||
while (t > 0) {
|
||||
const p = getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
);
|
||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
||||
t -= 0.05;
|
||||
}
|
||||
if (pointsOnCurve.length) {
|
||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
||||
}
|
||||
}
|
||||
return pointsOnCurve;
|
||||
};
|
||||
|
||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const arcLengths: number[] = [];
|
||||
arcLengths[0] = 0;
|
||||
const points = getPointsInBezierCurve(element, endPoint);
|
||||
let index = 0;
|
||||
let distance = 0;
|
||||
while (index < points.length - 1) {
|
||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
||||
distance += segmentDistance;
|
||||
arcLengths.push(distance);
|
||||
index++;
|
||||
}
|
||||
|
||||
return arcLengths;
|
||||
};
|
||||
|
||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||
return arcLengths.at(-1) as number;
|
||||
};
|
||||
|
||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
||||
) => {
|
||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||
const pointsCount = arcLengths.length - 1;
|
||||
const curveLength = arcLengths.at(-1) as number;
|
||||
const targetLength = interval * curveLength;
|
||||
let low = 0;
|
||||
let high = pointsCount;
|
||||
let index = 0;
|
||||
// Doing a binary search to find the largest length that is less than the target length
|
||||
while (low < high) {
|
||||
index = Math.floor(low + (high - low) / 2);
|
||||
if (arcLengths[index] < targetLength) {
|
||||
low = index + 1;
|
||||
} else {
|
||||
high = index;
|
||||
}
|
||||
}
|
||||
if (arcLengths[index] > targetLength) {
|
||||
index--;
|
||||
}
|
||||
if (arcLengths[index] === targetLength) {
|
||||
return index / pointsCount;
|
||||
}
|
||||
|
||||
return (
|
||||
1 -
|
||||
(index +
|
||||
(targetLength - arcLengths[index]) /
|
||||
(arcLengths[index + 1] - arcLengths[index])) /
|
||||
pointsCount
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box for a given element
|
||||
*/
|
||||
export const aabbForElement = (
|
||||
element: Readonly<ExcalidrawElement>,
|
||||
offset?: [number, number, number, number],
|
||||
) => {
|
||||
const bbox = {
|
||||
minX: element.x,
|
||||
minY: element.y,
|
||||
maxX: element.x + element.width,
|
||||
maxY: element.y + element.height,
|
||||
midX: element.x + element.width / 2,
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const bounds = [
|
||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
] as Bounds;
|
||||
|
||||
if (offset) {
|
||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||
return [
|
||||
bounds[0] - leftOffset,
|
||||
bounds[1] - topOffset,
|
||||
bounds[2] + rightOffset,
|
||||
bounds[3] + downOffset,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||
element.roundness?.type === ROUNDNESS.LEGACY
|
||||
) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||
|
||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||
|
||||
if (x <= CUTOFF_SIZE) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
return fixedRadiusSize;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
/** supply if you want the loop detection to account for current zoom */
|
||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [first, last] = [points[0], points[points.length - 1]];
|
||||
const distance = pointDistance(first, last);
|
||||
|
||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||
// really close we make the threshold smaller, and vice versa.
|
||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -2,28 +2,14 @@ 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 {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { 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`
|
||||
@@ -31,18 +17,8 @@ export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
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.points.length < 2;
|
||||
}
|
||||
|
||||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
@@ -158,42 +134,13 @@ export const getLockedLinearCursorAlignSize = (
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
customAngle?: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
|
||||
@@ -19,19 +19,9 @@ import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||
|
||||
import {
|
||||
syncInvalidIndicesImmutable,
|
||||
hashElementsVersion,
|
||||
hashString,
|
||||
isInitializedImageElement,
|
||||
isImageElement,
|
||||
} from "./index";
|
||||
import { hashElementsVersion, hashString } from "./index";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
||||
|
||||
export const CaptureUpdateAction = {
|
||||
/**
|
||||
@@ -115,7 +105,7 @@ export class Store {
|
||||
params:
|
||||
| {
|
||||
action: CaptureUpdateActionType;
|
||||
elements: readonly ExcalidrawElement[] | undefined;
|
||||
elements: SceneElementsMap | undefined;
|
||||
appState: AppState | ObservedAppState | undefined;
|
||||
}
|
||||
| {
|
||||
@@ -139,21 +129,13 @@ export class Store {
|
||||
} else {
|
||||
// immediately create an immutable change of the scheduled updates,
|
||||
// compared to the current state, so that they won't mutate later on during batching
|
||||
// also, we have to compare against the current state,
|
||||
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
|
||||
const currentSnapshot = StoreSnapshot.create(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||
action,
|
||||
// let's sync invalid indices first, so that we could detect this change
|
||||
// also have the synced elements immutable, so that we don't mutate elements,
|
||||
// that are already in the scene, otherwise we wouldn't see any change
|
||||
params.elements
|
||||
? syncInvalidIndicesImmutable(params.elements)
|
||||
: undefined,
|
||||
params.elements,
|
||||
params.appState,
|
||||
);
|
||||
|
||||
@@ -231,7 +213,16 @@ export class Store {
|
||||
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
||||
storeDelta = delta;
|
||||
} else {
|
||||
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
|
||||
// calculate the deltas based on the previous and next snapshot
|
||||
const elementsDelta = snapshot.metadata.didElementsChange
|
||||
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
|
||||
: ElementsDelta.empty();
|
||||
|
||||
const appStateDelta = snapshot.metadata.didAppStateChange
|
||||
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
|
||||
: AppStateDelta.empty();
|
||||
|
||||
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
|
||||
}
|
||||
|
||||
if (!storeDelta.isEmpty()) {
|
||||
@@ -514,24 +505,6 @@ export class StoreDelta {
|
||||
return new this(opts.id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the delta between the previous and next snapshot.
|
||||
*/
|
||||
public static calculate(
|
||||
prevSnapshot: StoreSnapshot,
|
||||
nextSnapshot: StoreSnapshot,
|
||||
) {
|
||||
const elementsDelta = nextSnapshot.metadata.didElementsChange
|
||||
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||
: ElementsDelta.empty();
|
||||
|
||||
const appStateDelta = nextSnapshot.metadata.didAppStateChange
|
||||
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||
: AppStateDelta.empty();
|
||||
|
||||
return this.create(elementsDelta, appStateDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a store delta instance from a DTO.
|
||||
*/
|
||||
@@ -551,7 +524,9 @@ export class StoreDelta {
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const elements = ElementsDelta.create(added, removed, updated, {
|
||||
shouldRedistribute: false,
|
||||
});
|
||||
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
}
|
||||
@@ -559,10 +534,27 @@ export class StoreDelta {
|
||||
/**
|
||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static inverse(delta: StoreDelta) {
|
||||
public static inverse(delta: StoreDelta): StoreDelta {
|
||||
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static applyLatestChanges(
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
modifierOptions: "deleted" | "inserted",
|
||||
): StoreDelta {
|
||||
return this.create(
|
||||
delta.elements.applyLatestChanges(elements, modifierOptions),
|
||||
delta.appState,
|
||||
{
|
||||
id: delta.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||
*/
|
||||
@@ -570,9 +562,12 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
delta.elements.applyTo(elements);
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
prevSnapshot.elements,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
delta.appState.applyTo(appState, nextElements);
|
||||
@@ -583,28 +578,6 @@ export class StoreDelta {
|
||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static applyLatestChanges(
|
||||
delta: StoreDelta,
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
modifierOptions?: "deleted" | "inserted",
|
||||
): StoreDelta {
|
||||
return this.create(
|
||||
delta.elements.applyLatestChanges(
|
||||
prevElements,
|
||||
nextElements,
|
||||
modifierOptions,
|
||||
),
|
||||
delta.appState,
|
||||
{
|
||||
id: delta.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@@ -714,10 +687,11 @@ export class StoreSnapshot {
|
||||
nextElements.set(id, changedElement);
|
||||
}
|
||||
|
||||
const nextAppState = getObservedAppState({
|
||||
...this.appState,
|
||||
...change.appState,
|
||||
});
|
||||
const nextAppState = Object.assign(
|
||||
{},
|
||||
this.appState,
|
||||
change.appState,
|
||||
) as ObservedAppState;
|
||||
|
||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||
// by default we assume that change is different from what we have in the snapshot
|
||||
@@ -873,7 +847,7 @@ export class StoreSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if there are any changed elements.
|
||||
* Detect if there any changed elements.
|
||||
*/
|
||||
private detectChangedElements(
|
||||
nextElements: SceneElementsMap,
|
||||
@@ -908,14 +882,6 @@ export class StoreSnapshot {
|
||||
!prevElement || // element was added
|
||||
prevElement.version < nextElement.version // element was updated
|
||||
) {
|
||||
if (
|
||||
isImageElement(nextElement) &&
|
||||
!isInitializedImageElement(nextElement)
|
||||
) {
|
||||
// ignore any updates on uninitialized image elements
|
||||
continue;
|
||||
}
|
||||
|
||||
changedElements.set(nextElement.id, nextElement);
|
||||
}
|
||||
}
|
||||
@@ -978,26 +944,18 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getObservedAppState = (
|
||||
appState: AppState | ObservedAppState,
|
||||
): ObservedAppState => {
|
||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||
const observedAppState = {
|
||||
name: appState.name,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
editingLinearElementId:
|
||||
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
|
||||
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
|
||||
null,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -326,7 +326,10 @@ 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,7 +1,5 @@
|
||||
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";
|
||||
@@ -27,7 +25,6 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
@@ -111,12 +108,6 @@ 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 => {
|
||||
@@ -129,15 +120,6 @@ 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 => {
|
||||
@@ -390,26 +372,3 @@ export const getLinearElementSubType = (
|
||||
}
|
||||
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,8 +195,7 @@ export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawSelectionElement;
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
@@ -297,10 +296,8 @@ export type FixedPointBinding = Merge<
|
||||
}
|
||||
>;
|
||||
|
||||
type Index = number;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
Index,
|
||||
number,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
@@ -329,16 +326,10 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "line";
|
||||
polygon: boolean;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: Index;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
|
||||
+252
-380
@@ -1,346 +1,259 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointFromVector,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
||||
>();
|
||||
|
||||
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
offset: number,
|
||||
): ElementShape | undefined => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.delete(element);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return shapes.get(offset);
|
||||
};
|
||||
|
||||
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: ElementShape,
|
||||
offset: number,
|
||||
) => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
shapes.set(offset, shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||
*
|
||||
* @param element The linear element to deconstruct
|
||||
* @returns The rotated in components.
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
const prevPoint =
|
||||
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
||||
switch (op.op) {
|
||||
case "move":
|
||||
continue;
|
||||
case "lineTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
case "bcurveTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
curves.push(
|
||||
curve<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
default: {
|
||||
console.error("Unknown op type", op.op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves **unrotated**.
|
||||
* line segments and curves.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
const roundness = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
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, []];
|
||||
}
|
||||
|
||||
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] + radius, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||
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] + radius),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||
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] + radius, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||
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] - radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
|
||||
const baseCorners = [
|
||||
curve(
|
||||
left[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
const offsets = [
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
top[0],
|
||||
offset,
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
top[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
offset,
|
||||
), //TOP RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
||||
),
|
||||
right[0],
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
right[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
bottom[1],
|
||||
offset,
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
bottom[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
left[0],
|
||||
offset,
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
const corners = [
|
||||
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]),
|
||||
),
|
||||
),
|
||||
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]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[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]),
|
||||
),
|
||||
),
|
||||
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]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], 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]),
|
||||
),
|
||||
),
|
||||
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]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], 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]),
|
||||
),
|
||||
),
|
||||
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]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], left[0]),
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
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],
|
||||
),
|
||||
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 shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
* Get the building components of a diamond element in the form of
|
||||
* line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||
: (topX - leftX) * 0.01;
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
||||
|
||||
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], []];
|
||||
}
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
@@ -349,135 +262,94 @@ export function deconstructDiamondElement(
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const baseCorners = [
|
||||
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 = [
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
right,
|
||||
right,
|
||||
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
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
bottom,
|
||||
bottom,
|
||||
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
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
left,
|
||||
left,
|
||||
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
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
top,
|
||||
top,
|
||||
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
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
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 shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -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, sans-serif, 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, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,6 @@ 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();
|
||||
});
|
||||
};
|
||||
@@ -53,7 +52,6 @@ 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();
|
||||
});
|
||||
};
|
||||
@@ -204,7 +202,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -218,7 +215,6 @@ describe("aligning", () => {
|
||||
// Add the created group to the current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -320,7 +316,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -335,7 +330,7 @@ describe("aligning", () => {
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
mouse.restorePosition(210, 200);
|
||||
mouse.restorePosition(200, 200);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@@ -346,7 +341,6 @@ 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();
|
||||
});
|
||||
};
|
||||
@@ -460,7 +454,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -473,7 +466,7 @@ describe("aligning", () => {
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(10, 0);
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@@ -489,7 +482,6 @@ describe("aligning", () => {
|
||||
// Select the nested group, the rectangle is already selected
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,10 +11,6 @@ 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;
|
||||
|
||||
@@ -176,12 +172,12 @@ describe("element binding", () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 49,
|
||||
size: 50,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
|
||||
mouse.downAt(49, 49);
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(51, 0);
|
||||
mouse.up(0, 0);
|
||||
|
||||
@@ -248,12 +244,18 @@ describe("element binding", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
|
||||
fireEvent.change(editor, { target: { value: "" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
@@ -283,14 +285,18 @@ describe("element binding", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
const editor = await getTextEditor();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
|
||||
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { hitElementItself } from "../src/collision";
|
||||
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("arrow", () => {
|
||||
UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 124,
|
||||
height: 302,
|
||||
angle: 1.8700426423973724,
|
||||
points: [
|
||||
[0, 0],
|
||||
[120, -198],
|
||||
[-4, -302],
|
||||
] as LocalPoint[],
|
||||
});
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
threshold: 10,
|
||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||
});
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -505,6 +505,8 @@ 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 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pointCenter, pointFrom } from "@excalidraw/math";
|
||||
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -32,11 +33,6 @@ 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,
|
||||
@@ -256,49 +252,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
it("should enter line editor when using double clicked with ctrl key", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
@@ -308,37 +262,6 @@ 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");
|
||||
@@ -423,12 +346,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
],
|
||||
[
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -488,12 +411,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"104.27552",
|
||||
"66.16120",
|
||||
"105.96978",
|
||||
"67.44233",
|
||||
],
|
||||
[
|
||||
"126.95494",
|
||||
"64.56052",
|
||||
"126.08587",
|
||||
"63.29417",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -804,12 +727,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"29.28349",
|
||||
"20.91105",
|
||||
"31.88408",
|
||||
"23.13276",
|
||||
],
|
||||
[
|
||||
"78.86048",
|
||||
"46.12277",
|
||||
"77.74793",
|
||||
"44.57841",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -893,12 +816,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
],
|
||||
[
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -1060,17 +983,19 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": "86.17305",
|
||||
"y": "76.11251",
|
||||
"x": "85.82202",
|
||||
"y": "75.63461",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should match styles for text editor", async () => {
|
||||
it("should match styles for text editor", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(editor).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1087,7 +1012,9 @@ describe("Test Linear Elements", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(arrow.id);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: DEFAULT_TEXT },
|
||||
@@ -1115,7 +1042,9 @@ describe("Test Linear Elements", () => {
|
||||
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(textElement.type).toBe("text");
|
||||
expect(textElement.containerId).toBe(arrow.id);
|
||||
const editor = await getTextEditor();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: DEFAULT_TEXT },
|
||||
@@ -1134,7 +1063,13 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
mouse.doubleClickAt(line.x, line.y);
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// TODO fix #7029 and rewrite this test
|
||||
@@ -1299,7 +1234,9 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
@@ -1325,7 +1262,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.downAt(rect.x, rect.y);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(204, 0);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
@@ -1474,55 +1411,5 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -510,12 +510,12 @@ describe("arrow element", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize(rectangle, "se", [-200, -150]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
@@ -538,11 +538,11 @@ describe("arrow element", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
@@ -819,7 +819,7 @@ describe("image element", () => {
|
||||
|
||||
UI.resize(image, "ne", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 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(140, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import * as constants from "@excalidraw/common";
|
||||
|
||||
import { getPerfectElementSize } from "../src/sizeHelpers";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
@@ -55,4 +57,13 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +258,11 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
app.scene,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
||||
@@ -3,36 +3,18 @@ import { pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
tupleToCoors,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
|
||||
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";
|
||||
@@ -46,54 +28,11 @@ export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, data, app) => {
|
||||
perform: (elements, appState, _, 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;
|
||||
@@ -108,12 +47,6 @@ export const actionFinalize = register({
|
||||
scene,
|
||||
);
|
||||
}
|
||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
@@ -131,107 +64,91 @@ 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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.newElement?.type === "freedraw"
|
||||
? appState.newElement
|
||||
: null;
|
||||
|
||||
if (element) {
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = element;
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
) {
|
||||
scene.mutateElement(element, {
|
||||
points: element.points.slice(0, -1),
|
||||
scene.mutateElement(multiPointElement, {
|
||||
points: multiPointElement.points.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
// 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);
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
// 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;
|
||||
const firstPoint = linePoints[0];
|
||||
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,
|
||||
scene.mutateElement(multiPointElement, {
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
isBindingElement(multiPointElement) &&
|
||||
!isLoop &&
|
||||
multiPointElement.points.length > 1
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!element
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
@@ -258,7 +175,7 @@ export const actionFinalize = register({
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
element
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
@@ -269,19 +186,23 @@ export const actionFinalize = register({
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
element &&
|
||||
multiPointElement &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[element.id]: true,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
element && isLinearElement(element)
|
||||
? new LinearElementEditor(element, arrayToMap(newElements))
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(
|
||||
multiPointElement,
|
||||
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,
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
toggleLinePolygonState,
|
||||
CaptureUpdateAction,
|
||||
} from "@excalidraw/element";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { ButtonIcon } from "../components/ButtonIcon";
|
||||
|
||||
import { newElementWith } from "../../element/src/mutateElement";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
@@ -94,110 +83,3 @@ export const actionToggleLinearEditor = register({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionTogglePolygon = register({
|
||||
name: "togglePolygon",
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
icon: polygonIcon,
|
||||
keywords: ["loop"],
|
||||
label: (elements, appState, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
const allPolygons = !selectedElements.some(
|
||||
(element) => !isLineElement(element) || !element.polygon,
|
||||
);
|
||||
|
||||
return allPolygons
|
||||
? "labels.polygon.breakPolygon"
|
||||
: "labels.polygon.convertToPolygon";
|
||||
},
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every(
|
||||
(element) => isLineElement(element) && element.points.length >= 4,
|
||||
)
|
||||
);
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (selectedElements.some((element) => !isLineElement(element))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetElements = selectedElements as ExcalidrawLineElement[];
|
||||
|
||||
// if one element not a polygon, convert all to polygon
|
||||
const nextPolygonState = targetElements.some((element) => !element.polygon);
|
||||
|
||||
const targetElementsMap = arrayToMap(targetElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
backgroundColor: nextPolygonState
|
||||
? element.backgroundColor
|
||||
: "transparent",
|
||||
...toggleLinePolygonState(element, nextPolygonState),
|
||||
});
|
||||
}),
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
if (
|
||||
selectedElements.length === 0 ||
|
||||
selectedElements.some(
|
||||
(element) =>
|
||||
!isLineElement(element) ||
|
||||
// only show polygon button if every selected element is already
|
||||
// a polygon, effectively showing this button only to allow for
|
||||
// disabling the polygon state
|
||||
!element.polygon ||
|
||||
element.points.length < 3,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allPolygon = selectedElements.every(
|
||||
(element) => isLineElement(element) && element.polygon,
|
||||
);
|
||||
|
||||
const label = t(
|
||||
allPolygon
|
||||
? "labels.polygon.breakPolygon"
|
||||
: "labels.polygon.convertToPolygon",
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonIcon
|
||||
icon={polygonIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
active={allPolygon}
|
||||
onClick={() => updateData(null)}
|
||||
style={{ marginLeft: "auto" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,16 +18,18 @@ import {
|
||||
arrayToMap,
|
||||
getFontFamilyString,
|
||||
getShortcutKey,
|
||||
tupleToCoors,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bindLinearElement,
|
||||
bindPointToSnapToElementOutline,
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -45,18 +47,15 @@ import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { hasStrokeColor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
CaptureUpdateAction,
|
||||
toggleLinePolygonState,
|
||||
} from "@excalidraw/element";
|
||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -350,52 +349,22 @@ export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (!value.currentItemBackgroundColor) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
let nextElements;
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const shouldEnablePolygon =
|
||||
!isTransparent(value.currentItemBackgroundColor) &&
|
||||
selectedElements.every(
|
||||
(el) => isLineElement(el) && canBecomePolygon(el.points),
|
||||
);
|
||||
|
||||
if (shouldEnablePolygon) {
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
nextElements = elements.map((el) => {
|
||||
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
|
||||
return newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
...toggleLinePolygonState(el, true),
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
} else {
|
||||
nextElements = changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: nextElements,
|
||||
...(value.currentItemBackgroundColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
captureUpdate: !!value.currentItemBackgroundColor
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
@@ -1404,7 +1373,7 @@ export const actionChangeRoundness = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
@@ -1448,7 +1417,6 @@ export const actionChangeRoundness = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
{renderAction("togglePolygon")}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
@@ -1658,16 +1626,63 @@ export const actionChangeArrowType = register({
|
||||
-1,
|
||||
elementsMap,
|
||||
);
|
||||
const startElement =
|
||||
newElement.startBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.startBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
const endElement =
|
||||
newElement.endBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.endBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
const startHoveredElement =
|
||||
!newElement.startBinding &&
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(startGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
const endHoveredElement =
|
||||
!newElement.endBinding &&
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(endGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
const startElement = startHoveredElement
|
||||
? startHoveredElement
|
||||
: newElement.startBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.startBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
const endElement = endHoveredElement
|
||||
? endHoveredElement
|
||||
: newElement.endBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.endBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
|
||||
const finalStartPoint = startHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
newElement,
|
||||
startHoveredElement,
|
||||
"start",
|
||||
)
|
||||
: startGlobalPoint;
|
||||
const finalEndPoint = endHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
newElement,
|
||||
endHoveredElement,
|
||||
"end",
|
||||
)
|
||||
: endGlobalPoint;
|
||||
|
||||
startHoveredElement &&
|
||||
bindLinearElement(
|
||||
newElement,
|
||||
startHoveredElement,
|
||||
"start",
|
||||
app.scene,
|
||||
);
|
||||
endHoveredElement &&
|
||||
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
|
||||
|
||||
const startBinding =
|
||||
startElement && newElement.startBinding
|
||||
@@ -1678,7 +1693,6 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -1691,7 +1705,6 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -1701,7 +1714,7 @@ export const actionChangeArrowType = register({
|
||||
startBinding,
|
||||
endBinding,
|
||||
...updateElbowArrowPoints(newElement, elementsMap, {
|
||||
points: [startGlobalPoint, endGlobalPoint].map(
|
||||
points: [finalStartPoint, finalEndPoint].map(
|
||||
(p): LocalPoint =>
|
||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||
),
|
||||
|
||||
@@ -25,10 +25,6 @@ export const actionToggleSearchMenu = register({
|
||||
predicate: (appState) => appState.gridModeEnabled,
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
if (appState.openDialog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
|
||||
@@ -179,7 +179,6 @@ export class ActionManager {
|
||||
appProps={this.app.props}
|
||||
app={this.app}
|
||||
data={data}
|
||||
renderAction={this.renderAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,8 +142,7 @@ export type ActionName =
|
||||
| "cropEditor"
|
||||
| "wrapSelectionInFrame"
|
||||
| "toggleLassoTool"
|
||||
| "toggleShapeSwitch"
|
||||
| "togglePolygon";
|
||||
| "toggleShapeSwitch";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -152,10 +151,6 @@ export type PanelComponentProps = {
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
app: AppClassProperties;
|
||||
renderAction: (
|
||||
name: ActionName,
|
||||
data?: PanelComponentProps["data"],
|
||||
) => React.JSX.Element | null;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
STATS_PANELS,
|
||||
THEME,
|
||||
DEFAULT_GRID_STEP,
|
||||
isTestEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
@@ -37,7 +36,7 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemRoundness: isTestEnv() ? "sharp" : "round",
|
||||
currentItemRoundness: "round",
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
@@ -109,6 +108,7 @@ export const getDefaultAppState = (): Omit<
|
||||
value: 1 as NormalizedZoomValue,
|
||||
},
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
snapLines: [],
|
||||
@@ -237,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
zenModeEnabled: { browser: true, export: false, server: false },
|
||||
zoom: { browser: true, export: false, server: false },
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
snapLines: { browser: false, export: false, server: false },
|
||||
|
||||
@@ -352,6 +352,7 @@ export const ShapesSwitcher = ({
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ interface ButtonIconProps {
|
||||
/** include standalone style (could interfere with parent styles) */
|
||||
standalone?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
@@ -31,7 +30,6 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
data-testid={testId}
|
||||
className={clsx(className, { standalone, active })}
|
||||
onClick={onClick}
|
||||
style={props.style}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
@@ -293,7 +293,6 @@ function CommandPaletteInner({
|
||||
actionManager.actions.decreaseFontSize,
|
||||
actionManager.actions.toggleLinearEditor,
|
||||
actionManager.actions.cropEditor,
|
||||
actionManager.actions.togglePolygon,
|
||||
actionLink,
|
||||
actionCopyElementLink,
|
||||
actionLinkToElement,
|
||||
@@ -503,6 +502,7 @@ function CommandPaletteInner({
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
@@ -74,6 +73,10 @@ const getHints = ({
|
||||
return t("hints.embeddable");
|
||||
}
|
||||
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (
|
||||
@@ -135,9 +138,7 @@ const getHints = ({
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
}
|
||||
return isLineElement(selectedElements[0])
|
||||
? t("hints.lineEditor_line_info")
|
||||
: t("hints.lineEditor_info");
|
||||
return t("hints.lineEditor_info");
|
||||
}
|
||||
if (
|
||||
!appState.newElement &&
|
||||
|
||||
@@ -297,10 +297,6 @@ export const SearchMenu = () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (app.state.openDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchInputRef.current?.matches(":focus")) {
|
||||
if (app.state.openDialog) {
|
||||
setAppState({
|
||||
|
||||
@@ -7,9 +7,6 @@ import {
|
||||
} from "@excalidraw/element";
|
||||
import { resizeSingleElement } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInResizingFrame } from "@excalidraw/element";
|
||||
import { replaceAllElementsInFrame } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@@ -18,10 +15,7 @@ import type { Scene } from "@excalidraw/element";
|
||||
import DragInput from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
@@ -49,8 +43,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
originalAppState,
|
||||
instantChange,
|
||||
scene,
|
||||
app,
|
||||
setAppState,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
@@ -161,7 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
return;
|
||||
}
|
||||
|
||||
// User types in a value to stats then presses Enter
|
||||
if (nextValue !== undefined) {
|
||||
const nextWidth = Math.max(
|
||||
property === "width"
|
||||
@@ -193,123 +184,52 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
},
|
||||
);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
// Stats slider is dragged
|
||||
{
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (keepAspectRatio) {
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
latestElement,
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle highlighting frame element candidates
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: nextElementsInFrame,
|
||||
});
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
if (keepAspectRatio) {
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
latestElement,
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,7 +269,6 @@ const DimensionDragInput = ({
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { useApp, useExcalidrawSetAppState } from "../App";
|
||||
import { useApp } from "../App";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
@@ -36,15 +36,6 @@ export type DragInputCallbackType<
|
||||
property: P;
|
||||
originalAppState: AppState;
|
||||
setInputValue: (value: number) => void;
|
||||
app: ReturnType<typeof useApp>;
|
||||
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||
}) => void;
|
||||
|
||||
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
|
||||
app: ReturnType<typeof useApp>;
|
||||
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||
originalElements: readonly E[] | null;
|
||||
originalAppState: AppState;
|
||||
}) => void;
|
||||
|
||||
interface StatsDragInputProps<
|
||||
@@ -63,7 +54,6 @@ interface StatsDragInputProps<
|
||||
appState: AppState;
|
||||
/** how many px you need to drag to get 1 unit change */
|
||||
sensitivity?: number;
|
||||
dragFinishedCallback?: DragFinishedCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = <
|
||||
@@ -81,10 +71,8 @@ const StatsDragInput = <
|
||||
scene,
|
||||
appState,
|
||||
sensitivity = 1,
|
||||
dragFinishedCallback,
|
||||
}: StatsDragInputProps<T, E>) => {
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -149,8 +137,6 @@ const StatsDragInput = <
|
||||
property,
|
||||
originalAppState: appState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
app.syncActionResult({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -277,8 +263,6 @@ const StatsDragInput = <
|
||||
scene,
|
||||
originalAppState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
|
||||
stepChange = 0;
|
||||
@@ -303,14 +287,6 @@ const StatsDragInput = <
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
// Notify implementors
|
||||
dragFinishedCallback?.({
|
||||
app,
|
||||
setAppState,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
});
|
||||
|
||||
lastPointer = null;
|
||||
accumulatedChange = 0;
|
||||
stepChange = 0;
|
||||
|
||||
@@ -2,12 +2,7 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
isFrameLikeElement,
|
||||
replaceAllElementsInFrame,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import { updateBoundElements } from "@excalidraw/element";
|
||||
import {
|
||||
rescalePointsInElement,
|
||||
resizeSingleElement,
|
||||
@@ -30,10 +25,7 @@ import DragInput from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit } from "./utils";
|
||||
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
@@ -161,8 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextValue,
|
||||
scene,
|
||||
property,
|
||||
setAppState,
|
||||
app,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||
@@ -249,25 +239,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
shouldInformMutation: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +250,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
const elementsToHighlight: ExcalidrawElement[] = [];
|
||||
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
const elementsInUnit = getElementsInAtomicUnit(
|
||||
@@ -372,63 +342,13 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
shouldInformMutation: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle highlighting frame element candidates
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
elementsToHighlight.push(...nextElementsInFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight,
|
||||
});
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const MultiDimension = ({
|
||||
property,
|
||||
elements,
|
||||
@@ -476,7 +396,6 @@ const MultiDimension = ({
|
||||
appState={appState}
|
||||
property={property}
|
||||
scene={scene}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("binding with linear elements", () => {
|
||||
const inputX = UI.queryStatsProperty("X")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
expect(inputX).not.toBeNull();
|
||||
UI.updateInput(inputX, String("204"));
|
||||
@@ -381,7 +382,8 @@ describe("stats for a non-generic element", () => {
|
||||
it("text element", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor();
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello!");
|
||||
act(() => {
|
||||
editor.blur();
|
||||
@@ -401,23 +403,11 @@ describe("stats for a non-generic element", () => {
|
||||
UI.updateInput(input, "36");
|
||||
expect(text.fontSize).toBe(36);
|
||||
|
||||
// can change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
|
||||
const textHeightBeforeWrapping = text.height;
|
||||
const textBeforeWrapping = text.text;
|
||||
const originalTextBeforeWrapping = textBeforeWrapping;
|
||||
UI.updateInput(width, "30");
|
||||
expect(text.height).toBeGreaterThan(textHeightBeforeWrapping);
|
||||
expect(text.text).not.toBe(textBeforeWrapping);
|
||||
expect(text.originalText).toBe(originalTextBeforeWrapping);
|
||||
// cannot change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
||||
expect(width).toBeUndefined();
|
||||
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
||||
expect(height).toBeUndefined();
|
||||
|
||||
// min font size is 4
|
||||
UI.updateInput(input, "0");
|
||||
@@ -586,7 +576,8 @@ describe("stats for multiple elements", () => {
|
||||
// text, rectangle, frame
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor();
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello!");
|
||||
act(() => {
|
||||
editor.blur();
|
||||
@@ -639,11 +630,12 @@ describe("stats for multiple elements", () => {
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).toBeDefined();
|
||||
|
||||
// changing width does not affect text
|
||||
UI.updateInput(width, "200");
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).toBe(200);
|
||||
expect(text?.width).not.toBe(200);
|
||||
|
||||
UI.updateInput(angle, "40");
|
||||
|
||||
@@ -665,7 +657,6 @@ describe("stats for multiple elements", () => {
|
||||
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@@ -737,196 +728,3 @@ describe("stats for multiple elements", () => {
|
||||
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame resizing behavior", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
API.setElements([]);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
stats = UI.queryStats();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should add shapes to frame when resizing frame to encompass them", () => {
|
||||
// Create a frame
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
// Create a rectangle outside the frame
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle]);
|
||||
|
||||
// Initially, rectangle should not be in the frame
|
||||
expect(rectangle.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Find the width input and update it to encompass the rectangle
|
||||
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(widthInput).toBeDefined();
|
||||
expect(widthInput.value).toBe("100");
|
||||
|
||||
// Resize frame to width 250, which should encompass the rectangle
|
||||
UI.updateInput(widthInput, "250");
|
||||
|
||||
// After resizing, the rectangle should now be part of the frame
|
||||
expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add multiple shapes when frame encompasses them through height resize", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 180,
|
||||
width: 40,
|
||||
height: 40,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle1, rectangle2]);
|
||||
|
||||
// Initially, rectangles should not be in the frame
|
||||
expect(rectangle1.frameId).toBe(null);
|
||||
expect(rectangle2.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Resize frame height to encompass both rectangles
|
||||
const heightInput = UI.queryStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
// Resize frame to height 250, which should encompass both rectangles
|
||||
UI.updateInput(heightInput, "250");
|
||||
|
||||
// After resizing, both rectangles should now be part of the frame
|
||||
expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect shapes that remain outside frame after resize", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const insideRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 120,
|
||||
y: 50,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
const outsideRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 50,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
API.setElements([frame, insideRect, outsideRect]);
|
||||
|
||||
// Initially, both rectangles should not be in the frame
|
||||
expect(insideRect.frameId).toBe(null);
|
||||
expect(outsideRect.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Resize frame width to 200, which should only encompass insideRect
|
||||
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
UI.updateInput(widthInput, "200");
|
||||
|
||||
// After resizing, only insideRect should be in the frame
|
||||
expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
|
||||
import { getBoundTextElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement, isTextElement } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
@@ -41,6 +41,12 @@ export const isPropertyEditable = (
|
||||
element: ExcalidrawElement,
|
||||
property: keyof ExcalidrawElement,
|
||||
) => {
|
||||
if (property === "height" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "width" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "angle" && isFrameLikeElement(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ const getRelevantAppStateProps = (
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
selectionElement: appState.selectionElement,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
selectedLinearElement: appState.selectedLinearElement,
|
||||
|
||||
@@ -100,6 +100,7 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
exportScale: appState.exportScale,
|
||||
|
||||
@@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
|
||||
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
|
||||
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
|
||||
return false;
|
||||
}
|
||||
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
@@ -92,7 +92,7 @@ export const isPointHittingLink = (
|
||||
if (
|
||||
!isMobile &&
|
||||
appState.viewModeEnabled &&
|
||||
hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
|
||||
hitElementBoundingBox(x, y, element, elementsMap)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,21 +129,6 @@ export const PinIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const polygonIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M19 8m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M5 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M15 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M6.5 9.5l3.5 -3" />
|
||||
<path d="M14 5.5l3 1.5" />
|
||||
<path d="M18.5 10l-2.5 7" />
|
||||
<path d="M13.5 17.5l-7 -5" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: lock-open (via Figma)
|
||||
export const UnlockedIcon = createIcon(
|
||||
<g>
|
||||
|
||||
@@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startBinding": {
|
||||
"elementId": "diamond-1",
|
||||
"focus": 0,
|
||||
"gap": 4.535423522449215,
|
||||
"gap": 4.545343408287929,
|
||||
},
|
||||
"strokeColor": "#e67700",
|
||||
"strokeStyle": "solid",
|
||||
@@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"endBinding": {
|
||||
"elementId": "text-2",
|
||||
"focus": 0,
|
||||
"gap": 16,
|
||||
"gap": 14,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -948,7 +948,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
0,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
@@ -996,7 +995,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
0,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
@@ -1540,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"endBinding": {
|
||||
"elementId": "B",
|
||||
"focus": 0,
|
||||
"gap": 32,
|
||||
"gap": 14,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1791,7 +1789,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 120,
|
||||
"x": 187.75450000000004,
|
||||
"x": 187.7545,
|
||||
"y": 44.5,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
normalizeLink,
|
||||
getLineHeight,
|
||||
} from "@excalidraw/common";
|
||||
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { normalizeFixedPoint } from "@excalidraw/element";
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
@@ -324,8 +323,7 @@ const restoreElement = (
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
({ points, x, y } =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
@@ -341,13 +339,6 @@ const restoreElement = (
|
||||
points,
|
||||
x,
|
||||
y,
|
||||
...(isLineElement(element)
|
||||
? {
|
||||
polygon: isValidPolygon(element.points)
|
||||
? element.polygon ?? false
|
||||
: false,
|
||||
}
|
||||
: {}),
|
||||
...getSizeFromPoints(points),
|
||||
});
|
||||
case "arrow": {
|
||||
@@ -360,8 +351,7 @@ const restoreElement = (
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
({ points, x, y } =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
const base = {
|
||||
|
||||
@@ -781,7 +781,7 @@ describe("Test Transform", () => {
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: -0,
|
||||
gap: 25,
|
||||
gap: 14,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
|
||||
@@ -466,7 +466,7 @@ const bindLinearElementToElement = (
|
||||
|
||||
Object.assign(
|
||||
linearElement,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords({
|
||||
LinearElementEditor.getNormalizedPoints({
|
||||
...linearElement,
|
||||
points: newPoints,
|
||||
}),
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
||||
import { getElementLineSegments } from "@excalidraw/element";
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
getBoundTextElement,
|
||||
intersectElementWithLineSegment,
|
||||
isPointInElement,
|
||||
} from "@excalidraw/element";
|
||||
import { lineSegment, pointFrom } from "@excalidraw/math";
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
|
||||
import { getElementShape } from "@excalidraw/element";
|
||||
import { shouldTestInside } from "@excalidraw/element";
|
||||
import { isPointInShape } from "@excalidraw/utils/collision";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
|
||||
import { getBoundTextElementId } from "@excalidraw/element";
|
||||
|
||||
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
||||
import type {
|
||||
ElementsSegmentsMap,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
} from "@excalidraw/math/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
@@ -22,9 +28,15 @@ import type { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
|
||||
import type App from "../components/App";
|
||||
|
||||
// just enough to form a segment; this is sufficient for eraser
|
||||
const POINTS_ON_TRAIL = 2;
|
||||
|
||||
export class EraserTrail extends AnimatedTrail {
|
||||
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||
private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
|
||||
private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
|
||||
new Map();
|
||||
|
||||
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||
super(animationFrameHandler, app, {
|
||||
@@ -67,21 +79,14 @@ export class EraserTrail extends AnimatedTrail {
|
||||
}
|
||||
|
||||
private updateElementsToBeErased(restoreToErase?: boolean) {
|
||||
const eraserPath: GlobalPoint[] =
|
||||
let eraserPath: GlobalPoint[] =
|
||||
super
|
||||
.getCurrentTrail()
|
||||
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
|
||||
|
||||
if (eraserPath.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// for efficiency and avoid unnecessary calculations,
|
||||
// take only POINTS_ON_TRAIL points to form some number of segments
|
||||
const pathSegment = lineSegment<GlobalPoint>(
|
||||
eraserPath[eraserPath.length - 1],
|
||||
eraserPath[eraserPath.length - 2],
|
||||
);
|
||||
eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL);
|
||||
|
||||
const candidateElements = this.app.visibleElements.filter(
|
||||
(el) => !el.locked,
|
||||
@@ -89,13 +94,28 @@ export class EraserTrail extends AnimatedTrail {
|
||||
|
||||
const candidateElementsMap = arrayToMap(candidateElements);
|
||||
|
||||
const pathSegments = eraserPath.reduce((acc, point, index) => {
|
||||
if (index === 0) {
|
||||
return acc;
|
||||
}
|
||||
acc.push(lineSegment(eraserPath[index - 1], point));
|
||||
return acc;
|
||||
}, [] as LineSegment<GlobalPoint>[]);
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const element of candidateElements) {
|
||||
// restore only if already added to the to-be-erased set
|
||||
if (restoreToErase && this.elementsToErase.has(element.id)) {
|
||||
const intersects = eraserTest(
|
||||
pathSegment,
|
||||
pathSegments,
|
||||
element,
|
||||
this.segmentsCache,
|
||||
this.geometricShapesCache,
|
||||
candidateElementsMap,
|
||||
this.app,
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
@@ -128,9 +148,12 @@ export class EraserTrail extends AnimatedTrail {
|
||||
}
|
||||
} else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
|
||||
const intersects = eraserTest(
|
||||
pathSegment,
|
||||
pathSegments,
|
||||
element,
|
||||
this.segmentsCache,
|
||||
this.geometricShapesCache,
|
||||
candidateElementsMap,
|
||||
this.app,
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
@@ -173,37 +196,45 @@ export class EraserTrail extends AnimatedTrail {
|
||||
super.clearTrails();
|
||||
this.elementsToErase.clear();
|
||||
this.groupsToErase.clear();
|
||||
this.segmentsCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const eraserTest = (
|
||||
pathSegment: LineSegment<GlobalPoint>,
|
||||
pathSegments: LineSegment<GlobalPoint>[],
|
||||
element: ExcalidrawElement,
|
||||
elementsSegments: ElementsSegmentsMap,
|
||||
shapesCache: Map<string, GeometricShape<GlobalPoint>>,
|
||||
elementsMap: ElementsMap,
|
||||
app: App,
|
||||
): boolean => {
|
||||
const lastPoint = pathSegment[1];
|
||||
if (
|
||||
shouldTestInside(element) &&
|
||||
isPointInElement(lastPoint, element, elementsMap)
|
||||
) {
|
||||
let shape = shapesCache.get(element.id);
|
||||
|
||||
if (!shape) {
|
||||
shape = getElementShape<GlobalPoint>(element, elementsMap);
|
||||
shapesCache.set(element.id, shape);
|
||||
}
|
||||
|
||||
const lastPoint = pathSegments[pathSegments.length - 1][1];
|
||||
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
let elementSegments = elementsSegments.get(element.id);
|
||||
|
||||
return (
|
||||
intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
|
||||
.length > 0 ||
|
||||
(!!boundTextElement &&
|
||||
intersectElementWithLineSegment(
|
||||
{
|
||||
...boundTextElement,
|
||||
...computeBoundTextPosition(element, boundTextElement, elementsMap),
|
||||
},
|
||||
elementsMap,
|
||||
pathSegment,
|
||||
0,
|
||||
true,
|
||||
).length > 0)
|
||||
if (!elementSegments) {
|
||||
elementSegments = getElementLineSegments(element, elementsMap);
|
||||
elementsSegments.set(element.id, elementSegments);
|
||||
}
|
||||
|
||||
return pathSegments.some((pathSegment) =>
|
||||
elementSegments?.some(
|
||||
(elementSegment) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
pathSegment,
|
||||
elementSegment,
|
||||
app.getElementHitThreshold(),
|
||||
) !== null,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
+45
-108
@@ -4,81 +4,14 @@ import {
|
||||
CaptureUpdateAction,
|
||||
StoreChange,
|
||||
StoreDelta,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { StoreSnapshot, Store } from "@excalidraw/element";
|
||||
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import type { AppState } from "./types";
|
||||
|
||||
export class HistoryDelta extends StoreDelta {
|
||||
/**
|
||||
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||
*/
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: StoreSnapshot,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
|
||||
elements,
|
||||
// used to fallback into local snapshot in case we couldn't apply the delta
|
||||
// due to a missing (force deleted) elements in the scene
|
||||
snapshot.elements,
|
||||
// we don't want to apply the `version` and `versionNonce` properties for history
|
||||
// as we always need to end up with a new version due to collaboration,
|
||||
// approaching each undo / redo as a new user action
|
||||
{
|
||||
excludedProperties: new Set(["version", "versionNonce"]),
|
||||
},
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
|
||||
appState,
|
||||
nextElements,
|
||||
);
|
||||
|
||||
const appliedVisibleChanges =
|
||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||
|
||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||
}
|
||||
|
||||
/**
|
||||
* Overriding once to avoid type casting everywhere.
|
||||
*/
|
||||
public static override calculate(
|
||||
prevSnapshot: StoreSnapshot,
|
||||
nextSnapshot: StoreSnapshot,
|
||||
) {
|
||||
return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overriding once to avoid type casting everywhere.
|
||||
*/
|
||||
public static override inverse(delta: StoreDelta): HistoryDelta {
|
||||
return super.inverse(delta) as HistoryDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overriding once to avoid type casting everywhere.
|
||||
*/
|
||||
public static override applyLatestChanges(
|
||||
delta: StoreDelta,
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
modifierOptions?: "deleted" | "inserted",
|
||||
) {
|
||||
return super.applyLatestChanges(
|
||||
delta,
|
||||
prevElements,
|
||||
nextElements,
|
||||
modifierOptions,
|
||||
) as HistoryDelta;
|
||||
}
|
||||
}
|
||||
class HistoryEntry extends StoreDelta {}
|
||||
|
||||
export class HistoryChangedEvent {
|
||||
constructor(
|
||||
@@ -92,8 +25,8 @@ export class History {
|
||||
[HistoryChangedEvent]
|
||||
>();
|
||||
|
||||
public readonly undoStack: HistoryDelta[] = [];
|
||||
public readonly redoStack: HistoryDelta[] = [];
|
||||
public readonly undoStack: HistoryEntry[] = [];
|
||||
public readonly redoStack: HistoryEntry[] = [];
|
||||
|
||||
public get isUndoStackEmpty() {
|
||||
return this.undoStack.length === 0;
|
||||
@@ -115,16 +48,16 @@ export class History {
|
||||
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
|
||||
*/
|
||||
public record(delta: StoreDelta) {
|
||||
if (delta.isEmpty() || delta instanceof HistoryDelta) {
|
||||
if (delta.isEmpty() || delta instanceof HistoryEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// construct history entry, so once it's emitted, it's not recorded again
|
||||
const historyDelta = HistoryDelta.inverse(delta);
|
||||
const entry = HistoryEntry.inverse(delta);
|
||||
|
||||
this.undoStack.push(historyDelta);
|
||||
this.undoStack.push(entry);
|
||||
|
||||
if (!historyDelta.elements.isEmpty()) {
|
||||
if (!entry.elements.isEmpty()) {
|
||||
// don't reset redo stack on local appState changes,
|
||||
// as a simple click (unselect) could lead to losing all the redo entries
|
||||
// only reset on non empty elements changes!
|
||||
@@ -141,7 +74,7 @@ export class History {
|
||||
elements,
|
||||
appState,
|
||||
() => History.pop(this.undoStack),
|
||||
(entry: HistoryDelta) => History.push(this.redoStack, entry),
|
||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,20 +83,20 @@ export class History {
|
||||
elements,
|
||||
appState,
|
||||
() => History.pop(this.redoStack),
|
||||
(entry: HistoryDelta) => History.push(this.undoStack, entry),
|
||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||
);
|
||||
}
|
||||
|
||||
private perform(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
pop: () => HistoryDelta | null,
|
||||
push: (entry: HistoryDelta) => void,
|
||||
pop: () => HistoryEntry | null,
|
||||
push: (entry: HistoryEntry) => void,
|
||||
): [SceneElementsMap, AppState] | void {
|
||||
try {
|
||||
let historyDelta = pop();
|
||||
let historyEntry = pop();
|
||||
|
||||
if (historyDelta === null) {
|
||||
if (historyEntry === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,47 +108,41 @@ export class History {
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
||||
// iterate through the history entries in case ;they result in no visible changes
|
||||
while (historyDelta) {
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyEntry) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
||||
StoreDelta.applyTo(
|
||||
historyEntry,
|
||||
nextElements,
|
||||
nextAppState,
|
||||
prevSnapshot,
|
||||
);
|
||||
|
||||
const prevElements = prevSnapshot.elements;
|
||||
const nextSnapshot = prevSnapshot.maybeClone(
|
||||
action,
|
||||
nextElements,
|
||||
nextAppState,
|
||||
);
|
||||
|
||||
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||
const delta = HistoryDelta.applyLatestChanges(
|
||||
historyDelta,
|
||||
prevElements,
|
||||
nextElements,
|
||||
);
|
||||
|
||||
if (!delta.isEmpty()) {
|
||||
// schedule immediate capture, so that it's emitted for the sync purposes
|
||||
this.store.scheduleMicroAction({
|
||||
action,
|
||||
change,
|
||||
delta,
|
||||
});
|
||||
|
||||
historyDelta = delta;
|
||||
}
|
||||
// schedule immediate capture, so that it's emitted for the sync purposes
|
||||
this.store.scheduleMicroAction({
|
||||
action,
|
||||
change: StoreChange.create(prevSnapshot, nextSnapshot),
|
||||
delta: historyEntry,
|
||||
});
|
||||
|
||||
prevSnapshot = nextSnapshot;
|
||||
} finally {
|
||||
push(historyDelta);
|
||||
// make sure to always push, even if the delta is corrupted
|
||||
push(historyEntry);
|
||||
}
|
||||
|
||||
if (containsVisibleChange) {
|
||||
break;
|
||||
}
|
||||
|
||||
historyDelta = pop();
|
||||
historyEntry = pop();
|
||||
}
|
||||
|
||||
return [nextElements, nextAppState];
|
||||
@@ -228,7 +155,7 @@ export class History {
|
||||
}
|
||||
}
|
||||
|
||||
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
|
||||
private static pop(stack: HistoryEntry[]): HistoryEntry | null {
|
||||
if (!stack.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -242,8 +169,18 @@ export class History {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static push(stack: HistoryDelta[], entry: HistoryDelta) {
|
||||
const inversedEntry = HistoryDelta.inverse(entry);
|
||||
return stack.push(inversedEntry);
|
||||
private static push(
|
||||
stack: HistoryEntry[],
|
||||
entry: HistoryEntry,
|
||||
prevElements: SceneElementsMap,
|
||||
) {
|
||||
const inversedEntry = HistoryEntry.inverse(entry);
|
||||
const updatedEntry = HistoryEntry.applyLatestChanges(
|
||||
inversedEntry,
|
||||
prevElements,
|
||||
"inserted",
|
||||
);
|
||||
|
||||
return stack.push(updatedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,6 @@ export class LassoTrail extends AnimatedTrail {
|
||||
const { selectedElementIds } = getLassoSelectedElementIds({
|
||||
lassoPath,
|
||||
elements: this.app.visibleElements,
|
||||
elementsMap: this.app.scene.getNonDeletedElementsMap(),
|
||||
elementsSegments: this.elementsSegments,
|
||||
intersectedElements: this.intersectedElements,
|
||||
enclosedElements: this.enclosedElements,
|
||||
|
||||
@@ -3,25 +3,20 @@ import { simplify } from "points-on-curve";
|
||||
import {
|
||||
polygonFromPoints,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
polygonIncludesPointNonZero,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
type Bounds,
|
||||
computeBoundTextPosition,
|
||||
doBoundsIntersect,
|
||||
getBoundTextElement,
|
||||
getElementBounds,
|
||||
intersectElementWithLineSegment,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type {
|
||||
ElementsSegmentsMap,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
} from "@excalidraw/math/types";
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
export const getLassoSelectedElementIds = (input: {
|
||||
lassoPath: GlobalPoint[];
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
elementsSegments: ElementsSegmentsMap;
|
||||
intersectedElements: Set<ExcalidrawElement["id"]>;
|
||||
enclosedElements: Set<ExcalidrawElement["id"]>;
|
||||
@@ -32,7 +27,6 @@ export const getLassoSelectedElementIds = (input: {
|
||||
const {
|
||||
lassoPath,
|
||||
elements,
|
||||
elementsMap,
|
||||
elementsSegments,
|
||||
intersectedElements,
|
||||
enclosedElements,
|
||||
@@ -46,26 +40,8 @@ export const getLassoSelectedElementIds = (input: {
|
||||
const unlockedElements = elements.filter((el) => !el.locked);
|
||||
// as the path might not enclose a shape anymore, clear before checking
|
||||
enclosedElements.clear();
|
||||
intersectedElements.clear();
|
||||
const lassoBounds = lassoPath.reduce(
|
||||
(acc, item) => {
|
||||
return [
|
||||
Math.min(acc[0], item[0]),
|
||||
Math.min(acc[1], item[1]),
|
||||
Math.max(acc[2], item[0]),
|
||||
Math.max(acc[3], item[1]),
|
||||
];
|
||||
},
|
||||
[Infinity, Infinity, -Infinity, -Infinity],
|
||||
) as Bounds;
|
||||
for (const element of unlockedElements) {
|
||||
// First check if the lasso segment intersects the element's axis-aligned
|
||||
// bounding box as it is much faster than checking intersection against
|
||||
// the element's shape
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
|
||||
if (
|
||||
doBoundsIntersect(lassoBounds, elementBounds) &&
|
||||
!intersectedElements.has(element.id) &&
|
||||
!enclosedElements.has(element.id)
|
||||
) {
|
||||
@@ -73,7 +49,7 @@ export const getLassoSelectedElementIds = (input: {
|
||||
if (enclosed) {
|
||||
enclosedElements.add(element.id);
|
||||
} else {
|
||||
const intersects = intersectionTest(path, element, elementsMap);
|
||||
const intersects = intersectionTest(path, element, elementsSegments);
|
||||
if (intersects) {
|
||||
intersectedElements.add(element.id);
|
||||
}
|
||||
@@ -109,34 +85,26 @@ const enclosureTest = (
|
||||
const intersectionTest = (
|
||||
lassoPath: GlobalPoint[],
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
elementsSegments: ElementsSegmentsMap,
|
||||
): boolean => {
|
||||
const lassoSegments = lassoPath
|
||||
.slice(1)
|
||||
.map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
|
||||
.concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
|
||||
const elementSegments = elementsSegments.get(element.id);
|
||||
if (!elementSegments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
const lassoSegments = lassoPath.reduce((acc, point, index) => {
|
||||
if (index === 0) {
|
||||
return acc;
|
||||
}
|
||||
acc.push(lineSegment(lassoPath[index - 1], point));
|
||||
return acc;
|
||||
}, [] as LineSegment<GlobalPoint>[]);
|
||||
|
||||
return lassoSegments.some(
|
||||
(lassoSegment) =>
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lassoSegment,
|
||||
0,
|
||||
true,
|
||||
).length > 0 ||
|
||||
(!!boundTextElement &&
|
||||
intersectElementWithLineSegment(
|
||||
{
|
||||
...boundTextElement,
|
||||
...computeBoundTextPosition(element, boundTextElement, elementsMap),
|
||||
},
|
||||
elementsMap,
|
||||
lassoSegment,
|
||||
0,
|
||||
true,
|
||||
).length > 0),
|
||||
return lassoSegments.some((lassoSegment) =>
|
||||
elementSegments.some(
|
||||
(elementSegment) =>
|
||||
// introduce a bit of tolerance to account for roughness and simplification of paths
|
||||
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -141,10 +141,6 @@
|
||||
"edit": "Edit line",
|
||||
"editArrow": "Edit arrow"
|
||||
},
|
||||
"polygon": {
|
||||
"breakPolygon": "Break polygon",
|
||||
"convertToPolygon": "Convert to polygon"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
@@ -344,9 +340,9 @@
|
||||
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
|
||||
"rotate": "You can constrain angles by holding SHIFT while rotating",
|
||||
"lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
|
||||
"lineEditor_line_info": "Double-click or press Enter to edit points",
|
||||
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
|
||||
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
|
||||
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
||||
"publishLibrary": "Publish your own library",
|
||||
"bindTextToElement": "Press enter to add text",
|
||||
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
|
||||
|
||||
@@ -66,22 +66,12 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.2.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/element": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
@@ -134,5 +124,12 @@
|
||||
"harfbuzzjs": "0.3.6",
|
||||
"jest-diff": "29.7.0",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { THEME, THEME_FILTER } from "@excalidraw/common";
|
||||
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
|
||||
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
|
||||
import { getDiamondPoints } from "@excalidraw/element";
|
||||
import { elementCenterPoint, getCornerRadius } from "@excalidraw/element";
|
||||
import { getCornerRadius } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveCatmullRomQuadraticApproxPoints,
|
||||
curveOffsetPoints,
|
||||
curveTangent,
|
||||
type GlobalPoint,
|
||||
offsetPointsForQuadraticBezier,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
vector,
|
||||
vectorNormal,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "@excalidraw/element/types";
|
||||
@@ -29,14 +31,11 @@ export const fillCircle = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
stroke: boolean,
|
||||
fill = true,
|
||||
stroke = true,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
if (fill) {
|
||||
context.fill();
|
||||
}
|
||||
context.fill();
|
||||
if (stroke) {
|
||||
context.stroke();
|
||||
}
|
||||
@@ -100,14 +99,25 @@ export const bootstrapCanvas = ({
|
||||
function drawCatmullRomQuadraticApprox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: GlobalPoint[],
|
||||
tension = 0.5,
|
||||
segments = 20,
|
||||
) {
|
||||
const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
|
||||
if (pointSets) {
|
||||
for (let i = 0; i < pointSets.length - 1; i++) {
|
||||
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
|
||||
ctx.lineTo(points[0][0], points[0][1]);
|
||||
|
||||
ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1 < 0 ? 0 : i - 1];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
|
||||
|
||||
for (let t = 0; t <= 1; t += 1 / segments) {
|
||||
const t2 = t * t;
|
||||
|
||||
const x =
|
||||
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
|
||||
|
||||
const y =
|
||||
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
|
||||
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,13 +125,35 @@ function drawCatmullRomQuadraticApprox(
|
||||
function drawCatmullRomCubicApprox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: GlobalPoint[],
|
||||
tension = 0.5,
|
||||
segments = 20,
|
||||
) {
|
||||
const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
|
||||
if (pointSets) {
|
||||
for (let i = 0; i < pointSets.length; i++) {
|
||||
const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
|
||||
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
||||
ctx.lineTo(points[0][0], points[0][1]);
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1 < 0 ? 0 : i - 1];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
|
||||
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
|
||||
|
||||
for (let t = 0; t <= 1; t += 1 / segments) {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
|
||||
const x =
|
||||
0.5 *
|
||||
(2 * p1[0] +
|
||||
(-p0[0] + p2[0]) * t +
|
||||
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
|
||||
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
|
||||
|
||||
const y =
|
||||
0.5 *
|
||||
(2 * p1[1] +
|
||||
(-p0[1] + p2[1]) * t +
|
||||
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
|
||||
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
|
||||
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,12 +161,11 @@ function drawCatmullRomCubicApprox(
|
||||
export const drawHighlightForRectWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
padding: number,
|
||||
) => {
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
elementCenterPoint(element),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -153,25 +184,25 @@ export const drawHighlightForRectWithRotation = (
|
||||
context.beginPath();
|
||||
|
||||
{
|
||||
const topLeftApprox = offsetPointsForQuadraticBezier(
|
||||
const topLeftApprox = offsetQuadraticBezier(
|
||||
pointFrom(0, 0 + radius),
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0 + radius, 0),
|
||||
padding,
|
||||
);
|
||||
const topRightApprox = offsetPointsForQuadraticBezier(
|
||||
const topRightApprox = offsetQuadraticBezier(
|
||||
pointFrom(element.width - radius, 0),
|
||||
pointFrom(element.width, 0),
|
||||
pointFrom(element.width, radius),
|
||||
padding,
|
||||
);
|
||||
const bottomRightApprox = offsetPointsForQuadraticBezier(
|
||||
const bottomRightApprox = offsetQuadraticBezier(
|
||||
pointFrom(element.width, element.height - radius),
|
||||
pointFrom(element.width, element.height),
|
||||
pointFrom(element.width - radius, element.height),
|
||||
padding,
|
||||
);
|
||||
const bottomLeftApprox = offsetPointsForQuadraticBezier(
|
||||
const bottomLeftApprox = offsetQuadraticBezier(
|
||||
pointFrom(radius, element.height),
|
||||
pointFrom(0, element.height),
|
||||
pointFrom(0, element.height - radius),
|
||||
@@ -196,25 +227,25 @@ export const drawHighlightForRectWithRotation = (
|
||||
// mask" on a filled shape for the diamond highlight, because stroking creates
|
||||
// sharp inset edges on line joins < 90 degrees.
|
||||
{
|
||||
const topLeftApprox = offsetPointsForQuadraticBezier(
|
||||
const topLeftApprox = offsetQuadraticBezier(
|
||||
pointFrom(0 + radius, 0),
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, 0 + radius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const topRightApprox = offsetPointsForQuadraticBezier(
|
||||
const topRightApprox = offsetQuadraticBezier(
|
||||
pointFrom(element.width, radius),
|
||||
pointFrom(element.width, 0),
|
||||
pointFrom(element.width - radius, 0),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const bottomRightApprox = offsetPointsForQuadraticBezier(
|
||||
const bottomRightApprox = offsetQuadraticBezier(
|
||||
pointFrom(element.width - radius, element.height),
|
||||
pointFrom(element.width, element.height),
|
||||
pointFrom(element.width, element.height - radius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const bottomLeftApprox = offsetPointsForQuadraticBezier(
|
||||
const bottomLeftApprox = offsetQuadraticBezier(
|
||||
pointFrom(0, element.height - radius),
|
||||
pointFrom(0, element.height),
|
||||
pointFrom(radius, element.height),
|
||||
@@ -288,11 +319,10 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
padding: number,
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
elementCenterPoint(element),
|
||||
element.angle,
|
||||
);
|
||||
context.save();
|
||||
@@ -310,40 +340,32 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
const topApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||
),
|
||||
const topApprox = offsetCubicBezier(
|
||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||
padding,
|
||||
);
|
||||
const rightApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||
),
|
||||
const rightApprox = offsetCubicBezier(
|
||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||
padding,
|
||||
);
|
||||
const bottomApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||
),
|
||||
const bottomApprox = offsetCubicBezier(
|
||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||
padding,
|
||||
);
|
||||
const leftApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||
),
|
||||
const leftApprox = offsetCubicBezier(
|
||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||
padding,
|
||||
);
|
||||
|
||||
@@ -351,13 +373,13 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
topApprox[topApprox.length - 1][0],
|
||||
topApprox[topApprox.length - 1][1],
|
||||
);
|
||||
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
|
||||
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, rightApprox);
|
||||
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
|
||||
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, bottomApprox);
|
||||
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
|
||||
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, leftApprox);
|
||||
context.lineTo(topApprox[1][0], topApprox[1][1]);
|
||||
context.lineTo(topApprox[0][0], topApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, topApprox);
|
||||
}
|
||||
|
||||
@@ -373,40 +395,32 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
const topApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||
),
|
||||
const topApprox = offsetCubicBezier(
|
||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const rightApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||
),
|
||||
const rightApprox = offsetCubicBezier(
|
||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const bottomApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||
),
|
||||
const bottomApprox = offsetCubicBezier(
|
||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const leftApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||
),
|
||||
const leftApprox = offsetCubicBezier(
|
||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
|
||||
@@ -414,16 +428,66 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
topApprox[topApprox.length - 1][0],
|
||||
topApprox[topApprox.length - 1][1],
|
||||
);
|
||||
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
|
||||
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, leftApprox);
|
||||
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
|
||||
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, bottomApprox);
|
||||
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
|
||||
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, rightApprox);
|
||||
context.lineTo(topApprox[1][0], topApprox[1][1]);
|
||||
context.lineTo(topApprox[0][0], topApprox[0][1]);
|
||||
drawCatmullRomCubicApprox(context, topApprox);
|
||||
}
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
function offsetCubicBezier(
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
p3: GlobalPoint,
|
||||
offsetDist: number,
|
||||
steps = 20,
|
||||
) {
|
||||
const offsetPoints = [];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const c = curve(p0, p1, p2, p3);
|
||||
const point = bezierEquation(c, t);
|
||||
const tangent = vectorNormalize(curveTangent(c, t));
|
||||
const normal = vectorNormal(tangent);
|
||||
|
||||
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
|
||||
}
|
||||
|
||||
return offsetPoints;
|
||||
}
|
||||
|
||||
function offsetQuadraticBezier(
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
offsetDist: number,
|
||||
steps = 20,
|
||||
) {
|
||||
const offsetPoints = [];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const t1 = 1 - t;
|
||||
const point = pointFrom<GlobalPoint>(
|
||||
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
|
||||
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
|
||||
);
|
||||
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
|
||||
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
|
||||
const tangent = vectorNormalize(vector(tangentX, tangentY));
|
||||
const normal = vectorNormal(tangent);
|
||||
|
||||
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
|
||||
}
|
||||
|
||||
return offsetPoints;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
pointFrom,
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type Radians,
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -163,8 +161,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
radius: number,
|
||||
isSelected: boolean,
|
||||
isPhantomPoint: boolean,
|
||||
isOverlappingPoint: boolean,
|
||||
isPhantomPoint = false,
|
||||
) => {
|
||||
context.strokeStyle = "#5e5ad8";
|
||||
context.setLineDash([]);
|
||||
@@ -179,11 +176,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
context,
|
||||
point[0],
|
||||
point[1],
|
||||
(isOverlappingPoint
|
||||
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||
: radius) / appState.zoom.value,
|
||||
radius / appState.zoom.value,
|
||||
!isPhantomPoint,
|
||||
!isOverlappingPoint || isSelected,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -193,10 +187,16 @@ const renderBindingHighlightForBindableElement = (
|
||||
elementsMap: ElementsMap,
|
||||
zoom: InteractiveCanvasAppState["zoom"],
|
||||
) => {
|
||||
const padding = maxBindingGap(element, element.width, element.height, zoom);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
|
||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||
context.fillStyle = "rgba(0,0,0,.05)";
|
||||
|
||||
// To ensure the binding highlight doesn't overlap the element itself
|
||||
const padding = maxBindingGap(element, element.width, element.height, zoom);
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
@@ -205,23 +205,15 @@ const renderBindingHighlightForBindableElement = (
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
drawHighlightForRectWithRotation(context, element, elementsMap, padding);
|
||||
drawHighlightForRectWithRotation(context, element, padding);
|
||||
break;
|
||||
case "diamond":
|
||||
drawHighlightForDiamondWithRotation(
|
||||
context,
|
||||
padding,
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
drawHighlightForDiamondWithRotation(context, padding, element);
|
||||
break;
|
||||
case "ellipse": {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
|
||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
|
||||
case "ellipse":
|
||||
context.lineWidth =
|
||||
maxBindingGap(element, element.width, element.height, zoom) -
|
||||
FIXED_BINDING_DISTANCE;
|
||||
|
||||
strokeEllipseWithRotation(
|
||||
context,
|
||||
@@ -232,7 +224,6 @@ const renderBindingHighlightForBindableElement = (
|
||||
element.angle,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,7 +253,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
|
||||
index,
|
||||
elementsMap,
|
||||
);
|
||||
fillCircle(context, x, y, threshold, true);
|
||||
fillCircle(context, x, y, threshold);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -451,48 +442,15 @@ const renderLinearPointHandles = (
|
||||
const radius = appState.editingLinearElement
|
||||
? POINT_HANDLE_SIZE
|
||||
: POINT_HANDLE_SIZE / 2;
|
||||
|
||||
const _isElbowArrow = isElbowArrow(element);
|
||||
const _isLineElement = isLineElement(element);
|
||||
|
||||
points.forEach((point, idx) => {
|
||||
if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
|
||||
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOverlappingPoint =
|
||||
idx > 0 &&
|
||||
(idx !== points.length - 1 || !_isLineElement || !element.polygon) &&
|
||||
pointsEqual(
|
||||
point,
|
||||
idx === points.length - 1 ? points[0] : points[idx - 1],
|
||||
2 / appState.zoom.value,
|
||||
);
|
||||
|
||||
let isSelected =
|
||||
const isSelected =
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
// when element is a polygon, highlight the last point as well if first
|
||||
// point is selected since they overlap and the last point tends to be
|
||||
// rendered on top
|
||||
if (
|
||||
_isLineElement &&
|
||||
element.polygon &&
|
||||
!isSelected &&
|
||||
idx === element.points.length - 1 &&
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
|
||||
) {
|
||||
isSelected = true;
|
||||
}
|
||||
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
point,
|
||||
radius,
|
||||
isSelected,
|
||||
false,
|
||||
isOverlappingPoint,
|
||||
);
|
||||
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
||||
});
|
||||
|
||||
// Rendering segment mid points
|
||||
@@ -519,7 +477,6 @@ const renderLinearPointHandles = (
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
!fixedSegments.includes(idx + 1),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -543,7 +500,6 @@ const renderLinearPointHandles = (
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -570,7 +526,7 @@ const renderTransformHandles = (
|
||||
context.strokeStyle = renderConfig.selectionColor;
|
||||
}
|
||||
if (key === "rotation") {
|
||||
fillCircle(context, x + width / 2, y + height / 2, width / 2, true);
|
||||
fillCircle(context, x + width / 2, y + height / 2, width / 2);
|
||||
// prefer round corners if roundRect API is available
|
||||
} else if (context.roundRect) {
|
||||
context.beginPath();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isElementInViewport } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { memoize, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
@@ -71,14 +72,25 @@ export class Renderer {
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
pendingImageElementId,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElementId: ExcalidrawElement["id"] | undefined;
|
||||
pendingImageElementId: AppState["pendingImageElementId"];
|
||||
}) => {
|
||||
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
|
||||
|
||||
for (const element of elements) {
|
||||
if (isImageElement(element)) {
|
||||
if (
|
||||
// => not placed on canvas yet (but in elements array)
|
||||
pendingImageElementId === element.id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (newElementId === element.id) {
|
||||
continue;
|
||||
}
|
||||
@@ -107,6 +119,7 @@ export class Renderer {
|
||||
width,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
pendingImageElementId,
|
||||
// cache-invalidation nonce
|
||||
sceneNonce: _sceneNonce,
|
||||
}: {
|
||||
@@ -121,6 +134,7 @@ export class Renderer {
|
||||
/** note: first render of newElement will always bust the cache
|
||||
* (we'd have to prefilter elements outside of this function) */
|
||||
newElementId: ExcalidrawElement["id"] | undefined;
|
||||
pendingImageElementId: AppState["pendingImageElementId"];
|
||||
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
|
||||
}) => {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
@@ -129,6 +143,7 @@ export class Renderer {
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
pendingImageElementId,
|
||||
});
|
||||
|
||||
const visibleElements = getVisibleCanvasElements({
|
||||
|
||||
@@ -102,14 +102,14 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
expect(dialog).not.toBeNull();
|
||||
|
||||
const selector = ".ttd-dialog-input";
|
||||
let editor = await getTextEditor({ selector, waitForEditor: true });
|
||||
let editor = await getTextEditor(selector, true);
|
||||
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
|
||||
|
||||
expect(editor.textContent).toMatchSnapshot();
|
||||
|
||||
updateTextEditor(editor, "flowchart TD1");
|
||||
editor = await getTextEditor({ selector, waitForEditor: false });
|
||||
editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(editor.textContent).toBe("flowchart TD1");
|
||||
expect(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -105,7 +107,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -149,9 +153,10 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
50,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
@@ -187,7 +192,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -25,7 +27,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 23633383,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
@@ -49,15 +51,17 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1505387817,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1604849351,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 81784553,
|
||||
"versionNonce": 915032327,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
@@ -81,7 +85,9 @@ exports[`move element > rectangle 5`] = `
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -89,7 +95,7 @@ exports[`move element > rectangle 5`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1014066025,
|
||||
"versionNonce": 1116226695,
|
||||
"width": 30,
|
||||
"x": 0,
|
||||
"y": 40,
|
||||
@@ -118,7 +124,9 @@ exports[`move element > rectangles with binding arrow 5`] = `
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -126,7 +134,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1006504105,
|
||||
"versionNonce": 1723083209,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -155,15 +163,17 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1116226695,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1150084233,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1984422985,
|
||||
"versionNonce": 745419401,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
@@ -186,7 +196,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "81.40630",
|
||||
"height": "87.29887",
|
||||
"id": "id6",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -200,15 +210,15 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
0,
|
||||
],
|
||||
[
|
||||
"81.00000",
|
||||
"81.40630",
|
||||
"86.85786",
|
||||
"87.29887",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 23633383,
|
||||
"seed": 1604849351,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
@@ -221,9 +231,9 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1573789895,
|
||||
"width": "81.00000",
|
||||
"x": "110.00000",
|
||||
"y": 50,
|
||||
"versionNonce": 1051383431,
|
||||
"width": "86.85786",
|
||||
"x": "107.07107",
|
||||
"y": "47.07107",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 8,
|
||||
"versionNonce": 1604849351,
|
||||
"versionNonce": 400692809,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
@@ -93,9 +93,10 @@ exports[`multi point mode in linear elements > line 3`] = `
|
||||
110,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
@@ -105,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 8,
|
||||
"versionNonce": 1604849351,
|
||||
"versionNonce": 400692809,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user