Compare commits

..

33 Commits

Author SHA1 Message Date
Márk Tolmács
c141500400
chore: Relocate visualdebug so ESLint doesn't complain (#9668) 2025-06-18 14:45:51 +02:00
Márk Tolmács
8e27de2cdc
fix: Frame dimensions change by stats don't include new elements (#9568) 2025-06-16 14:07:03 +02:00
Márk Tolmács
0a19c93509
fix: Bindings at partially overlapping binding areas (#9536) 2025-06-16 12:30:59 +02:00
Márk Tolmács
958597dfaa
chore: Refactor doBoundsIntersect (#9657) 2025-06-16 12:30:42 +02:00
Marcel Mraz
058918f8e5
feat: capture images after they initialize (#9643)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-15 23:43:14 +02:00
Spawn
3f194918e6
feat: add mulitplatform Docker image support (#9594) 2025-06-15 20:11:37 +02:00
Ryan Di
93c92d13e9
feat: wrap texts from stats panel (#9552) 2025-06-14 13:05:24 +02:00
zsviczian
84e96e9393
fix: move doBoundsIntersect from element/src/bounds.ts to common/math/src/utils.ts (#9650)
move doBoundsIntersect to math/utils

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-14 11:01:30 +00:00
zsviczian
320af405e9
fix: move elementCenterPoint from common/src/utils.ts to element/src/bounds.ts (#9647)
move elementCenterPoint from utils to bounds.ts
2025-06-14 12:49:22 +02:00
Marcel Mraz
60512f13d5 Fix broken history when eleemnt in update scene are optional 2025-06-14 12:29:58 +02:00
Márk Tolmács
f0458cc216
fix: Mid-point for rounded linears are not precisely centered (#9544) 2025-06-12 21:08:37 +02:00
Márk Tolmács
9f3fdf5505
fix: Test hook usage in production code (#9645) 2025-06-12 10:39:50 +02:00
Márk Tolmács
f42e1ab64e
perf: Improve elbow arrow indirect binding logic (#9624) 2025-06-11 19:15:48 +02:00
Ashwin Temkar
18808481fd
fix: set cursor to auto when not hovering a point on linear element (#9642)
* fix: set cursor to auto when not hovering a point on linear element #9628

* Simplify hover test for cursor

* Add back comment

* Fix test for hit testing

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-06-11 16:52:02 +02:00
Marcel Mraz
a7b64f02b3
fix: remove image preview on image insertion (#9626)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-10 21:31:11 +02:00
Marcel Mraz
0d4abd1ddc
fix: add history capture for paste and drop of images and embeds (#9605) 2025-06-10 14:28:16 +02:00
Sachintha Lakmin
9e77373c81
fix: add generic font family fallbacks before Segoe UI Emoji to fix glyph rendering on windows (#9425) 2025-06-10 13:43:39 +02:00
Marcel Mraz
d108053351
feat: various delta improvements (#9571) 2025-06-09 09:55:35 +02:00
David Luzar
d4e85a9480
feat: use enter to edit line points & update hints (#9630)
feat: use enter to edit line points & update hints
2025-06-07 18:05:20 +02:00
David Luzar
08cd4c4f9a
test: improve getTextEditor test helper (#9629)
* test: improve getTextEditor test helper

* fix test
2025-06-07 17:45:37 +02:00
cheapster
469caadb87
fix: prevent double-click to edit/create text scenarios on line (#9597)
* fix : double click on line enables line editor

* fix : prevent double-click to edit/create text
when inside line editor

* refactor: use lineCheck instead of arrowCheck in
doubleClick handler to align with updated logic

* fix: replace negative arrowCheck with lineCheck in
dbl click handler and fix double-click bind text
test in linearElementEditor tests

* clean up test

* simplify check

* add tests

* prevent text editing on dblclick when inside arrow editor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-07 17:08:35 +02:00
Márk Tolmács
ca1a4f25e7
feat: Precise hit testing (#9488) 2025-06-07 12:56:32 +02:00
Sujal Gupta
56c05b3099
fix: prevent search menu from opening when dialog is open (#9279) 2025-06-03 15:53:00 +02:00
Aarav Dayal
6c0ff7fc5c
docs: added the correct CSS import for nextjs dynamic first import integration example (#9584)
Added the correct CSS import for nextjs dynamic first import integration example

This is with reference to [this](https://github.com/excalidraw/excalidraw/issues/9562)
2025-05-29 22:03:20 +02:00
Muhammad Khuzaima Umair
7cad3645a0
perf: Simplify normalizeRadians function (#9572)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-28 15:58:42 +02:00
Márk Tolmács
5921ebc416
fix: Regression in long press context menu closure (#9588) 2025-05-28 13:38:47 +02:00
Márk Tolmács
864353be5f
feat: Try to preserve line angle on SHIFT+drag (#9570) 2025-05-27 12:39:45 +02:00
cheapster
db2911c6c4
fix: ghost point issue when moving a shape after dragging a point in the line editor (#9530)
fix: ghost point issue when moving a shape after
dragging a point in the line editor
2025-05-26 21:34:41 +02:00
David Luzar
fc3e062074
feat: do not break polygon on point delete inside line editor (#9580)
* feat: do not break polygon on point delete inside line editor

* fix: polygon point highlighting when selected point == 0
2025-05-26 16:51:47 +02:00
zsviczian
87c87a9fb1
feat: line polygons (#9477)
* Loop Lock/Unlock

* fixed condition. 4 line points are required for the action to be available

* extracted updateLoopLock to improve readability. Removed unnecessary SVG attributes

* lint + added loopLock to restore.ts

* added  loopLock to newElement, updated test snapshots

* lint

* dislocate enpoint when breaking the loop.

* change icon & turn into a state style button

* POC: auto-transform to polygon on bg set

* keep polygon icon constant

* do not split points on de-polygonizing & highlight overlapping points

* rewrite color picker to support no (mixed) colors & fix focus handling

* refactor

* tweak point rendering inside line editor

* do not disable polygon when creating new points via alt

* auto-enable polygon when aligning start/end points

* TBD: remove bg color when disabling polygon

* TBD: only show polygon button for enabled polygons

* fix polygon behavior when adding/removing/moving points within line editor

* convert to polygon when creating line

* labels tweak

* add to command palette

* loopLock -> polygon

* restore `polygon` state on type conversions

* update snapshots

* naming

* break polygon on restore/finalize if invalid & prevent creation

* snapshots

* fix: merge issue and forgotten debug

* snaps

* do not merge points for 3-point lines

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-26 11:14:55 +02:00
Márk Tolmács
4dc205537c
feat: Call actionFinalize at the end of arrow creation and drag (#9453)
* First iter

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Restore binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* More actionFinalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Additional fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* New elbow arrow is removed if  too small

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Remove very small arrows

* Still allow loops

* Restore tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Update history snapshot

* More history snapshot updates

* keep invisible 2-point lines/freedraw elements

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-25 22:28:24 +02:00
David Luzar
cc571c4681
chore: init CLAUDE.md (#9563)
* chore: init CLAUDE.md

* Add Copilot instructions

* update gitignore

* simplify

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-25 21:23:40 +02:00
Marcel Mraz
14d512f321
Fix import.meta.env.MODE being undefined in host apps 2025-05-22 15:25:48 +02:00
119 changed files with 8600 additions and 6067 deletions

View File

@ -1,3 +1,5 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/

View File

@ -1,3 +1,5 @@
MODE="production"
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/

45
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,45 @@
# Project coding standards
## Generic Communication Guidelines
- Be succint and be aware that expansive generative AI answers are costly and slow
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
- Stop apologising if corrected, just provide the correct information or code
- Prefer code unless asked for explanation
- Stop summarizing what you've changed after modifications unless asked for
## TypeScript Guidelines
- Use TypeScript for all new code
- Where possible, prefer implementations without allocation
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
- Prefer immutable data (const, readonly)
- Use optional chaining (?.) and nullish coalescing (??) operators
## React Guidelines
- Use functional components with hooks
- Follow the React hooks rules (no conditional hooks)
- Keep components small and focused
- Use CSS modules for component styling
## Naming Conventions
- Use PascalCase for component names, interfaces, and type aliases
- Use camelCase for variables, functions, and methods
- Use ALL_CAPS for constants
## Error Handling
- Use try/catch blocks for async operations
- Implement proper error boundaries in React components
- Always log errors with contextual information
## Testing
- Always attempt to fix #problems
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
## Types
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}

View File

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

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ coverage
dev-dist dev-dist
html html
meta*.json meta*.json
.claude

34
CLAUDE.md Normal file
View File

@ -0,0 +1,34 @@
# CLAUDE.md
## Project Structure
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
- **`examples/`** - Integration examples (NextJS, browser script)
## Development Workflow
1. **Package Development**: Work in `packages/*` for editor features
2. **App Development**: Work in `excalidraw-app/` for app-specific features
3. **Testing**: Always run `yarn test:update` before committing
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
## Development Commands
```bash
yarn test:typecheck # TypeScript type checking
yarn test:update # Run all tests (with snapshot updates)
yarn fix # Auto-fix formatting and linting issues
```
## Architecture Notes
### Package System
- Uses Yarn workspaces for monorepo management
- Internal packages use path aliases (see `vitest.config.mts`)
- Build system uses esbuild for packages, Vite for the app
- TypeScript throughout with strict configuration

View File

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

View File

@ -40,7 +40,6 @@
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a> </a>
<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> </p>
<div align="center"> <div align="center">

View File

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

View File

@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic( const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw, async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{ {

View File

@ -18,10 +18,10 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
const renderLine = ( const renderLine = (

View File

@ -205,6 +205,7 @@ describe("collaboration", () => {
// with explicit undo (as addition) we expect our item to be restored from the snapshot! // with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([ expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props), expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }), expect.objectContaining({ ...rect2Props, isDeleted: false }),
@ -247,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }), expect.objectContaining({ ...rect2Props, isDeleted: true }),
]); ]);
}); });
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
}); });
}); });

View File

@ -147,19 +147,49 @@ export const FONT_FAMILY = {
Assistant: 10, Assistant: 10,
}; };
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = { export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100, [CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000, [WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
}; };
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = ( export const getFontFamilyFallbacks = (
fontFamily: number, fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => { ): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) { switch (fontFamily) {
case FONT_FAMILY.Excalifont: case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT]; return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default: default:
return [WINDOWS_EMOJI_FALLBACK_FONT]; return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
} }
}; };
@ -477,3 +507,10 @@ export enum UserIdleState {
AWAY = "away", AWAY = "away",
IDLE = "idle", IDLE = "idle",
} }
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;

View File

@ -1,10 +1,9 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import { average } from "@excalidraw/math";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
ExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@ -101,7 +100,6 @@ export const getFontFamilyString = ({
}) => { }) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) { if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id) return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`) .map((x) => `, ${x}`)
.join("")}`; .join("")}`;
@ -712,8 +710,8 @@ export const arrayToObject = <T>(
array: readonly T[], array: readonly T[],
groupBy?: (value: T) => string | number, groupBy?: (value: T) => string | number,
) => ) =>
array.reduce((acc, value) => { array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : String(value)] = value; acc[groupBy ? groupBy(value) : idx] = value;
return acc; return acc;
}, {} as { [key: string]: T }); }, {} as { [key: string]: T });
@ -1238,20 +1236,6 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] => export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value]; Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */ /** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => { export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value); return Array.isArray(value);

View File

@ -1,95 +0,0 @@
import { RoughGenerator } from "roughjs/bin/generator";
import { COLOR_PALETTE } from "@excalidraw/common";
import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { _generateElementShape } from "./Shape";
import { elementWithCanvasCache } from "./renderElement";
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
import type { Drawable } from "roughjs/bin/core";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = _generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}

View File

@ -6,7 +6,6 @@ import {
invariant, invariant,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
elementCenterPoint,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -27,8 +26,6 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -36,12 +33,12 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import { import {
doBoundsIntersect,
getCenterForBounds, getCenterForBounds,
getElementBounds, getElementBounds,
doBoundsIntersect,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
@ -63,7 +60,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement, elementCenterPoint } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@ -109,7 +106,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@ -131,6 +127,7 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
@ -141,6 +138,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@ -150,6 +148,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -176,6 +175,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene, scene: Scene,
elementsMap: ElementsMap,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@ -216,43 +216,29 @@ const bindOrUnbindLinearElementEdge = (
} }
}; };
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
};
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => (["start", "end"] as const).map((edge) => {
getOriginalBindingIfStillCloseOfLinearElementEdge( const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
linearElement, const elementId =
edge as "start" | "end", edge === "start"
elementsMap, ? linearElement.startBinding?.elementId
zoom, : linearElement.endBinding?.elementId;
), if (elementId) {
); const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = ( const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
@ -268,7 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@ -279,7 +265,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: "keep"; : "keep";
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@ -311,7 +297,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@ -322,7 +308,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@ -398,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
); );
}; };
export const maybeSuggestBindingsForLinearElementAtCoords = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
scene: Scene,
zoom: AppState["zoom"],
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): ExcalidrawBindableElement[] =>
Array.from(
pointerCoords.reduce(
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.add(hoveredBindableElement);
}
return acc;
},
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
),
);
export const maybeBindLinearElement = ( export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
@ -441,22 +469,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = ( const normalizePointBinding = (
binding: { focus: number; gap: number }, binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
) => { ) => ({
let gap = binding.gap; ...binding,
const maxGap = maxBindingGap( gap: Math.min(
hoveredElement, binding.gap,
hoveredElement.width, maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
hoveredElement.height, ),
); });
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
@ -488,6 +507,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(),
), ),
}; };
} }
@ -535,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
const isLinearElementSimple = ( const isLinearElementSimple = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
): boolean => linearElement.points.length < 3; ): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
const unbindLinearElement = ( const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
@ -703,8 +723,13 @@ const calculateFocusAndGap = (
); );
return { return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), focus: determineFocusDistance(
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), hoveredElement,
elementsMap,
adjacentPoint,
edgePoint,
),
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
}; };
}; };
@ -874,6 +899,7 @@ export const getHeadingForElbowArrowSnap = (
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@ -882,11 +908,16 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading; return otherPointHeading;
} }
const distance = getDistanceForBinding(origPoint, bindableElement, zoom); const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement)), vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
); );
} }
@ -896,9 +927,10 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToElement(bindableElement, elementsMap, point);
const bindDistance = maxBindingGap( const bindDistance = maxBindingGap(
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
@ -913,12 +945,13 @@ export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
if (isDevEnv() || isTestEnv()) { if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
} }
const aabb = aabbForElement(bindableElement); const aabb = aabbForElement(bindableElement, elementsMap);
const localP = const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>( const globalP = pointFrom<GlobalPoint>(
@ -926,7 +959,7 @@ export const bindPointToSnapToElementOutline = (
arrow.y + localP[1], arrow.y + localP[1],
); );
const edgePoint = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP) ? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP; : globalP;
const elbowed = isElbowArrow(arrow); const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
@ -945,26 +978,31 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP), headingForPointFromElement(bindableElement, aabb, globalP),
); );
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : edgePoint[1], !isHorizontal ? center[1] : snapPoint[1],
);
const intersector = lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
); );
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment( elementsMap,
otherPoint, intersector,
pointFromVector( FIXED_BINDING_DISTANCE,
vectorScale( ).sort(pointDistanceSq)[0];
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
),
)[0];
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment( lineSegment(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@ -991,31 +1029,15 @@ export const bindPointToSnapToElementOutline = (
return edgePoint; return edgePoint;
} }
if (elbowed) { return elbowed ? intersection : edgePoint;
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 = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@ -1108,35 +1130,34 @@ export const avoidRectangularCorner = (
export const snapToMid = ( export const snapToMid = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; 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); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80); const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80);
if ( if (
nonRotated[0] <= x + width / 2 && nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
@ -1146,8 +1167,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
@ -1157,8 +1178,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[1] >= y + height / 2 && nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
@ -1167,7 +1188,7 @@ export const snapToMid = (
angle, angle,
); );
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1; const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>( const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance, x + width / 4 - distance,
y + height / 4 - distance, y + height / 4 - distance,
@ -1184,27 +1205,28 @@ export const snapToMid = (
x + (3 * width) / 4 + distance, x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance, y + (3 * height) / 4 + distance,
); );
if ( if (
pointDistance(topLeft, nonRotated) < pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topLeft, center, angle); return pointRotateRads(topLeft, center, angle);
} }
if ( if (
pointDistance(topRight, nonRotated) < pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topRight, center, angle); return pointRotateRads(topRight, center, angle);
} }
if ( if (
pointDistance(bottomLeft, nonRotated) < pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomLeft, center, angle); return pointRotateRads(bottomLeft, center, angle);
} }
if ( if (
pointDistance(bottomRight, nonRotated) < pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomRight, center, angle); return pointRotateRads(bottomRight, center, angle);
} }
@ -1239,8 +1261,9 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
@ -1266,6 +1289,7 @@ const updateBoundPoint = (
); );
const focusPointAbsolute = determineFocusPoint( const focusPointAbsolute = determineFocusPoint(
bindableElement, bindableElement,
elementsMap,
binding.focus, binding.focus,
adjacentPoint, adjacentPoint,
); );
@ -1284,7 +1308,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
const center = elementCenterPoint(bindableElement); const center = elementCenterPoint(bindableElement, elementsMap);
const interceptorLength = const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
@ -1292,6 +1316,7 @@ const updateBoundPoint = (
const intersections = [ const intersections = [
...intersectElementWithLineSegment( ...intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@ -1342,6 +1367,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>, linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@ -1353,6 +1379,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap,
); );
const globalMidPoint = pointFrom( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
@ -1396,7 +1423,7 @@ const maybeCalculateNewGapWhenScaling = (
return { ...currentBinding, gap: newGap }; return { ...currentBinding, gap: newGap };
}; };
const getElligibleElementForBindingElement = ( const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
@ -1548,14 +1575,38 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element);
const shape = getElementShape(element, elementsMap); // PERF: Run a cheap test to see if the binding element
return ( // is even close to the element
isPointOnShape(pointFrom(x, y), shape, threshold) || const bounds = [
(fullShape === true && x - threshold,
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) y - threshold,
x + threshold,
y + threshold,
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
); );
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@ -1575,7 +1626,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements // bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32), Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight // keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET, BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
); );
}; };
@ -1586,12 +1637,13 @@ export const maxBindingGap = (
// of the element. // of the element.
const determineFocusDistance = ( const determineFocusDistance = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: GlobalPoint, a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint, b: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (pointsEqual(a, b)) { if (pointsEqual(a, b)) {
return 0; return 0;
@ -1716,12 +1768,13 @@ const determineFocusDistance = (
const determineFocusPoint = ( const determineFocusPoint = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// The oriented, relative distance from the center of `element` of the // The oriented, relative distance from the center of `element` of the
// returned focusPoint // returned focusPoint
focus: number, focus: number,
adjacentPoint: GlobalPoint, adjacentPoint: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (focus === 0) { if (focus === 0) {
return center; return center;
@ -2144,6 +2197,7 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
@ -2152,7 +2206,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
}; };
@ -2176,6 +2230,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
@ -2186,6 +2241,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],

View File

@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./Shape"; import { generateRoughOptions } from "./shape";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache } from "./shape";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
@ -45,7 +45,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getElementShape } from "./shapes"; import { getElementShape } from "./shape";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
@ -102,9 +102,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { static getBounds(
const cachedBounds = ElementBounds.boundsCache.get(element); element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if ( if (
cachedBounds?.version && cachedBounds?.version &&
@ -115,6 +129,23 @@ export class ElementBounds {
) { ) {
return cachedBounds.bounds; return cachedBounds.bounds;
} }
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap); const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, { ElementBounds.boundsCache.set(element, {
@ -553,7 +584,7 @@ const solveQuadratic = (
return [s1, s2]; return [s1, s2];
}; };
const getCubicBezierCurveBound = ( export const getCubicBezierCurveBound = (
p0: GlobalPoint, p0: GlobalPoint,
p1: GlobalPoint, p1: GlobalPoint,
p2: GlobalPoint, p2: GlobalPoint,
@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = ( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => { ): Bounds => {
return ElementBounds.getBounds(element, elementsMap); return ElementBounds.getBounds(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (
@ -1133,6 +1165,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
); );
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const doBoundsIntersect = ( export const doBoundsIntersect = (
bounds1: Bounds | null, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
@ -1146,3 +1243,14 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
}; };
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0,
yOffset: number = 0,
) => {
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
};

View File

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

View File

@ -14,9 +14,8 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Point } from "points-on-curve"; import { type Point } from "points-on-curve";
import { elementCenterPoint } from "@excalidraw/common";
import { import {
elementCenterPoint,
getElementAbsoluteCoords, getElementAbsoluteCoords,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
export const cropElement = ( export const cropElement = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType, transformHandle: TransformHandleType,
naturalWidth: number, naturalWidth: number,
naturalHeight: number, naturalHeight: number,
@ -63,7 +63,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
-element.angle as Radians, -element.angle as Radians,
); );

View File

@ -5,11 +5,12 @@ import {
isDevEnv, isDevEnv,
isShallowEqual, isShallowEqual,
isTestEnv, isTestEnv,
randomInteger,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
@ -18,7 +19,12 @@ import type {
SceneElementsMap, SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; import type {
DTO,
Mutable,
SubtypeOf,
ValueOf,
} from "@excalidraw/common/utility-types";
import type { import type {
AppState, AppState,
@ -51,6 +57,8 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene"; import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@ -73,13 +81,20 @@ export class Delta<T> {
public static create<T>( public static create<T>(
deleted: Partial<T>, deleted: Partial<T>,
inserted: Partial<T>, inserted: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>, modifier?: (
modifierOptions?: "deleted" | "inserted", delta: Partial<T>,
partialType: "deleted" | "inserted",
) => Partial<T>,
modifierOptions?: "deleted" | "inserted" | "both",
) { ) {
const modifiedDeleted = const modifiedDeleted =
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; modifier && modifierOptions !== "inserted"
? modifier(deleted, "deleted")
: deleted;
const modifiedInserted = const modifiedInserted =
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; modifier && modifierOptions !== "deleted"
? modifier(inserted, "inserted")
: inserted;
return new Delta(modifiedDeleted, modifiedInserted); return new Delta(modifiedDeleted, modifiedInserted);
} }
@ -113,11 +128,7 @@ export class Delta<T> {
// - we do this only on previously detected changed elements // - we do this only on previously detected changed elements
// - we do shallow compare only on the first level of properties (not going any deeper) // - we do shallow compare only on the first level of properties (not going any deeper)
// - # of properties is reasonably small // - # of properties is reasonably small
for (const key of this.distinctKeysIterator( for (const key of this.getDifferences(prevObject, nextObject)) {
"full",
prevObject,
nextObject,
)) {
deleted[key as keyof T] = prevObject[key]; deleted[key as keyof T] = prevObject[key];
inserted[key as keyof T] = nextObject[key]; inserted[key as keyof T] = nextObject[key];
} }
@ -256,12 +267,14 @@ export class Delta<T> {
arrayToObject(deletedArray, groupBy), arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy), arrayToObject(insertedArray, groupBy),
), ),
(x) => x,
); );
const insertedDifferences = arrayToObject( const insertedDifferences = arrayToObject(
Delta.getRightDifferences( Delta.getRightDifferences(
arrayToObject(deletedArray, groupBy), arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy), arrayToObject(insertedArray, groupBy),
), ),
(x) => x,
); );
if ( if (
@ -320,6 +333,42 @@ export class Delta<T> {
return !!anyDistinctKey; 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. * Returns sorted object1 keys that have distinct values.
*/ */
@ -346,6 +395,32 @@ export class Delta<T> {
).sort(); ).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. * Iterator comparing values of object properties based on the passed joining strategy.
* *
@ -354,7 +429,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. * 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 {}>( private static *distinctKeysIterator<T extends {}>(
join: "left" | "right" | "full", join: "left" | "right" | "inner" | "full",
object1: T, object1: T,
object2: T, object2: T,
skipShallowCompare = false, skipShallowCompare = false,
@ -369,6 +444,8 @@ export class Delta<T> {
keys = Object.keys(object1); keys = Object.keys(object1);
} else if (join === "right") { } else if (join === "right") {
keys = Object.keys(object2); keys = Object.keys(object2);
} else if (join === "inner") {
keys = Object.keys(object1).filter((key) => key in object2);
} else if (join === "full") { } else if (join === "full") {
keys = Array.from( keys = Array.from(
new Set([...Object.keys(object1), ...Object.keys(object2)]), new Set([...Object.keys(object1), ...Object.keys(object2)]),
@ -382,17 +459,17 @@ export class Delta<T> {
} }
for (const key of keys) { for (const key of keys) {
const object1Value = object1[key as keyof T]; const value1 = object1[key as keyof T];
const object2Value = object2[key as keyof T]; const value2 = object2[key as keyof T];
if (object1Value !== object2Value) { if (value1 !== value2) {
if ( if (
!skipShallowCompare && !skipShallowCompare &&
typeof object1Value === "object" && typeof value1 === "object" &&
typeof object2Value === "object" && typeof value2 === "object" &&
object1Value !== null && value1 !== null &&
object2Value !== null && value2 !== null &&
isShallowEqual(object1Value, object2Value) isShallowEqual(value1, value2)
) { ) {
continue; continue;
} }
@ -617,14 +694,20 @@ export class AppStateDelta implements DeltaContainer<AppState> {
break; break;
case "croppingElementId": { case "croppingElementId": {
const croppingElementId = nextAppState[key]; const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) { if (!croppingElementId) {
// previously there was a croppingElementId (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
nextAppState[key] = null; const element = nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
} }
break; break;
} }
case "editingGroupId": case "editingGroupId":
@ -858,10 +941,17 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>, Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
"seed"
>; export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>;
};
type ApplyToFlags = {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
};
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@ -944,13 +1034,33 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted; }: 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( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean, satifiesSpecialInvariants: (delta: Delta<ElementPartial>) => boolean,
) { ) {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) { if (
!this.satisfiesCommmonInvariants(delta) ||
!satifiesSpecialInvariants(delta)
) {
console.error( console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`, `Broken invariant for "${type}" delta, element "${id}", delta:`,
delta, delta,
@ -986,7 +1096,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial; const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { isDeleted: true } as ElementPartial;
const inserted = {
isDeleted: true,
version: prevElement.version + 1,
versionNonce: randomInteger(),
} as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
deleted, deleted,
@ -1002,7 +1117,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const prevElement = prevElements.get(nextElement.id); const prevElement = prevElements.get(nextElement.id);
if (!prevElement) { if (!prevElement) {
const deleted = { isDeleted: true } as ElementPartial; const deleted = {
isDeleted: true,
version: nextElement.version - 1,
versionNonce: randomInteger(),
} as ElementPartial;
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false, isDeleted: false,
@ -1087,16 +1207,40 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
/** /**
* Update delta/s based on the existing elements. * Update delta/s based on the existing elements.
* *
* @param elements current elements * @param nextElements current elements
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s * @returns new instance with modified delta/s
*/ */
public applyLatestChanges( public applyLatestChanges(
elements: SceneElementsMap, prevElements: SceneElementsMap,
modifierOptions: "deleted" | "inserted", nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): ElementsDelta { ): ElementsDelta {
const modifier = const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => { (
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;
}
const latestPartial: { [key: string]: unknown } = {}; const latestPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial) as Array<keyof typeof partial>) { for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
@ -1120,19 +1264,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {}; const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) { for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id); const prevElement = prevElements.get(id);
const nextElement = nextElements.get(id);
if (existingElement) { let latestDelta: Delta<ElementPartial> | null = null;
const modifiedDelta = Delta.create(
if (prevElement || nextElement) {
latestDelta = Delta.create(
delta.deleted, delta.deleted,
delta.inserted, delta.inserted,
modifier(existingElement), modifier(prevElement, nextElement),
modifierOptions, modifierOptions,
); );
modifiedDeltas[id] = modifiedDelta;
} else { } else {
modifiedDeltas[id] = delta; 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;
} }
} }
@ -1150,12 +1300,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
elementsSnapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
}; };
@ -1164,13 +1317,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
nextElements, nextElements,
elementsSnapshot, snapshot,
options,
flags, flags,
); );
const addedElements = applyDeltas("added", this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas("removed", this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas("updated", this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(elements, nextElements);
@ -1229,18 +1383,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createApplier = private static createApplier =
( (
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"],
flags: { options: ApplyToOptions,
containsVisibleDifference: boolean; flags: ApplyToFlags,
containsZindexDifference: boolean;
},
) => ) =>
( (deltas: Record<string, Delta<ElementPartial>>) => {
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
type,
nextElements, nextElements,
snapshot, snapshot,
flags, flags,
@ -1250,7 +1398,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsDelta.applyDelta(element, delta, flags); const newElement = ElementsDelta.applyDelta(
element,
delta,
options,
flags,
);
nextElements.set(newElement.id, newElement); nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement); acc.set(newElement.id, newElement);
} }
@ -1261,13 +1415,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createGetter = private static createGetter =
( (
type: "added" | "removed" | "updated",
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"],
flags: { flags: ApplyToFlags,
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) => ) =>
(id: string, partial: ElementPartial) => { (id: string, partial: ElementPartial) => {
let element = elements.get(id); let element = elements.get(id);
@ -1281,10 +1431,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
flags.containsZindexDifference = true; flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change // as the element was force deleted, we need to check if adding it back results in a visible change
if ( if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true; flags.containsVisibleDifference = true;
} }
} else { } else {
@ -1304,16 +1451,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
flags: { options: ApplyToOptions,
containsVisibleDifference: boolean; flags: ApplyToFlags,
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) { ) {
const { boundElements, ...directlyApplicablePartial } = delta.inserted; 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);
}
if ( if (
delta.deleted.boundElements?.length || delta.deleted.boundElements?.length ||
@ -1331,19 +1490,6 @@ 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) { if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change // strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
@ -1650,6 +1796,29 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
): [ElementPartial, ElementPartial] { ): [ElementPartial, ElementPartial] {
try { try {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); 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) { } catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements delta.`); console.error(`Couldn't postprocess elements delta.`);
@ -1665,7 +1834,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static stripIrrelevantProps( private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>, partial: Partial<OrderedExcalidrawElement>,
): ElementPartial { ): ElementPartial {
const { id, updated, version, versionNonce, ...strippedPartial } = partial; const { id, updated, ...strippedPartial } = partial;
return strippedPartial; return strippedPartial;
} }

View File

@ -6,27 +6,33 @@ import {
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import { elementCenterPoint } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { elementCenterPoint } from "./bounds";
import type { import type {
ExcalidrawBindableElement, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export const distanceToBindableElement = ( export const distanceToElement = (
element: ExcalidrawBindableElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
switch (element.type) { switch (element.type) {
case "selection":
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
@ -34,11 +40,15 @@ export const distanceToBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
return distanceToRectanguloidElement(element, p); return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond": case "diamond":
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, elementsMap, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
} }
}; };
@ -52,9 +62,10 @@ export const distanceToBindableElement = (
*/ */
const distanceToRectanguloidElement = ( const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
*/ */
const distanceToDiamondElement = ( const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@ -108,12 +120,24 @@ const distanceToDiamondElement = (
*/ */
const distanceToEllipseElement = ( const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint( return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle // Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians), pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2), ellipse(center, element.width / 2, element.height / 2),
); );
}; };
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};

View File

@ -20,6 +20,7 @@ import {
tupleToCoors, tupleToCoors,
getSizeFromPoints, getSizeFromPoints,
isDevEnv, isDevEnv,
arrayToMap,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -29,10 +30,9 @@ import {
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@ -52,7 +52,7 @@ import {
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes"; import { aabbForElement, pointInsideBounds } from "./bounds";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
@ -898,50 +898,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points }; return { points: updates.points ?? arrow.points };
} }
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {
invariant( invariant(
!updates.points || updates.points.length >= 2, !updates.points || updates.points.length >= 2,
@ -1273,6 +1229,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
hoveredStartElement, hoveredStartElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
@ -1286,6 +1243,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
hoveredEndElement, hoveredEndElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
@ -1293,12 +1251,14 @@ const getElbowArrowData = (
endGlobalPoint, endGlobalPoint,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@ -1315,6 +1275,7 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
@ -1327,6 +1288,7 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
@ -1342,6 +1304,7 @@ const getElbowArrowData = (
hoveredEndElement hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
) )
: endPointBounds, : endPointBounds,
@ -1351,6 +1314,7 @@ const getElbowArrowData = (
hoveredStartElement hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
) )
: startPointBounds, : startPointBounds,
@ -1397,8 +1361,8 @@ const getElbowArrowData = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement), hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@ -2229,35 +2193,28 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (element) { if (element && elementsMap) {
const snapPoint = bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (element) { if (element) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( return getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
element, element,
elementsMap ?? arrayToMap([element]),
); );
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint;
} }
return initialPoint; return initialPoint;
@ -2268,6 +2225,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading => ): Heading =>
getHeadingForElbowArrowSnap( getHeadingForElbowArrowSnap(
p, p,
@ -2276,7 +2234,8 @@ const getBindPointHeading = (
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number, number,
number, number,
number, number,
@ -2284,6 +2243,7 @@ const getBindPointHeading = (
], ],
), ),
origPoint, origPoint,
elementsMap,
); );
const getHoveredElement = ( const getHoveredElement = (

View File

@ -21,7 +21,7 @@ import {
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { newArrowElement, newElement } from "./newElement"; import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "./shapes"; import { aabbForElement } from "./bounds";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame"; import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
import { import {
isBindableElement, isBindableElement,
@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>; ) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(
edgePoint[0] + el.x, node,
edgePoint[1] + el.y, aabbForElement(node, elementsMap),
] as Readonly<GlobalPoint>); [edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({ acc.push({
relative, relative,

View File

@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { mutateElement } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
@ -11,6 +11,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap,
} from "./types"; } from "./types";
export class InvalidFractionalIndexError extends Error { export class InvalidFractionalIndexError extends Error {
@ -161,9 +162,15 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements // try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) => const elementsCandidates = elements.map((x) => {
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x, const elementUpdates = elementsUpdates.get(x);
);
if (elementUpdates) {
return { ...x, index: elementUpdates.index };
}
return x;
});
// ensure next indices are valid before mutation, throws on invalid ones // ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices( validateFractionalIndices(
@ -177,8 +184,8 @@ export const syncMovedIndices = (
); );
// split mutation so we don't end up in an incosistent state // split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
} catch (e) { } catch (e) {
// fallback to default sync // fallback to default sync
@ -189,7 +196,7 @@ export const syncMovedIndices = (
}; };
/** /**
* Synchronizes all invalid fractional indices with the array order by mutating passed elements. * Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
* *
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself. * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/ */
@ -200,13 +207,32 @@ export const syncInvalidIndices = (
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
return elements as OrderedExcalidrawElement[]; return elements as OrderedExcalidrawElement[];
}; };
/**
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndicesImmutable = (
elements: readonly ExcalidrawElement[],
): SceneElementsMap | undefined => {
const syncedElements = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index }] of elementsUpdates) {
syncedElements.set(element.id, newElementWith(element, { index }));
}
return syncedElements as SceneElementsMap;
};
/** /**
* Get contiguous groups of indices of passed moved elements. * Get contiguous groups of indices of passed moved elements.
* *

View File

@ -102,9 +102,7 @@ export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
export * from "./Scene"; export * from "./Scene";
export * from "./selection"; export * from "./selection";
export * from "./Shape"; export * from "./shape";
export * from "./ShapeCache";
export * from "./shapes";
export * from "./showSelectedShapeActions"; export * from "./showSelectedShapeActions";
export * from "./sizeHelpers"; export * from "./sizeHelpers";
export * from "./sortElements"; export * from "./sortElements";

View File

@ -7,6 +7,8 @@ import {
type LocalPoint, type LocalPoint,
pointDistance, pointDistance,
vectorFromPoint, vectorFromPoint,
curveLength,
curvePointAtLength,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -18,9 +20,14 @@ import {
getGridPoint, getGridPoint,
invariant, invariant,
tupleToCoors, tupleToCoors,
viewportCoordsToSceneCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { Store } from "@excalidraw/element"; import {
deconstructLinearOrFreeDrawElement,
isPathALoop,
type Store,
} from "@excalidraw/element";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@ -39,6 +46,7 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords,
} from "./binding"; } from "./binding";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -55,18 +63,12 @@ import {
isFixedPointBinding, isFixedPointBinding,
} from "./typeChecks"; } from "./typeChecks";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache, toggleLinePolygonState } from "./shape";
import {
isPathALoop,
getBezierCurveLength,
getControlPointsForBezierCurve,
mapIntervalToBezierT,
getBezierXY,
} from "./shapes";
import { getLockedLinearCursorAlignSize } from "./sizeHelpers"; import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import { isLineElement } from "./typeChecks";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
@ -85,6 +87,35 @@ import type {
PointsPositionUpdates, PointsPositionUpdates,
} from "./types"; } 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 { export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & { public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@ -117,6 +148,7 @@ export class LinearElementEditor {
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
constructor( constructor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
@ -127,7 +159,11 @@ export class LinearElementEditor {
}; };
if (!pointsEqual(element.points[0], pointFrom(0, 0))) { if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element, elementsMap); mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
);
} }
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
@ -150,6 +186,7 @@ export class LinearElementEditor {
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -240,19 +277,15 @@ export class LinearElementEditor {
app: AppClassProperties, app: AppClassProperties,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[],
) => void,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
scene: Scene, ): Pick<AppState, keyof AppState> | null {
): LinearElementEditor | null {
if (!linearElementEditor) { if (!linearElementEditor) {
return null; return null;
} }
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) { if (!element) {
return null; return null;
} }
@ -293,6 +326,12 @@ export class LinearElementEditor {
const selectedIndex = selectedPointsIndices[0]; const selectedIndex = selectedPointsIndices[0];
const referencePoint = const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; 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( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
@ -300,11 +339,12 @@ export class LinearElementEditor {
referencePoint, referencePoint,
pointFrom(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, app.scene,
new Map([ new Map([
[ [
selectedIndex, selectedIndex,
@ -332,7 +372,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, app.scene,
new Map( new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint = const newPointPosition: LocalPoint =
@ -364,46 +404,59 @@ export class LinearElementEditor {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) { if (boundTextElement) {
handleBindTextResize(element, scene, false); handleBindTextResize(element, app.scene, false);
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
let suggestedBindings: ExcalidrawBindableElement[] = [];
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
const firstSelectedIndex = selectedPointsIndices[0] === 0;
const lastSelectedIndex =
selectedPointsIndices[selectedPointsIndices.length - 1] ===
element.points.length - 1;
const coords: { x: number; y: number }[] = []; const coords: { x: number; y: number }[] = [];
const firstSelectedIndex = selectedPointsIndices[0]; if (!firstSelectedIndex !== !lastSelectedIndex) {
if (firstSelectedIndex === 0) { coords.push({ x: scenePointerX, y: scenePointerY });
coords.push( } else {
tupleToCoors( if (firstSelectedIndex) {
LinearElementEditor.getPointGlobalCoordinates( coords.push(
element, tupleToCoors(
element.points[0], LinearElementEditor.getPointGlobalCoordinates(
elementsMap, element,
element.points[0],
elementsMap,
),
), ),
), );
); }
}
const lastSelectedIndex = if (lastSelectedIndex) {
selectedPointsIndices[selectedPointsIndices.length - 1]; coords.push(
if (lastSelectedIndex === element.points.length - 1) { tupleToCoors(
coords.push( LinearElementEditor.getPointGlobalCoordinates(
tupleToCoors( element,
LinearElementEditor.getPointGlobalCoordinates( element.points[
element, selectedPointsIndices[selectedPointsIndices.length - 1]
element.points[lastSelectedIndex], ],
elementsMap, elementsMap,
),
), ),
), );
); }
} }
if (coords.length) { if (coords.length) {
maybeSuggestBinding(element, coords); suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
element,
coords,
app.scene,
app.state.zoom,
);
} }
} }
return { const newLinearElementEditor = {
...linearElementEditor, ...linearElementEditor,
selectedPointsIndices, selectedPointsIndices,
segmentMidPointHoveredCoords: segmentMidPointHoveredCoords:
@ -421,6 +474,16 @@ export class LinearElementEditor {
? lastClickedPoint ? lastClickedPoint
: -1, : -1,
isDragging: true, isDragging: true,
customLineAngle,
};
return {
...app.state,
editingLinearElement: app.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
suggestedBindings,
}; };
} }
@ -435,6 +498,7 @@ export class LinearElementEditor {
): LinearElementEditor { ): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
const { elementId, selectedPointsIndices, isDragging, pointerDownState } = const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement; editingLinearElement;
@ -459,6 +523,18 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
if (isLineElement(element)) {
scene.mutateElement(
element,
{
...toggleLinePolygonState(element, true),
},
{
informMutation: false,
isDragging: false,
},
);
}
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, scene,
@ -478,13 +554,15 @@ export class LinearElementEditor {
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding( ? getHoveredElementForBinding(
tupleToCoors( (selectedPointsIndices?.length ?? 0) > 1
LinearElementEditor.getPointAtIndexGlobalCoordinates( ? tupleToCoors(
element, LinearElementEditor.getPointAtIndexGlobalCoordinates(
selectedPoint!, element,
elementsMap, selectedPoint!,
), elementsMap,
), ),
)
: pointerCoords,
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
@ -503,6 +581,8 @@ export class LinearElementEditor {
return { return {
...editingLinearElement, ...editingLinearElement,
...bindings, ...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding // if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift, // shift, deselect all points except the one clicked. If holding shift,
// toggle the point. // toggle the point.
@ -524,6 +604,7 @@ export class LinearElementEditor {
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
}; };
} }
@ -567,10 +648,7 @@ export class LinearElementEditor {
} }
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element, element,
points[index],
points[index + 1],
index + 1, index + 1,
elementsMap,
); );
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
@ -672,7 +750,18 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint); let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
invariant(
lines.length === 0 && curves.length > 0,
"Only linears built out of curves are supported",
);
invariant(
lines.length + curves.length >= index,
"Invalid segment index while calculating mid point",
);
distance = curveLength<GlobalPoint>(curves[index]);
} }
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4; return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
@ -680,39 +769,42 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint, index: number,
endPoint: GlobalPoint,
endPointIndex: number,
elementsMap: ElementsMap,
): GlobalPoint { ): GlobalPoint {
let segmentMidPoint = pointCenter(startPoint, endPoint); if (isElbowArrow(element)) {
if (element.points.length > 2 && element.roundness) { invariant(
const controlPoints = getControlPointsForBezierCurve( element.points.length >= index,
element, "Invalid segment index while calculating elbow arrow mid point",
element.points[endPointIndex],
); );
if (controlPoints) {
const t = mapIntervalToBezierT(
element,
element.points[endPointIndex],
0.5,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( const p = pointCenter(element.points[index - 1], element.points[index]);
element,
getBezierXY( return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap,
);
}
} }
return segmentMidPoint; const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
invariant(
(lines.length === 0 && curves.length > 0) ||
(lines.length > 0 && curves.length === 0),
"Only linears built out of either segments or curves are supported",
);
invariant(
lines.length + curves.length >= index,
"Invalid segment index while calculating mid point",
);
if (lines.length) {
const segment = lines[index - 1];
return pointCenter(segment[0], segment[1]);
}
if (curves.length) {
const segment = curves[index - 1];
return curvePointAtLength(segment, 0.5);
}
invariant(false, "Invalid segment type while calculating mid point");
} }
static getSegmentMidPointIndex( static getSegmentMidPointIndex(
@ -946,9 +1038,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app.scene, [ LinearElementEditor.deletePoints(element, app, [points.length - 1]);
points.length - 1,
]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@ -999,7 +1089,7 @@ export class LinearElementEditor {
]), ]),
); );
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@ -1142,40 +1232,23 @@ export class LinearElementEditor {
/** /**
* Normalizes line points so that the start point is at [0,0]. This is * Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase. Also returns new x/y to account * expected in various parts of the codebase.
* for the potential normalization. *
* Also returns normalized x and y coords to account for the normalization
* of the points.
*/ */
static getNormalizedPoints(element: ExcalidrawLinearElement): { static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
points: LocalPoint[]; const { points, offsetX, offsetY } = getNormalizedPoints(element);
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return { return {
points: points.map((p) => { points,
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
}; };
} }
// element-mutating methods // element-mutating methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static normalizePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
);
}
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant( invariant(
appState.editingLinearElement, appState.editingLinearElement,
@ -1254,41 +1327,42 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, app: AppClassProperties,
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
let offsetX = 0; const isUncommittedPoint =
let offsetY = 0; app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const isDeletingOriginPoint = pointIndices.includes(0); const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
});
// if deleting first point, make the next to be [0,0] and recalculate const isPolygon = isLineElement(element) && element.polygon;
// positions of the rest with respect to it
if (isDeletingOriginPoint) { // keep polygon intact if deleting start/end point or uncommitted point
const firstNonDeletedPoint = element.points.find((point, idx) => { if (
return !pointIndices.includes(idx); isPolygon &&
}); (isUncommittedPoint ||
if (firstNonDeletedPoint) { pointIndices.includes(0) ||
offsetX = firstNonDeletedPoint[0]; pointIndices.includes(element.points.length - 1))
offsetY = firstNonDeletedPoint[1]; ) {
} nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
} }
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { const {
if (!pointIndices.includes(idx)) { points: normalizedPoints,
acc.push( offsetX,
!acc.length offsetY,
? pointFrom(0, 0) } = getNormalizedPoints({ points: nextPoints });
: pointFrom(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
}, []);
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, app.scene,
nextPoints, normalizedPoints,
offsetX, offsetX,
offsetY, offsetY,
); );
@ -1297,16 +1371,27 @@ export class LinearElementEditor {
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, scene: Scene,
targetPoints: { point: LocalPoint }[], addedPoints: LocalPoint[],
) { ) {
const offsetX = 0; const nextPoints = [...element.points, ...addedPoints];
const offsetY = 0;
if (isLineElement(element) && element.polygon) {
nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
}
const {
points: normalizedPoints,
offsetX,
offsetY,
} = getNormalizedPoints({ points: nextPoints });
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, scene,
nextPoints, normalizedPoints,
offsetX, offsetX,
offsetY, offsetY,
); );
@ -1323,17 +1408,37 @@ export class LinearElementEditor {
) { ) {
const { points } = element; 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 // 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 // which would break the invariant of it being at [0,0], we move
// all the other points in the opposite direction by delta to // all the other points in the opposite direction by delta to
// offset it. We do the same with actual element.x/y position, so // offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user. // this hacks are completely transparent to the user.
const [deltaX, deltaY] =
const updatedOriginPoint =
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0); pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = pointFrom<LocalPoint>(
deltaX - points[0][0], const [offsetX, offsetY] = updatedOriginPoint;
deltaY - points[0][1],
);
const nextPoints = isElbowArrow(element) const nextPoints = isElbowArrow(element)
? [ ? [
@ -1503,6 +1608,7 @@ export class LinearElementEditor {
isDragging: options?.isDragging ?? false, isDragging: options?.isDragging ?? false,
}); });
} else { } else {
// TODO do we need to get precise coords here just to calc centers?
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@ -1511,7 +1617,7 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads( const rotatedOffset = pointRotateRads(
pointFrom(offsetX, offsetY), pointFrom(offsetX, offsetY),
pointFrom(dX, dY), pointFrom(dX, dY),
element.angle, element.angle,
@ -1519,8 +1625,8 @@ export class LinearElementEditor {
scene.mutateElement(element, { scene.mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
x: element.x + rotated[0], x: element.x + rotatedOffset[0],
y: element.y + rotated[1], y: element.y + rotatedOffset[1],
}); });
} }
} }
@ -1531,6 +1637,7 @@ export class LinearElementEditor {
referencePoint: LocalPoint, referencePoint: LocalPoint,
scenePointer: GlobalPoint, scenePointer: GlobalPoint,
gridSize: NullableGridSize, gridSize: NullableGridSize,
customLineAngle?: number,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
@ -1556,6 +1663,7 @@ export class LinearElementEditor {
referencePointCoords[1], referencePointCoords[1],
gridX, gridX,
gridY, gridY,
customLineAngle,
); );
return pointRotateRads( return pointRotateRads(
@ -1592,10 +1700,7 @@ export class LinearElementEditor {
const index = element.points.length / 2 - 1; const index = element.points.length / 2 - 1;
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element, element,
points[index],
points[index + 1],
index + 1, index + 1,
elementsMap,
); );
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;

View File

@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
@ -23,7 +23,7 @@ import type {
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "updated"
>; >;
/** /**
@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; element.version = updates.version ?? element.version + 1;
element.versionNonce = randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
return element; return element;
@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return { return {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
}; };
}; };

View File

@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@ -45,6 +47,7 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
} from "./types"; } from "./types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@ -457,9 +460,10 @@ export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
polygon?: ExcalidrawLineElement["polygon"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
return { const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
lastCommittedPoint: null, lastCommittedPoint: null,
@ -468,6 +472,17 @@ export const newLinearElement = (
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
}; };
if (isLineElement(element)) {
const lineElement: NonDeleted<ExcalidrawLineElement> = {
...element,
polygon: opts.polygon ?? false,
};
return lineElement;
}
return element;
}; };
export const newArrowElement = <T extends boolean>( export const newArrowElement = <T extends boolean>(

View File

@ -54,9 +54,9 @@ import {
isImageElement, isImageElement,
} from "./typeChecks"; } from "./typeChecks";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./shapes"; import { getCornerRadius } from "./utils";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache } from "./shape";
import type { import type {
ExcalidrawElement, ExcalidrawElement,

View File

@ -2,7 +2,6 @@ import {
pointCenter, pointCenter,
normalizeRadians, normalizeRadians,
pointFrom, pointFrom,
pointFromPair,
pointRotateRads, pointRotateRads,
type Radians, type Radians,
type LocalPoint, type LocalPoint,
@ -104,18 +103,6 @@ export const transformElements = (
); );
updateBoundElements(element, scene); updateBoundElements(element, scene);
} }
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
element,
scene,
transformHandleType,
shouldResizeFromCenter,
pointerX,
pointerY,
);
updateBoundElements(element, scene);
return true;
} else if (transformHandleType) { } else if (transformHandleType) {
const elementId = selectedElements[0].id; const elementId = selectedElements[0].id;
const latestElement = elementsMap.get(elementId); const latestElement = elementsMap.get(elementId);
@ -150,6 +137,9 @@ export const transformElements = (
); );
} }
} }
if (isTextElement(element)) {
updateBoundElements(element, scene);
}
return true; return true;
} else if (selectedElements.length > 1) { } else if (selectedElements.length > 1) {
if (transformHandleType === "rotation") { if (transformHandleType === "rotation") {
@ -282,151 +272,50 @@ export const measureFontSizeFromWidth = (
}; };
}; };
const resizeSingleTextElement = ( export const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"], origElement: NonDeleted<ExcalidrawTextElement>,
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
scene: Scene, scene: Scene,
transformHandleType: TransformHandleDirection, transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, nextWidth: number,
pointerY: number, nextHeight: number,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
if (transformHandleType !== "e" && transformHandleType !== "w") { const metricsWidth = element.width * (nextHeight / element.height);
if (transformHandleType.includes("e")) {
scaleX = (rotatedX - x1) / (x2 - x1); const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
} if (metrics === null) {
if (transformHandleType.includes("w")) { return;
scaleX = (x2 - rotatedX) / (x2 - x1);
}
if (transformHandleType.includes("n")) {
scaleY = (y2 - rotatedY) / (y2 - y1);
}
if (transformHandleType.includes("s")) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
} }
const scale = Math.max(scaleX, scaleY); if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
if (scale > 0) { const newOrigin = getResizedOrigin(
const nextWidth = element.width * scale; previousOrigin,
const nextHeight = element.height * scale; origElement.width,
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth); origElement.height,
if (metrics === null) { metricsWidth,
return; nextHeight,
} origElement.angle,
transformHandleType,
const startTopLeft = [x1, y1]; false,
const startBottomRight = [x2, y2]; shouldResizeFromCenter,
const startCenter = [cx, cy];
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = pointFrom<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = pointFrom<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = pointFrom<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
}
if (["s", "n"].includes(transformHandleType)) {
newTopLeft[0] = startCenter[0] - nextWidth / 2;
}
if (["e", "w"].includes(transformHandleType)) {
newTopLeft[1] = startCenter[1] - nextHeight / 2;
}
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
}
const angle = element.angle;
const rotatedTopLeft = pointRotateRads(
newTopLeft,
pointFrom(cx, cy),
angle,
); );
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(
newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
scene.mutateElement(element, { scene.mutateElement(element, {
fontSize: metrics.size, fontSize: metrics.size,
width: nextWidth, width: metricsWidth,
height: nextHeight, height: nextHeight,
x: nextX, x: newOrigin.x,
y: nextY, y: newOrigin.y,
}); });
return;
} }
if (transformHandleType === "e" || transformHandleType === "w") { if (transformHandleType === "e" || transformHandleType === "w") {
const stateAtResizeStart = originalElements.get(element.id)!;
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
stateAtResizeStart.width,
stateAtResizeStart.height,
true,
);
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
element,
element.width,
element.height,
true,
);
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth = getMinTextElementWidth( const minWidth = getMinTextElementWidth(
getFontString({ getFontString({
fontSize: element.fontSize, fontSize: element.fontSize,
@ -435,17 +324,7 @@ const resizeSingleTextElement = (
element.lineHeight, element.lineHeight,
); );
let scaleX = atStartBoundsWidth / boundsCurrentWidth; const newWidth = Math.max(minWidth, nextWidth);
if (transformHandleType.includes("e")) {
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleType.includes("w")) {
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
const newWidth =
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
const text = wrapText( const text = wrapText(
element.originalText, element.originalText,
@ -458,49 +337,27 @@ const resizeSingleTextElement = (
element.lineHeight, element.lineHeight,
); );
const eleNewHeight = metrics.height; const newHeight = metrics.height;
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
getResizedElementAbsoluteCoords(
stateAtResizeStart,
newWidth,
eleNewHeight,
true,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
let newTopLeft = [...startTopLeft] as [number, number]; const newOrigin = getResizedOrigin(
if (["n", "w", "nw"].includes(transformHandleType)) { previousOrigin,
newTopLeft = [ origElement.width,
startBottomRight[0] - Math.abs(newBoundsWidth), origElement.height,
startTopLeft[1], newWidth,
]; newHeight,
} element.angle,
transformHandleType,
// adjust topLeft to new rotation point false,
const angle = stateAtResizeStart.angle; shouldResizeFromCenter,
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
); );
const resizedElement: Partial<ExcalidrawTextElement> = { const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth), width: Math.abs(newWidth),
height: Math.abs(metrics.height), height: Math.abs(metrics.height),
x: newTopLeft[0], x: newOrigin.x,
y: newTopLeft[1], y: newOrigin.y,
text, text,
autoResize: false, autoResize: false,
}; };
@ -821,6 +678,18 @@ export const resizeSingleElement = (
shouldInformMutation?: boolean; shouldInformMutation?: boolean;
} = {}, } = {},
) => { ) => {
if (isTextElement(latestElement) && isTextElement(origElement)) {
return resizeSingleTextElement(
origElement,
latestElement,
scene,
handleDirection,
shouldResizeFromCenter,
nextWidth,
nextHeight,
);
}
let boundTextFont: { fontSize?: number } = {}; let boundTextFont: { fontSize?: number } = {};
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
@ -1518,11 +1387,7 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; 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, { updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,

View File

@ -1,26 +1,65 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import {
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; type GeometricShape,
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
} from "@excalidraw/utils/shape";
import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import {
ROUGHNESS,
isTransparent,
assertNever,
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type {
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types"; AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement";
import { import {
canBecomePolygon,
isElbowArrow, isElbowArrow,
isEmbeddableElement, isEmbeddableElement,
isIframeElement, isIframeElement,
isIframeLikeElement, isIframeLikeElement,
isLinearElement, isLinearElement,
} from "./typeChecks"; } from "./typeChecks";
import { getCornerRadius, isPathALoop } from "./shapes"; import { getCornerRadius, isPathALoop } from "./utils";
import { headingForPointIsHorizontal } from "./heading"; import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement"; import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import {
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
import { shouldTestInside } from "./collision";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -28,12 +67,89 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth]; const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
@ -303,6 +419,182 @@ const getArrowheadShapes = (
} }
}; };
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
switch (element.type) {
case "line":
case "arrow": {
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
return generator.path(generateElbowArrowShape(points, 16), options)
.sets[0].ops;
} else if (!element.roundness) {
return points.map((point, idx) => {
const p = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
case "freedraw": {
if (element.points.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
/** /**
* Generates the roughjs shape for given element. * Generates the roughjs shape for given element.
* *
@ -310,7 +602,7 @@ const getArrowheadShapes = (
* *
* @private * @private
*/ */
export const _generateElementShape = ( const generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator, generator: RoughGenerator,
{ {
@ -611,3 +903,103 @@ const generateElbowArrowShape = (
return d.join(" "); return d.join(" ");
}; };
/**
* get the pure geometric shape of an excalidraw elementw
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
export const toggleLinePolygonState = (
element: ExcalidrawLineElement,
nextPolygonState: boolean,
): {
polygon: ExcalidrawLineElement["polygon"];
points: ExcalidrawLineElement["points"];
} | null => {
const updatedPoints = [...element.points];
if (nextPolygonState) {
if (!canBecomePolygon(element.points)) {
return null;
}
const firstPoint = updatedPoints[0];
const lastPoint = updatedPoints[updatedPoints.length - 1];
const distance = Math.hypot(
firstPoint[0] - lastPoint[0],
firstPoint[1] - lastPoint[1],
);
if (
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
updatedPoints.length < 4
) {
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
} else {
updatedPoints[updatedPoints.length - 1] = pointFrom(
firstPoint[0],
firstPoint[1],
);
}
}
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
const ret = {
polygon: nextPolygonState,
points: updatedPoints,
};
return ret;
};

View File

@ -1,398 +0,0 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
invariant,
elementCenterPoint,
} from "@excalidraw/common";
import {
isPoint,
pointFrom,
pointDistance,
pointFromPair,
pointRotateRads,
pointsEqual,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import {
getClosedCurveShape,
getCurvePathOps,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
type GeometricShape,
} from "@excalidraw/utils/shape";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { shouldTestInside } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement } from "./textElement";
import { ShapeCache } from "./ShapeCache";
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "./types";
/**
* get the pure geometric shape of an excalidraw elementw
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
if (element.type === "arrow") {
return getElementShape(
{
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
),
},
elementsMap,
);
}
return getElementShape(boundTextElement, elementsMap);
}
return null;
};
export const getControlPointsForBezierCurve = <
P extends GlobalPoint | LocalPoint,
>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
const ops = getCurvePathOps(shape[0]);
let currentP = pointFrom<P>(0, 0);
let index = 0;
let minDistance = Infinity;
let controlPoints: P[] | null = null;
while (index < ops.length) {
const { op, data } = ops[index];
if (op === "move") {
invariant(
isPoint(data),
"The returned ops is not compatible with a point",
);
currentP = pointFromPair(data);
}
if (op === "bcurveTo") {
const p0 = currentP;
const p1 = pointFrom<P>(data[0], data[1]);
const p2 = pointFrom<P>(data[2], data[3]);
const p3 = pointFrom<P>(data[4], data[5]);
const distance = pointDistance(p3, endPoint);
if (distance < minDistance) {
minDistance = distance;
controlPoints = [p0, p1, p2, p3];
}
currentP = p3;
}
index++;
}
return controlPoints;
};
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
p0: P,
p1: P,
p2: P,
p3: P,
t: number,
): P => {
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const tx = equation(t, 0);
const ty = equation(t, 1);
return pointFrom(tx, ty);
};
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
if (!controlPoints) {
return [];
}
const pointsOnCurve: P[] = [];
let t = 1;
// Take 20 points on curve for better accuracy
while (t > 0) {
const p = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
pointsOnCurve.push(pointFrom(p[0], p[1]));
t -= 0.05;
}
if (pointsOnCurve.length) {
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
}
}
return pointsOnCurve;
};
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths: number[] = [];
arcLengths[0] = 0;
const points = getPointsInBezierCurve(element, endPoint);
let index = 0;
let distance = 0;
while (index < points.length - 1) {
const segmentDistance = pointDistance(points[index], points[index + 1]);
distance += segmentDistance;
arcLengths.push(distance);
index++;
}
return arcLengths;
};
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
return arcLengths.at(-1) as number;
};
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
const pointsCount = arcLengths.length - 1;
const curveLength = arcLengths.at(-1) as number;
const targetLength = interval * curveLength;
let low = 0;
let high = pointsCount;
let index = 0;
// Doing a binary search to find the largest length that is less than the target length
while (low < high) {
index = Math.floor(low + (high - low) / 2);
if (arcLengths[index] < targetLength) {
low = index + 1;
} else {
high = index;
}
}
if (arcLengths[index] > targetLength) {
index--;
}
if (arcLengths[index] === targetLength) {
return index / pointsCount;
}
return (
1 -
(index +
(targetLength - arcLengths[index]) /
(arcLengths[index + 1] - arcLengths[index])) /
pointsCount
);
};
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
pointInsideBounds(pointFrom(b[0], b[3]), a);
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};

View File

@ -2,14 +2,28 @@ import {
SHIFT_LOCKING_ANGLE, SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
} from "@excalidraw/common"; } 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 type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
} from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types"; 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 // 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' // - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements` // - could also be part of `_clearElements`
@ -17,8 +31,18 @@ export const isInvisiblySmallElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
): boolean => { ): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2; return (
element.points.length < 2 ||
(element.points.length === 2 &&
isArrowElement(element) &&
pointsEqual(
element.points[0],
element.points[element.points.length - 1],
INVISIBLY_SMALL_ELEMENT_SIZE,
))
);
} }
return element.width === 0 && element.height === 0; return element.width === 0 && element.height === 0;
}; };
@ -134,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number, originY: number,
x: number, x: number,
y: number, y: number,
customAngle?: number,
) => { ) => {
let width = x - originX; let width = x - originX;
let height = y - originY; let height = y - originY;
const lockedAngle = const angle = Math.atan2(height, width) as Radians;
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) * let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE; SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) { if (lockedAngle === 0) {
height = 0; height = 0;

View File

@ -19,9 +19,19 @@ import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta"; import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index"; import {
syncInvalidIndicesImmutable,
hashElementsVersion,
hashString,
isInitializedImageElement,
isImageElement,
} from "./index";
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types"; import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export const CaptureUpdateAction = { export const CaptureUpdateAction = {
/** /**
@ -105,7 +115,7 @@ export class Store {
params: params:
| { | {
action: CaptureUpdateActionType; action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined; elements: readonly ExcalidrawElement[] | undefined;
appState: AppState | ObservedAppState | undefined; appState: AppState | ObservedAppState | undefined;
} }
| { | {
@ -129,13 +139,21 @@ export class Store {
} else { } else {
// immediately create an immutable change of the scheduled updates, // immediately create an immutable change of the scheduled updates,
// compared to the current state, so that they won't mutate later on during batching // 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( const currentSnapshot = StoreSnapshot.create(
this.app.scene.getElementsMapIncludingDeleted(), this.app.scene.getElementsMapIncludingDeleted(),
this.app.state, this.app.state,
); );
const scheduledSnapshot = currentSnapshot.maybeClone( const scheduledSnapshot = currentSnapshot.maybeClone(
action, action,
params.elements, // 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.appState, params.appState,
); );
@ -213,16 +231,7 @@ 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 // 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; storeDelta = delta;
} else { } else {
// calculate the deltas based on the previous and next snapshot storeDelta = StoreDelta.calculate(prevSnapshot, 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()) { if (!storeDelta.isEmpty()) {
@ -505,6 +514,24 @@ export class StoreDelta {
return new this(opts.id, elements, appState); 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. * Restore a store delta instance from a DTO.
*/ */
@ -524,9 +551,7 @@ export class StoreDelta {
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
}: DTO<StoreDelta>) { }: 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()); return new this(id, elements, AppStateDelta.empty());
} }
@ -534,27 +559,10 @@ export class StoreDelta {
/** /**
* Inverse store delta, creates new instance of `StoreDelta`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: StoreDelta): StoreDelta { public static inverse(delta: StoreDelta) {
return this.create(delta.elements.inverse(), delta.appState.inverse()); 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. * Apply the delta to the passed elements and appState, does not modify the snapshot.
*/ */
@ -562,12 +570,9 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] =
elements, delta.elements.applyTo(elements);
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@ -578,6 +583,28 @@ export class StoreDelta {
return [nextElements, nextAppState, appliedVisibleChanges]; 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() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@ -687,11 +714,10 @@ export class StoreSnapshot {
nextElements.set(id, changedElement); nextElements.set(id, changedElement);
} }
const nextAppState = Object.assign( const nextAppState = getObservedAppState({
{}, ...this.appState,
this.appState, ...change.appState,
change.appState, });
) as ObservedAppState;
return StoreSnapshot.create(nextElements, nextAppState, { return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot // by default we assume that change is different from what we have in the snapshot
@ -847,7 +873,7 @@ export class StoreSnapshot {
} }
/** /**
* Detect if there any changed elements. * Detect if there are any changed elements.
*/ */
private detectChangedElements( private detectChangedElements(
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
@ -882,6 +908,14 @@ export class StoreSnapshot {
!prevElement || // element was added !prevElement || // element was added
prevElement.version < nextElement.version // element was updated 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); changedElements.set(nextElement.id, nextElement);
} }
} }
@ -944,18 +978,26 @@ const getDefaultObservedAppState = (): ObservedAppState => {
}; };
}; };
export const getObservedAppState = (appState: AppState): ObservedAppState => { export const getObservedAppState = (
appState: AppState | ObservedAppState,
): ObservedAppState => {
const observedAppState = { const observedAppState = {
name: appState.name, name: appState.name,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, 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, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

@ -326,10 +326,7 @@ export const getContainerCenter = (
if (!midSegmentMidpoint) { if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container, container,
points[index],
points[index + 1],
index + 1, index + 1,
elementsMap,
); );
} }
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };

View File

@ -1,5 +1,7 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common"; import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types"; import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types"; import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@ -25,6 +27,7 @@ import type {
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
@ -108,6 +111,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type); return element != null && isLinearElementType(element.type);
}; };
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = ( export const isArrowElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawArrowElement => {
@ -120,6 +129,15 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed; 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 = ( export const isSharpArrow = (
element?: ExcalidrawElement, element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawArrowElement => {
@ -372,3 +390,26 @@ export const getLinearElementSubType = (
} }
return "line"; 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]))
);
};

View File

@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
@ -296,8 +297,10 @@ export type FixedPointBinding = Merge<
} }
>; >;
type Index = number;
export type PointsPositionUpdates = Map< export type PointsPositionUpdates = Map<
number, Index,
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
@ -326,10 +329,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
polygon: boolean;
}>;
export type FixedSegment = { export type FixedSegment = {
start: LocalPoint; start: LocalPoint;
end: LocalPoint; end: LocalPoint;
index: number; index: Index;
}; };
export type ExcalidrawArrowElement = ExcalidrawLinearElement & export type ExcalidrawArrowElement = ExcalidrawLinearElement &

View File

@ -1,259 +1,346 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "@excalidraw/common";
import { import {
curve, curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment, lineSegment,
pointDistance,
pointFrom, pointFrom,
pointFromVector, pointFromArray,
rectangle, rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { Curve, LineSegment } from "@excalidraw/math"; import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { getCornerRadius } from "./shapes";
import { getDiamondPoints } from "./bounds"; import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import type { import type {
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } 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 * Get the building components of a rectanguloid element in the form of
* line segments and curves. * line segments and curves **unrotated**.
* *
* @param element Target rectanguloid element * @param element Target rectanguloid element
* @param offset Optional offset to expand the rectanguloid shape * @param offset Optional offset to expand the rectanguloid shape
* @returns Tuple of line segments (0) and curves (1) * @returns Tuple of **unrotated** line segments (0) and curves (1)
*/ */
export function deconstructRectanguloidElement( export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius( const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height), Math.min(element.width, element.height),
element, element,
); );
if (roundness <= 0) { if (radius === 0) {
const r = rectangle( radius = 0.01;
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( const r = rectangle(
pointFrom(element.x, element.y), pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height), pointFrom(element.x + element.width, element.y + element.height),
); );
const top = lineSegment<GlobalPoint>( const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
); );
const right = lineSegment<GlobalPoint>( const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
); );
const bottom = lineSegment<GlobalPoint>( const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
); );
const left = lineSegment<GlobalPoint>( const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
); );
const offsets = [ const baseCorners = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
curve( curve(
pointFromVector(offsets[0], left[1]), left[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
pointFrom<GlobalPoint>( left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
pointFrom<GlobalPoint>( top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
), ),
pointFromVector(offsets[0], top[0]), top[0],
), // TOP LEFT ), // TOP LEFT
curve( curve(
pointFromVector(offsets[1], top[1]), top[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
pointFrom<GlobalPoint>( top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
pointFrom<GlobalPoint>( right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
), ),
pointFromVector(offsets[1], right[0]), right[0],
), // TOP RIGHT ), // TOP RIGHT
curve( curve(
pointFromVector(offsets[2], right[1]), right[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
pointFrom<GlobalPoint>( right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
pointFrom<GlobalPoint>( bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
), ),
pointFromVector(offsets[2], bottom[1]), bottom[1],
), // BOTTOM RIGHT ), // BOTTOM RIGHT
curve( curve(
pointFromVector(offsets[3], bottom[0]), bottom[0],
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
pointFrom<GlobalPoint>( bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
pointFrom<GlobalPoint>( left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
), ),
pointFromVector(offsets[3], left[0]), left[0],
), // BOTTOM LEFT ), // BOTTOM LEFT
]; ];
const sides = [ const corners =
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), offset > 0
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), ? baseCorners.map(
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), (corner) =>
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), curveCatmullRomCubicApproxPoints(
]; curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
return [sides, corners]; const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
/** /**
* Get the building components of a diamond element in the form of * Get the **unrotated** building components of a diamond element
* line segments and curves as a tuple, in this order. * in the form of line segments and curves as a tuple, in this order.
* *
* @param element The element to deconstruct * @param element The element to deconstruct
* @param offset An optional offset * @param offset An optional offset
* @returns Tuple of line segments (0) and curves (1) * @returns Tuple of line **unrotated** segments (0) and curves (1)
*/ */
export function deconstructDiamondElement( export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const cachedShape = getElementShapesCacheEntry(element, offset);
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) { if (cachedShape) {
const [top, right, bottom, left]: GlobalPoint[] = [ return cachedShape;
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 [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [ const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + topX, element.y + topY),
@ -262,94 +349,135 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
const offsets = [ const baseCorners = [
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( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] - horizontalRadius,
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[0], right), right,
pointFromVector(offsets[0], right), right,
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] + horizontalRadius,
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), ),
), // RIGHT ), // RIGHT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] + verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[1], bottom), bottom,
pointFromVector(offsets[1], bottom), bottom,
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] - verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
), // BOTTOM ), // BOTTOM
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] + horizontalRadius,
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[2], left), left,
pointFromVector(offsets[2], left), left,
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] - horizontalRadius,
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), ),
), // LEFT ), // LEFT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] - verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[3], top), top,
pointFromVector(offsets[3], top), top,
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] + verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), ),
), // TOP ), // TOP
]; ];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [ const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), lineSegment<GlobalPoint>(
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), corners[0][corners[0].length - 1][3],
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), corners[1][0][0],
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), ),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
]; ];
return [sides, corners]; const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};

View File

@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />

View File

@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection // Add the created group to the current selection
mouse.restorePosition(0, 0); mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created // The second rectangle is already selected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down(); mouse.down();
mouse.up(100, 100); mouse.up(100, 100);
mouse.restorePosition(200, 200); mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created // The second group is already selected because it was the last group created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100); mouse.up(100, 100);
// Add group to current selection // Add group to current selection
mouse.restorePosition(0, 0); mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected // Select the nested group, the rectangle is already selected
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };

View File

@ -11,6 +11,10 @@ import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
const { h } = window; const { h } = window;
@ -172,12 +176,12 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: 0, x: 0,
y: 0, y: 0,
size: 50, size: 49,
}); });
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50); mouse.downAt(49, 49);
mouse.moveTo(51, 0); mouse.moveTo(51, 0);
mouse.up(0, 0); mouse.up(0, 0);
@ -244,18 +248,12 @@ describe("element binding", () => {
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "" } }); fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
}); });
@ -285,18 +283,14 @@ describe("element binding", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id); expect(arrow.endBinding?.elementId).toBe(text.id);
}); });

View File

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

View File

@ -505,8 +505,6 @@ describe("group-related duplication", () => {
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50); mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
}); });
// console.log(h.elements);
assertElements(h.elements, [ assertElements(h.elements, [
{ id: frame.id }, { id: frame.id },
{ id: rectangle1.id, frameId: frame.id }, { id: rectangle1.id, frameId: frame.id },

View File

@ -1,6 +1,5 @@
import { pointCenter, pointFrom } from "@excalidraw/math"; import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react"; import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { import {
@ -33,6 +32,11 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src"; import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src"; import { newArrowElement } from "../src";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -252,7 +256,49 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor when using double clicked with ctrl key", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
// ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
@ -262,6 +308,37 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); 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", () => { describe("Inside editor", () => {
it("should not drag line and add midpoint when dragged irrespective of threshold", () => { it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
@ -346,12 +423,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[ [
[ [
"55.96978", "54.27552",
"47.44233", "46.16120",
], ],
[ [
"76.08587", "76.95494",
"43.29417", "44.56052",
], ],
] ]
`); `);
@ -411,12 +488,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"105.96978", "104.27552",
"67.44233", "66.16120",
], ],
[ [
"126.08587", "126.95494",
"63.29417", "64.56052",
], ],
] ]
`); `);
@ -727,12 +804,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"31.88408", "29.28349",
"23.13276", "20.91105",
], ],
[ [
"77.74793", "78.86048",
"44.57841", "46.12277",
], ],
] ]
`); `);
@ -816,12 +893,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"55.96978", "54.27552",
"47.44233", "46.16120",
], ],
[ [
"76.08587", "76.95494",
"43.29417", "44.56052",
], ],
] ]
`); `);
@ -983,19 +1060,17 @@ describe("Test Linear Elements", () => {
); );
expect(position).toMatchInlineSnapshot(` expect(position).toMatchInlineSnapshot(`
{ {
"x": "85.82202", "x": "86.17305",
"y": "75.63461", "y": "76.11251",
} }
`); `);
}); });
}); });
it("should match styles for text editor", () => { it("should match styles for text editor", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).toMatchSnapshot(); expect(editor).toMatchSnapshot();
}); });
@ -1012,9 +1087,7 @@ describe("Test Linear Elements", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id); expect(text.containerId).toBe(arrow.id);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@ -1042,9 +1115,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer; const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text"); expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id); expect(textElement.containerId).toBe(arrow.id);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@ -1063,13 +1134,7 @@ describe("Test Linear Elements", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y); 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 // TODO fix #7029 and rewrite this test
@ -1234,9 +1299,7 @@ describe("Test Linear Elements", () => {
mouse.select(arrow); mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } }); fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@ -1262,7 +1325,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0); expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@ -1411,5 +1474,55 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20); expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).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);
});
}); });
}); });

View File

@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]); UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
const imageWidth = image.width; const imageWidth = image.width;
const scale = 20 / image.height; const scale = 20 / image.height;
@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0); expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();

View File

@ -1,7 +1,5 @@
import { vi } from "vitest"; import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers"; import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3; const EPSILON_DIGITS = 3;
@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).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);
});
});
}); });

View File

@ -258,11 +258,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
element,
app.scene,
selectedPointsIndices,
);
return { return {
elements, elements,

View File

@ -3,18 +3,36 @@ import { pointFrom } from "@excalidraw/math";
import { import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import {
isBindingElement,
isFreeDrawElement,
isLinearElement,
isLineElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { isBindingElement, isLinearElement } from "@excalidraw/element"; import {
KEYS,
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; arrayToMap,
tupleToCoors,
updateActiveTool,
} from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } 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 { t } from "../i18n";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { done } from "../components/icons"; import { done } from "../components/icons";
@ -28,11 +46,54 @@ export const actionFinalize = register({
name: "finalize", name: "finalize",
label: "", label: "",
trackEvent: false, trackEvent: false,
perform: (elements, appState, _, app) => { perform: (elements, appState, data, app) => {
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const { event, sceneCoords } =
(data as {
event?: PointerEvent;
sceneCoords?: { x: number; y: number };
}) ?? {};
const elementsMap = scene.getNonDeletedElementsMap(); 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) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
@ -47,6 +108,12 @@ export const actionFinalize = register({
scene, scene,
); );
} }
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
@ -64,91 +131,107 @@ export const actionFinalize = register({
let newElements = elements; 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) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
const multiPointElement = appState.multiElement let element: NonDeleted<ExcalidrawElement> | null = null;
? appState.multiElement if (appState.multiElement) {
: appState.newElement?.type === "freedraw" element = appState.multiElement;
? appState.newElement } else if (
: null; appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement)
) {
element = appState.newElement;
} else if (Object.keys(appState.selectedElementIds).length === 1) {
const candidate = elementsMap.get(
Object.keys(appState.selectedElementIds)[0],
) as NonDeleted<ExcalidrawLinearElement> | undefined;
if (candidate) {
element = candidate;
}
}
if (multiPointElement) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (
multiPointElement.type !== "freedraw" && appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = multiPointElement; const { points, lastCommittedPoint } = element;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
) { ) {
scene.mutateElement(multiPointElement, { scene.mutateElement(element, {
points: multiPointElement.points.slice(0, -1), points: element.points.slice(0, -1),
}); });
} }
} }
if (isInvisiblySmallElement(multiPointElement)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // 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( newElements = newElements.filter((el) => el.id !== element!.id);
(el) => el.id !== multiPointElement.id,
);
} }
// If the multi point line closes the loop, if (isLinearElement(element) || isFreeDrawElement(element)) {
// set the last point to first point. // If the multi point line closes the loop,
// This ensures that loop remains closed at different scales. // set the last point to first point.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); // This ensures that loop remains closed at different scales.
if ( const isLoop = isPathALoop(element.points, appState.zoom.value);
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw" if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
) { const linePoints = element.points;
if (isLoop) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
scene.mutateElement(multiPointElement, { const points: LocalPoint[] = linePoints.map((p, index) =>
points: linePoints.map((p, index) => index === linePoints.length - 1
index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1])
? pointFrom(firstPoint[0], firstPoint[1]) : p,
: p, );
), if (isLineElement(element)) {
scene.mutateElement(element, {
points,
polygon: true,
});
} else {
scene.mutateElement(element, {
points,
});
}
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
}); });
} }
}
if ( if (
isBindingElement(multiPointElement) && isBindingElement(element) &&
!isLoop && !isLoop &&
multiPointElement.points.length > 1 element.points.length > 1 &&
) { isBindingEnabled(appState)
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( ) {
multiPointElement, const coords =
-1, sceneCoords ??
arrayToMap(elements), tupleToCoors(
); LinearElementEditor.getPointAtIndexGlobalCoordinates(
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene); element,
-1,
arrayToMap(elements),
),
);
maybeBindLinearElement(element, appState, coords, scene);
}
} }
} }
if ( if (
(!appState.activeTool.locked && (!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") || appState.activeTool.type !== "freedraw") ||
!multiPointElement !element
) { ) {
resetCursor(interactiveCanvas); resetCursor(interactiveCanvas);
} }
@ -175,7 +258,7 @@ export const actionFinalize = register({
activeTool: activeTool:
(appState.activeTool.locked || (appState.activeTool.locked ||
appState.activeTool.type === "freedraw") && appState.activeTool.type === "freedraw") &&
multiPointElement element
? appState.activeTool ? appState.activeTool
: activeTool, : activeTool,
activeEmbeddable: null, activeEmbeddable: null,
@ -186,23 +269,19 @@ export const actionFinalize = register({
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
appState.activeTool.type !== "freedraw" appState.activeTool.type !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [element.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing // To select the linear element when user has finished mutipoint editing
selectedLinearElement: selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement) element && isLinearElement(element)
? new LinearElementEditor( ? new LinearElementEditor(element, arrayToMap(newElements))
multiPointElement,
arrayToMap(newElements),
)
: appState.selectedLinearElement, : appState.selectedLinearElement,
pendingImageElementId: null,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View File

@ -1,19 +1,30 @@
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import {
import { isElbowArrow, isLinearElement } from "@excalidraw/element"; isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import {
toggleLinePolygonState,
CaptureUpdateAction,
} from "@excalidraw/element";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons"; import { lineEditorIcon, polygonIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { ButtonIcon } from "../components/ButtonIcon";
import { newElementWith } from "../../element/src/mutateElement";
import { register } from "./register"; import { register } from "./register";
export const actionToggleLinearEditor = register({ export const actionToggleLinearEditor = register({
@ -83,3 +94,110 @@ 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" }}
/>
);
},
});

View File

@ -18,18 +18,16 @@ import {
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey, getShortcutKey,
tupleToCoors,
getLineHeight, getLineHeight,
isTransparent,
reduceToCommonValue, reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import { import {
bindLinearElement, bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
updateBoundElements, updateBoundElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -47,15 +45,18 @@ import {
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { hasStrokeColor } from "@excalidraw/element"; import { hasStrokeColor } from "@excalidraw/element";
import { updateElbowArrowPoints } from "@excalidraw/element"; import {
updateElbowArrowPoints,
import { CaptureUpdateAction } from "@excalidraw/element"; CaptureUpdateAction,
toggleLinePolygonState,
} from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
@ -349,22 +350,52 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor", name: "changeBackgroundColor",
label: "labels.changeBackground", label: "labels.changeBackground",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return { if (!value.currentItemBackgroundColor) {
...(value.currentItemBackgroundColor && { return {
elements: changeProperty(elements, appState, (el) => appState: {
newElementWith(el, { ...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, backgroundColor: value.currentItemBackgroundColor,
}), ...toggleLinePolygonState(el, true),
), });
}), }
return el;
});
} else {
nextElements = changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
);
}
return {
elements: nextElements,
appState: { appState: {
...appState, ...appState,
...value, ...value,
}, },
captureUpdate: !!value.currentItemBackgroundColor captureUpdate: CaptureUpdateAction.IMMEDIATELY,
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
@ -1373,7 +1404,7 @@ export const actionChangeRoundness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
@ -1417,6 +1448,7 @@ export const actionChangeRoundness = register({
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
{renderAction("togglePolygon")}
</div> </div>
</fieldset> </fieldset>
); );
@ -1626,63 +1658,16 @@ export const actionChangeArrowType = register({
-1, -1,
elementsMap, elementsMap,
); );
const startHoveredElement = const startElement =
!newElement.startBinding && newElement.startBinding &&
getHoveredElementForBinding( (elementsMap.get(
tupleToCoors(startGlobalPoint), newElement.startBinding.elementId,
elements, ) as ExcalidrawBindableElement);
elementsMap, const endElement =
appState.zoom, newElement.endBinding &&
false, (elementsMap.get(
true, newElement.endBinding.elementId,
); ) as ExcalidrawBindableElement);
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 = const startBinding =
startElement && newElement.startBinding startElement && newElement.startBinding
@ -1693,6 +1678,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
startElement, startElement,
"start", "start",
elementsMap,
), ),
} }
: null; : null;
@ -1705,6 +1691,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
endElement, endElement,
"end", "end",
elementsMap,
), ),
} }
: null; : null;
@ -1714,7 +1701,7 @@ export const actionChangeArrowType = register({
startBinding, startBinding,
endBinding, endBinding,
...updateElbowArrowPoints(newElement, elementsMap, { ...updateElbowArrowPoints(newElement, elementsMap, {
points: [finalStartPoint, finalEndPoint].map( points: [startGlobalPoint, endGlobalPoint].map(
(p): LocalPoint => (p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y), pointFrom(p[0] - newElement.x, p[1] - newElement.y),
), ),

View File

@ -25,6 +25,10 @@ export const actionToggleSearchMenu = register({
predicate: (appState) => appState.gridModeEnabled, predicate: (appState) => appState.gridModeEnabled,
}, },
perform(elements, appState, _, app) { perform(elements, appState, _, app) {
if (appState.openDialog) {
return false;
}
if ( if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name && appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB appState.openSidebar.tab === CANVAS_SEARCH_TAB

View File

@ -179,6 +179,7 @@ export class ActionManager {
appProps={this.app.props} appProps={this.app.props}
app={this.app} app={this.app}
data={data} data={data}
renderAction={this.renderAction}
/> />
); );
} }

View File

@ -142,7 +142,8 @@ export type ActionName =
| "cropEditor" | "cropEditor"
| "wrapSelectionInFrame" | "wrapSelectionInFrame"
| "toggleLassoTool" | "toggleLassoTool"
| "toggleShapeSwitch"; | "toggleShapeSwitch"
| "togglePolygon";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@ -151,6 +152,10 @@ export type PanelComponentProps = {
appProps: ExcalidrawProps; appProps: ExcalidrawProps;
data?: Record<string, any>; data?: Record<string, any>;
app: AppClassProperties; app: AppClassProperties;
renderAction: (
name: ActionName,
data?: PanelComponentProps["data"],
) => React.JSX.Element | null;
}; };
export interface Action { export interface Action {

View File

@ -10,6 +10,7 @@ import {
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round, currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
@ -108,7 +109,6 @@ export const getDefaultAppState = (): Omit<
value: 1 as NormalizedZoomValue, value: 1 as NormalizedZoomValue,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null, selectedLinearElement: null,
snapLines: [], snapLines: [],
@ -237,7 +237,6 @@ const APP_STATE_STORAGE_CONF = (<
zenModeEnabled: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false }, zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, 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 }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false }, selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false }, snapLines: { browser: false, export: false, server: false },

View File

@ -352,7 +352,6 @@ export const ShapesSwitcher = ({
if (value === "image") { if (value === "image") {
app.setActiveTool({ app.setActiveTool({
type: value, type: value,
insertOnCanvasDirectly: pointerType !== "mouse",
}); });
} else { } else {
app.setActiveTool({ type: value }); app.setActiveTool({ type: value });

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@ interface ButtonIconProps {
/** include standalone style (could interfere with parent styles) */ /** include standalone style (could interfere with parent styles) */
standalone?: boolean; standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
style?: React.CSSProperties;
} }
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>( export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
@ -30,6 +31,7 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
data-testid={testId} data-testid={testId}
className={clsx(className, { standalone, active })} className={clsx(className, { standalone, active })}
onClick={onClick} onClick={onClick}
style={props.style}
> >
{icon} {icon}
</button> </button>

View File

@ -293,6 +293,7 @@ function CommandPaletteInner({
actionManager.actions.decreaseFontSize, actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor, actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor, actionManager.actions.cropEditor,
actionManager.actions.togglePolygon,
actionLink, actionLink,
actionCopyElementLink, actionCopyElementLink,
actionLinkToElement, actionLinkToElement,
@ -502,7 +503,6 @@ function CommandPaletteInner({
if (value === "image") { if (value === "image") {
app.setActiveTool({ app.setActiveTool({
type: value, type: value,
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
}); });
} else { } else {
app.setActiveTool({ type: value }); app.setActiveTool({ type: value });

View File

@ -4,6 +4,7 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement,
isTextBindableContainer, isTextBindableContainer,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -73,10 +74,6 @@ const getHints = ({
return t("hints.embeddable"); return t("hints.embeddable");
} }
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
@ -138,7 +135,9 @@ const getHints = ({
? t("hints.lineEditor_pointSelected") ? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected"); : t("hints.lineEditor_nothingSelected");
} }
return t("hints.lineEditor_info"); return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info")
: t("hints.lineEditor_info");
} }
if ( if (
!appState.newElement && !appState.newElement &&

View File

@ -297,6 +297,10 @@ export const SearchMenu = () => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (app.state.openDialog) {
return;
}
if (!searchInputRef.current?.matches(":focus")) { if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) { if (app.state.openDialog) {
setAppState({ setAppState({

View File

@ -7,6 +7,9 @@ import {
} from "@excalidraw/element"; } from "@excalidraw/element";
import { resizeSingleElement } from "@excalidraw/element"; import { resizeSingleElement } from "@excalidraw/element";
import { isImageElement } 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"; import type { ExcalidrawElement } from "@excalidraw/element/types";
@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface DimensionDragInputProps { interface DimensionDragInputProps {
@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
originalAppState, originalAppState,
instantChange, instantChange,
scene, scene,
app,
setAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0]; const origElement = originalElements[0];
@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
return; return;
} }
// User types in a value to stats then presses Enter
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextWidth = Math.max( const nextWidth = Math.max(
property === "width" property === "width"
@ -184,52 +193,123 @@ 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; return;
} }
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, origElement.width + changeInWidth); // Stats slider is dragged
if (property === "width") { {
if (shouldChangeByStepSize) { const changeInWidth = property === "width" ? accumulatedChange : 0;
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); const changeInHeight = property === "height" ? accumulatedChange : 0;
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight); let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") { if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100; if (shouldChangeByStepSize) {
} else { nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100; } 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,
});
} }
} }
}
};
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); const handleDragFinished: DragFinishedCallbackType = ({
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); setAppState,
app,
originalElements,
originalAppState,
}) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const origElement = originalElements?.[0];
const latestElement = origElement && elementsMap.get(origElement.id);
resizeSingleElement( // Handle frame membership update for resized frames
nextWidth, if (latestElement && isFrameLikeElement(latestElement)) {
nextHeight, const nextElementsInFrame = getElementsInResizingFrame(
app.scene.getElementsIncludingDeleted(),
latestElement, latestElement,
origElement, originalAppState,
originalElementsMap, app.scene.getNonDeletedElementsMap(),
scene,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
); );
const updatedElements = replaceAllElementsInFrame(
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
setAppState({
elementsToHighlight: null,
});
} }
}; };
@ -269,6 +349,7 @@ const DimensionDragInput = ({
scene={scene} scene={scene}
appState={appState} appState={appState}
property={property} property={property}
dragFinishedCallback={handleDragFinished}
/> />
); );
}; };

View File

@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Scene } from "@excalidraw/element"; import type { Scene } from "@excalidraw/element";
import { useApp } from "../App"; import { useApp, useExcalidrawSetAppState } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { SMALLEST_DELTA } from "./utils"; import { SMALLEST_DELTA } from "./utils";
@ -36,6 +36,15 @@ export type DragInputCallbackType<
property: P; property: P;
originalAppState: AppState; originalAppState: AppState;
setInputValue: (value: number) => void; 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; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps<
@ -54,6 +63,7 @@ interface StatsDragInputProps<
appState: AppState; appState: AppState;
/** how many px you need to drag to get 1 unit change */ /** how many px you need to drag to get 1 unit change */
sensitivity?: number; sensitivity?: number;
dragFinishedCallback?: DragFinishedCallbackType;
} }
const StatsDragInput = < const StatsDragInput = <
@ -71,8 +81,10 @@ const StatsDragInput = <
scene, scene,
appState, appState,
sensitivity = 1, sensitivity = 1,
dragFinishedCallback,
}: StatsDragInputProps<T, E>) => { }: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const setAppState = useExcalidrawSetAppState();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
@ -137,6 +149,8 @@ const StatsDragInput = <
property, property,
originalAppState: appState, originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
app,
setAppState,
}); });
app.syncActionResult({ app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -263,6 +277,8 @@ const StatsDragInput = <
scene, scene,
originalAppState, originalAppState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
app,
setAppState,
}); });
stepChange = 0; stepChange = 0;
@ -287,6 +303,14 @@ const StatsDragInput = <
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
// Notify implementors
dragFinishedCallback?.({
app,
setAppState,
originalElements,
originalAppState,
});
lastPointer = null; lastPointer = null;
accumulatedChange = 0; accumulatedChange = 0;
stepChange = 0; stepChange = 0;

View File

@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
import { useMemo } from "react"; import { useMemo } from "react";
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common"; import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
import { updateBoundElements } from "@excalidraw/element"; import {
getElementsInResizingFrame,
isFrameLikeElement,
replaceAllElementsInFrame,
updateBoundElements,
} from "@excalidraw/element";
import { import {
rescalePointsInElement, rescalePointsInElement,
resizeSingleElement, resizeSingleElement,
@ -25,7 +30,10 @@ import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils"; import { getElementsInAtomicUnit } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType<
nextValue, nextValue,
scene, scene,
property, property,
setAppState,
app,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const atomicUnits = getAtomicUnits(originalElements, originalAppState); const atomicUnits = getAtomicUnits(originalElements, originalAppState);
@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false, 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);
}
} }
} }
} }
@ -250,6 +279,7 @@ const handleDimensionChange: DragInputCallbackType<
const changeInWidth = property === "width" ? accumulatedChange : 0; const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0; const changeInHeight = property === "height" ? accumulatedChange : 0;
const elementsToHighlight: ExcalidrawElement[] = [];
for (const atomicUnit of atomicUnits) { for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit( const elementsInUnit = getElementsInAtomicUnit(
@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false, 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(); 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 = ({ const MultiDimension = ({
property, property,
elements, elements,
@ -396,6 +476,7 @@ const MultiDimension = ({
appState={appState} appState={appState}
property={property} property={property}
scene={scene} scene={scene}
dragFinishedCallback={handleDragFinished}
/> />
); );
}; };

View File

@ -133,7 +133,6 @@ describe("binding with linear elements", () => {
const inputX = UI.queryStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204")); UI.updateInput(inputX, String("204"));
@ -382,8 +381,7 @@ describe("stats for a non-generic element", () => {
it("text element", async () => { it("text element", async () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { act(() => {
editor.blur(); editor.blur();
@ -403,11 +401,23 @@ describe("stats for a non-generic element", () => {
UI.updateInput(input, "36"); UI.updateInput(input, "36");
expect(text.fontSize).toBe(36); expect(text.fontSize).toBe(36);
// cannot change width or height // can change width or height
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input"); const width = UI.queryStatsProperty("W")?.querySelector(
expect(width).toBeUndefined(); ".drag-input",
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input"); ) as HTMLInputElement;
expect(height).toBeUndefined(); 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);
// min font size is 4 // min font size is 4
UI.updateInput(input, "0"); UI.updateInput(input, "0");
@ -576,8 +586,7 @@ describe("stats for multiple elements", () => {
// text, rectangle, frame // text, rectangle, frame
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { act(() => {
editor.blur(); editor.blur();
@ -630,12 +639,11 @@ describe("stats for multiple elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
// changing width does not affect text
UI.updateInput(width, "200"); UI.updateInput(width, "200");
expect(rectangle?.width).toBe(200); expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200); expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200); expect(text?.width).toBe(200);
UI.updateInput(angle, "40"); UI.updateInput(angle, "40");
@ -657,6 +665,7 @@ describe("stats for multiple elements", () => {
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -728,3 +737,196 @@ describe("stats for multiple elements", () => {
expect(newGroupHeight).toBeCloseTo(500, 4); 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,
);
});
});

View File

@ -1,7 +1,7 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
import { getBoundTextElement } from "@excalidraw/element"; import { getBoundTextElement } from "@excalidraw/element";
import { isFrameLikeElement, isTextElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@ -41,12 +41,6 @@ export const isPropertyEditable = (
element: ExcalidrawElement, element: ExcalidrawElement,
property: keyof ExcalidrawElement, property: keyof ExcalidrawElement,
) => { ) => {
if (property === "height" && isTextElement(element)) {
return false;
}
if (property === "width" && isTextElement(element)) {
return false;
}
if (property === "angle" && isFrameLikeElement(element)) { if (property === "angle" && isFrameLikeElement(element)) {
return false; return false;
} }

View File

@ -198,7 +198,6 @@ const getRelevantAppStateProps = (
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
selectionElement: appState.selectionElement, selectionElement: appState.selectionElement,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement, selectedLinearElement: appState.selectedLinearElement,

View File

@ -100,7 +100,6 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale, exportScale: appState.exportScale,

View File

@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

View File

@ -92,7 +92,7 @@ export const isPointHittingLink = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
hitElementBoundingBox(x, y, element, elementsMap) hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
) { ) {
return true; return true;
} }

View File

@ -129,6 +129,21 @@ export const PinIcon = createIcon(
tablerIconProps, 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) // tabler-icons: lock-open (via Figma)
export const UnlockedIcon = createIcon( export const UnlockedIcon = createIcon(
<g> <g>

View File

@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"focus": 0, "focus": 0,
"gap": 4.545343408287929, "gap": 4.535423522449215,
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 14, "gap": 16,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -948,6 +948,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0, 0,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": Any<Number>, "seed": Any<Number>,
@ -995,6 +996,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0, 0,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": Any<Number>, "seed": Any<Number>,
@ -1538,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"focus": 0, "focus": 0,
"gap": 14, "gap": 32,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -1789,7 +1791,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 120,
"x": 187.7545, "x": 187.75450000000004,
"y": 44.5, "y": 44.5,
} }
`; `;

View File

@ -18,7 +18,7 @@ import {
normalizeLink, normalizeLink,
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@ -34,6 +34,7 @@ import {
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -323,7 +324,8 @@ const restoreElement = (
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); ({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
} }
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@ -339,6 +341,13 @@ const restoreElement = (
points, points,
x, x,
y, y,
...(isLineElement(element)
? {
polygon: isValidPolygon(element.points)
? element.polygon ?? false
: false,
}
: {}),
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { case "arrow": {
@ -351,7 +360,8 @@ const restoreElement = (
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); ({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
} }
const base = { const base = {

View File

@ -781,7 +781,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: -0, focus: -0,
gap: 14, gap: 25,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {

View File

@ -466,7 +466,7 @@ const bindLinearElementToElement = (
Object.assign( Object.assign(
linearElement, linearElement,
LinearElementEditor.getNormalizedPoints({ LinearElementEditor.getNormalizeElementPointsAndCoords({
...linearElement, ...linearElement,
points: newPoints, points: newPoints,
}), }),

View File

@ -1,25 +1,19 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { getElementLineSegments } from "@excalidraw/element";
import { import {
lineSegment, computeBoundTextPosition,
lineSegmentIntersectionPoints, getBoundTextElement,
pointFrom, intersectElementWithLineSegment,
} from "@excalidraw/math"; isPointInElement,
} from "@excalidraw/element";
import { lineSegment, pointFrom } from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
import { getElementShape } from "@excalidraw/element";
import { shouldTestInside } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element";
import { isPointInShape } from "@excalidraw/utils/collision";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { GeometricShape } from "@excalidraw/utils/shape"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type {
ElementsSegmentsMap,
GlobalPoint,
LineSegment,
} from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animated-trail";
@ -28,15 +22,9 @@ import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; 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 { export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set(); private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: 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) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, { super(animationFrameHandler, app, {
@ -79,14 +67,21 @@ export class EraserTrail extends AnimatedTrail {
} }
private updateElementsToBeErased(restoreToErase?: boolean) { private updateElementsToBeErased(restoreToErase?: boolean) {
let eraserPath: GlobalPoint[] = const eraserPath: GlobalPoint[] =
super super
.getCurrentTrail() .getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || []; ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
if (eraserPath.length < 2) {
return [];
}
// for efficiency and avoid unnecessary calculations, // for efficiency and avoid unnecessary calculations,
// take only POINTS_ON_TRAIL points to form some number of segments // take only POINTS_ON_TRAIL points to form some number of segments
eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); const pathSegment = lineSegment<GlobalPoint>(
eraserPath[eraserPath.length - 1],
eraserPath[eraserPath.length - 2],
);
const candidateElements = this.app.visibleElements.filter( const candidateElements = this.app.visibleElements.filter(
(el) => !el.locked, (el) => !el.locked,
@ -94,28 +89,13 @@ export class EraserTrail extends AnimatedTrail {
const candidateElementsMap = arrayToMap(candidateElements); 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) { for (const element of candidateElements) {
// restore only if already added to the to-be-erased set // restore only if already added to the to-be-erased set
if (restoreToErase && this.elementsToErase.has(element.id)) { if (restoreToErase && this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@ -148,12 +128,9 @@ export class EraserTrail extends AnimatedTrail {
} }
} else if (!restoreToErase && !this.elementsToErase.has(element.id)) { } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@ -196,45 +173,37 @@ export class EraserTrail extends AnimatedTrail {
super.clearTrails(); super.clearTrails();
this.elementsToErase.clear(); this.elementsToErase.clear();
this.groupsToErase.clear(); this.groupsToErase.clear();
this.segmentsCache.clear();
} }
} }
const eraserTest = ( const eraserTest = (
pathSegments: LineSegment<GlobalPoint>[], pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
shapesCache: Map<string, GeometricShape<GlobalPoint>>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
app: App,
): boolean => { ): boolean => {
let shape = shapesCache.get(element.id); const lastPoint = pathSegment[1];
if (
if (!shape) { shouldTestInside(element) &&
shape = getElementShape<GlobalPoint>(element, elementsMap); isPointInElement(lastPoint, element, elementsMap)
shapesCache.set(element.id, shape); ) {
}
const lastPoint = pathSegments[pathSegments.length - 1][1];
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
return true; return true;
} }
let elementSegments = elementsSegments.get(element.id); const boundTextElement = getBoundTextElement(element, elementsMap);
if (!elementSegments) { return (
elementSegments = getElementLineSegments(element, elementsMap); intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
elementsSegments.set(element.id, elementSegments); .length > 0 ||
} (!!boundTextElement &&
intersectElementWithLineSegment(
return pathSegments.some((pathSegment) => {
elementSegments?.some( ...boundTextElement,
(elementSegment) => ...computeBoundTextPosition(element, boundTextElement, elementsMap),
lineSegmentIntersectionPoints( },
pathSegment, elementsMap,
elementSegment, pathSegment,
app.getElementHitThreshold(), 0,
) !== null, true,
), ).length > 0)
); );
}; };

View File

@ -4,14 +4,81 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
StoreChange, StoreChange,
StoreDelta, StoreDelta,
type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { StoreSnapshot, Store } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types"; import type { SceneElementsMap } from "@excalidraw/element/types";
import type { AppState } from "./types"; import type { AppState } from "./types";
class HistoryEntry extends StoreDelta {} 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;
}
}
export class HistoryChangedEvent { export class HistoryChangedEvent {
constructor( constructor(
@ -25,8 +92,8 @@ export class History {
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
public readonly undoStack: HistoryEntry[] = []; public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryEntry[] = []; public readonly redoStack: HistoryDelta[] = [];
public get isUndoStackEmpty() { public get isUndoStackEmpty() {
return this.undoStack.length === 0; return this.undoStack.length === 0;
@ -48,16 +115,16 @@ export class History {
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action. * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
*/ */
public record(delta: StoreDelta) { public record(delta: StoreDelta) {
if (delta.isEmpty() || delta instanceof HistoryEntry) { if (delta.isEmpty() || delta instanceof HistoryDelta) {
return; return;
} }
// construct history entry, so once it's emitted, it's not recorded again // construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(delta); const historyDelta = HistoryDelta.inverse(delta);
this.undoStack.push(entry); this.undoStack.push(historyDelta);
if (!entry.elements.isEmpty()) { if (!historyDelta.elements.isEmpty()) {
// don't reset redo stack on local appState changes, // don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries // as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes! // only reset on non empty elements changes!
@ -74,7 +141,7 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements), (entry: HistoryDelta) => History.push(this.redoStack, entry),
); );
} }
@ -83,20 +150,20 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements), (entry: HistoryDelta) => History.push(this.undoStack, entry),
); );
} }
private perform( private perform(
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
pop: () => HistoryEntry | null, pop: () => HistoryDelta | null,
push: (entry: HistoryEntry) => void, push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
try { try {
let historyEntry = pop(); let historyDelta = pop();
if (historyEntry === null) { if (historyDelta === null) {
return; return;
} }
@ -108,41 +175,47 @@ export class History {
let nextAppState = appState; let nextAppState = appState;
let containsVisibleChange = false; let containsVisibleChange = false;
// iterate through the history entries in case they result in no visible changes // iterate through the history entries in case ;they result in no visible changes
while (historyEntry) { while (historyDelta) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
StoreDelta.applyTo( historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
historyEntry,
nextElements,
nextAppState,
prevSnapshot,
);
const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone( const nextSnapshot = prevSnapshot.maybeClone(
action, action,
nextElements, nextElements,
nextAppState, nextAppState,
); );
// schedule immediate capture, so that it's emitted for the sync purposes const change = StoreChange.create(prevSnapshot, nextSnapshot);
this.store.scheduleMicroAction({ const delta = HistoryDelta.applyLatestChanges(
action, historyDelta,
change: StoreChange.create(prevSnapshot, nextSnapshot), prevElements,
delta: historyEntry, nextElements,
}); );
if (!delta.isEmpty()) {
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({
action,
change,
delta,
});
historyDelta = delta;
}
prevSnapshot = nextSnapshot; prevSnapshot = nextSnapshot;
} finally { } finally {
// make sure to always push, even if the delta is corrupted push(historyDelta);
push(historyEntry);
} }
if (containsVisibleChange) { if (containsVisibleChange) {
break; break;
} }
historyEntry = pop(); historyDelta = pop();
} }
return [nextElements, nextAppState]; return [nextElements, nextAppState];
@ -155,7 +228,7 @@ export class History {
} }
} }
private static pop(stack: HistoryEntry[]): HistoryEntry | null { private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) { if (!stack.length) {
return null; return null;
} }
@ -169,18 +242,8 @@ export class History {
return null; return null;
} }
private static push( private static push(stack: HistoryDelta[], entry: HistoryDelta) {
stack: HistoryEntry[], const inversedEntry = HistoryDelta.inverse(entry);
entry: HistoryEntry, return stack.push(inversedEntry);
prevElements: SceneElementsMap,
) {
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry);
} }
} }

View File

@ -199,6 +199,7 @@ export class LassoTrail extends AnimatedTrail {
const { selectedElementIds } = getLassoSelectedElementIds({ const { selectedElementIds } = getLassoSelectedElementIds({
lassoPath, lassoPath,
elements: this.app.visibleElements, elements: this.app.visibleElements,
elementsMap: this.app.scene.getNonDeletedElementsMap(),
elementsSegments: this.elementsSegments, elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements, intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements, enclosedElements: this.enclosedElements,

View File

@ -3,20 +3,25 @@ import { simplify } from "points-on-curve";
import { import {
polygonFromPoints, polygonFromPoints,
lineSegment, lineSegment,
lineSegmentIntersectionPoints,
polygonIncludesPointNonZero, polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import {
ElementsSegmentsMap, type Bounds,
GlobalPoint, computeBoundTextPosition,
LineSegment, doBoundsIntersect,
} from "@excalidraw/math/types"; getBoundTextElement,
import type { ExcalidrawElement } from "@excalidraw/element/types"; getElementBounds,
intersectElementWithLineSegment,
} from "@excalidraw/element";
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
export const getLassoSelectedElementIds = (input: { export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[]; lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
elementsSegments: ElementsSegmentsMap; elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>; intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>; enclosedElements: Set<ExcalidrawElement["id"]>;
@ -27,6 +32,7 @@ export const getLassoSelectedElementIds = (input: {
const { const {
lassoPath, lassoPath,
elements, elements,
elementsMap,
elementsSegments, elementsSegments,
intersectedElements, intersectedElements,
enclosedElements, enclosedElements,
@ -40,8 +46,26 @@ export const getLassoSelectedElementIds = (input: {
const unlockedElements = elements.filter((el) => !el.locked); const unlockedElements = elements.filter((el) => !el.locked);
// as the path might not enclose a shape anymore, clear before checking // as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear(); 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) { 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 ( if (
doBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
@ -49,7 +73,7 @@ export const getLassoSelectedElementIds = (input: {
if (enclosed) { if (enclosed) {
enclosedElements.add(element.id); enclosedElements.add(element.id);
} else { } else {
const intersects = intersectionTest(path, element, elementsSegments); const intersects = intersectionTest(path, element, elementsMap);
if (intersects) { if (intersects) {
intersectedElements.add(element.id); intersectedElements.add(element.id);
} }
@ -85,26 +109,34 @@ const enclosureTest = (
const intersectionTest = ( const intersectionTest = (
lassoPath: GlobalPoint[], lassoPath: GlobalPoint[],
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap, elementsMap: ElementsMap,
): boolean => { ): boolean => {
const elementSegments = elementsSegments.get(element.id); const lassoSegments = lassoPath
if (!elementSegments) { .slice(1)
return false; .map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
} .concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
const lassoSegments = lassoPath.reduce((acc, point, index) => { const boundTextElement = getBoundTextElement(element, elementsMap);
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) => return lassoSegments.some(
elementSegments.some( (lassoSegment) =>
(elementSegment) => intersectElementWithLineSegment(
// introduce a bit of tolerance to account for roughness and simplification of paths element,
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, elementsMap,
), lassoSegment,
0,
true,
).length > 0 ||
(!!boundTextElement &&
intersectElementWithLineSegment(
{
...boundTextElement,
...computeBoundTextPosition(element, boundTextElement, elementsMap),
},
elementsMap,
lassoSegment,
0,
true,
).length > 0),
); );
}; };

View File

@ -141,6 +141,10 @@
"edit": "Edit line", "edit": "Edit line",
"editArrow": "Edit arrow" "editArrow": "Edit arrow"
}, },
"polygon": {
"breakPolygon": "Break polygon",
"convertToPolygon": "Convert to polygon"
},
"elementLock": { "elementLock": {
"lock": "Lock", "lock": "Lock",
"unlock": "Unlock", "unlock": "Unlock",
@ -340,9 +344,9 @@
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center", "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", "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_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_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", "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", "publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text", "bindTextToElement": "Press enter to add text",
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart", "createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",

View File

@ -1,24 +1,22 @@
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common"; import { THEME, THEME_FILTER } from "@excalidraw/common";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element"; import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
import { getDiamondPoints } from "@excalidraw/element"; import { getDiamondPoints } from "@excalidraw/element";
import { getCornerRadius } from "@excalidraw/element"; import { elementCenterPoint, getCornerRadius } from "@excalidraw/element";
import { import {
bezierEquation,
curve, curve,
curveTangent, curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint, type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import type {
ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@ -31,11 +29,14 @@ export const fillCircle = (
cx: number, cx: number,
cy: number, cy: number,
radius: number, radius: number,
stroke = true, stroke: boolean,
fill = true,
) => { ) => {
context.beginPath(); context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2); context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill(); if (fill) {
context.fill();
}
if (stroke) { if (stroke) {
context.stroke(); context.stroke();
} }
@ -99,25 +100,14 @@ export const bootstrapCanvas = ({
function drawCatmullRomQuadraticApprox( function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
for (let i = 0; i < points.length - 1; i++) { ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
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);
} }
} }
} }
@ -125,35 +115,13 @@ function drawCatmullRomQuadraticApprox(
function drawCatmullRomCubicApprox( function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < pointSets.length; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1]; const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
const p1 = points[i]; ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
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);
} }
} }
} }
@ -161,11 +129,12 @@ function drawCatmullRomCubicApprox(
export const drawHighlightForRectWithRotation = ( export const drawHighlightForRectWithRotation = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
padding: number, padding: number,
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
@ -184,25 +153,25 @@ export const drawHighlightForRectWithRotation = (
context.beginPath(); context.beginPath();
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
padding, padding,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width, radius), pointFrom(element.width, radius),
padding, padding,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
padding, padding,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height), pointFrom(radius, element.height),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
@ -227,25 +196,25 @@ export const drawHighlightForRectWithRotation = (
// mask" on a filled shape for the diamond highlight, because stroking creates // mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees. // sharp inset edges on line joins < 90 degrees.
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius), pointFrom(element.width, radius),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(radius, element.height), pointFrom(radius, element.height),
@ -319,10 +288,11 @@ export const drawHighlightForDiamondWithRotation = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
padding: number, padding: number,
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
context.save(); context.save();
@ -340,32 +310,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX - verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding, padding,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding, padding,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding, padding,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding, padding,
); );
@ -373,13 +351,13 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
@ -395,32 +373,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX + verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
@ -428,66 +414,16 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
context.closePath(); context.closePath();
context.fill(); context.fill();
context.restore(); 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;
}

View File

@ -1,5 +1,6 @@
import { import {
pointFrom, pointFrom,
pointsEqual,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type Radians, type Radians,
@ -28,6 +29,7 @@ import {
isFrameLikeElement, isFrameLikeElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -161,7 +163,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
point: Point, point: Point,
radius: number, radius: number,
isSelected: boolean, isSelected: boolean,
isPhantomPoint = false, isPhantomPoint: boolean,
isOverlappingPoint: boolean,
) => { ) => {
context.strokeStyle = "#5e5ad8"; context.strokeStyle = "#5e5ad8";
context.setLineDash([]); context.setLineDash([]);
@ -176,8 +179,11 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context, context,
point[0], point[0],
point[1], point[1],
radius / appState.zoom.value, (isOverlappingPoint
? radius * (appState.editingLinearElement ? 1.5 : 2)
: radius) / appState.zoom.value,
!isPhantomPoint, !isPhantomPoint,
!isOverlappingPoint || isSelected,
); );
}; };
@ -187,16 +193,10 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"], zoom: InteractiveCanvasAppState["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); const padding = maxBindingGap(element, element.width, element.height, zoom);
context.fillStyle = "rgba(0,0,0,.05)";
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "text": case "text":
@ -205,15 +205,23 @@ const renderBindingHighlightForBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
drawHighlightForRectWithRotation(context, element, padding); drawHighlightForRectWithRotation(context, element, elementsMap, padding);
break; break;
case "diamond": case "diamond":
drawHighlightForDiamondWithRotation(context, padding, element); drawHighlightForDiamondWithRotation(
context,
padding,
element,
elementsMap,
);
break; break;
case "ellipse": case "ellipse": {
context.lineWidth = const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
maxBindingGap(element, element.width, element.height, zoom) - const width = x2 - x1;
FIXED_BINDING_DISTANCE; const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation( strokeEllipseWithRotation(
context, context,
@ -224,6 +232,7 @@ const renderBindingHighlightForBindableElement = (
element.angle, element.angle,
); );
break; break;
}
} }
}; };
@ -253,7 +262,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
index, index,
elementsMap, elementsMap,
); );
fillCircle(context, x, y, threshold); fillCircle(context, x, y, threshold, true);
}); });
}; };
@ -442,15 +451,48 @@ const renderLinearPointHandles = (
const radius = appState.editingLinearElement const radius = appState.editingLinearElement
? POINT_HANDLE_SIZE ? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2; : POINT_HANDLE_SIZE / 2;
const _isElbowArrow = isElbowArrow(element);
const _isLineElement = isLineElement(element);
points.forEach((point, idx) => { points.forEach((point, idx) => {
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) { if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
return; return;
} }
const isSelected = const isOverlappingPoint =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); idx > 0 &&
(idx !== points.length - 1 || !_isLineElement || !element.polygon) &&
pointsEqual(
point,
idx === points.length - 1 ? points[0] : points[idx - 1],
2 / appState.zoom.value,
);
renderSingleLinearPoint(context, appState, point, radius, isSelected); let 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,
);
}); });
// Rendering segment mid points // Rendering segment mid points
@ -477,6 +519,7 @@ const renderLinearPointHandles = (
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
false, false,
!fixedSegments.includes(idx + 1), !fixedSegments.includes(idx + 1),
false,
); );
} }
}); });
@ -500,6 +543,7 @@ const renderLinearPointHandles = (
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
false, false,
true, true,
false,
); );
} }
}); });
@ -526,7 +570,7 @@ const renderTransformHandles = (
context.strokeStyle = renderConfig.selectionColor; context.strokeStyle = renderConfig.selectionColor;
} }
if (key === "rotation") { if (key === "rotation") {
fillCircle(context, x + width / 2, y + height / 2, width / 2); fillCircle(context, x + width / 2, y + height / 2, width / 2, true);
// prefer round corners if roundRect API is available // prefer round corners if roundRect API is available
} else if (context.roundRect) { } else if (context.roundRect) {
context.beginPath(); context.beginPath();

View File

@ -1,5 +1,4 @@
import { isElementInViewport } from "@excalidraw/element"; import { isElementInViewport } from "@excalidraw/element";
import { isImageElement } from "@excalidraw/element";
import { memoize, toBrandedType } from "@excalidraw/common"; import { memoize, toBrandedType } from "@excalidraw/common";
@ -72,25 +71,14 @@ export class Renderer {
elements, elements,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"]; editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined; newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
for (const element of elements) { 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) { if (newElementId === element.id) {
continue; continue;
} }
@ -119,7 +107,6 @@ export class Renderer {
width, width,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
// cache-invalidation nonce // cache-invalidation nonce
sceneNonce: _sceneNonce, sceneNonce: _sceneNonce,
}: { }: {
@ -134,7 +121,6 @@ export class Renderer {
/** note: first render of newElement will always bust the cache /** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */ * (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined; newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>; sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => { }) => {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
@ -143,7 +129,6 @@ export class Renderer {
elements, elements,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
}); });
const visibleElements = getVisibleCanvasElements({ const visibleElements = getVisibleCanvasElements({

View File

@ -102,14 +102,14 @@ describe("Test <MermaidToExcalidraw/>", () => {
expect(dialog).not.toBeNull(); expect(dialog).not.toBeNull();
const selector = ".ttd-dialog-input"; const selector = ".ttd-dialog-input";
let editor = await getTextEditor(selector, true); let editor = await getTextEditor({ selector, waitForEditor: true });
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull(); expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
expect(editor.textContent).toMatchSnapshot(); expect(editor.textContent).toMatchSnapshot();
updateTextEditor(editor, "flowchart TD1"); updateTextEditor(editor, "flowchart TD1");
editor = await getTextEditor(selector, false); editor = await getTextEditor({ selector, waitForEditor: false });
expect(editor.textContent).toBe("flowchart TD1"); expect(editor.textContent).toBe("flowchart TD1");
expect( expect(

View File

@ -71,9 +71,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -107,9 +105,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -153,10 +149,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
50, 50,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -192,9 +187,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -17,9 +17,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -27,7 +25,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1505387817, "versionNonce": 23633383,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -51,17 +49,15 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1505387817,
},
"seed": 1604849351,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 915032327, "versionNonce": 81784553,
"width": 30, "width": 30,
"x": -10, "x": -10,
"y": 60, "y": 60,
@ -85,9 +81,7 @@ exports[`move element > rectangle 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -95,7 +89,7 @@ exports[`move element > rectangle 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1116226695, "versionNonce": 1014066025,
"width": 30, "width": 30,
"x": 0, "x": 0,
"y": 40, "y": 40,
@ -124,9 +118,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -134,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 1006504105,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@ -163,17 +155,15 @@ exports[`move element > rectangles with binding arrow 6`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1116226695,
},
"seed": 1150084233,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 745419401, "versionNonce": 1984422985,
"width": 300, "width": 300,
"x": 201, "x": 201,
"y": 2, "y": 2,
@ -196,7 +186,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "87.29887", "height": "81.40630",
"id": "id6", "id": "id6",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -210,15 +200,15 @@ exports[`move element > rectangles with binding arrow 7`] = `
0, 0,
], ],
[ [
"86.85786", "81.00000",
"87.29887", "81.40630",
], ],
], ],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 1604849351, "seed": 23633383,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id0", "elementId": "id0",
@ -231,9 +221,9 @@ exports[`move element > rectangles with binding arrow 7`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"versionNonce": 1051383431, "versionNonce": 1573789895,
"width": "86.85786", "width": "81.00000",
"x": "107.07107", "x": "110.00000",
"y": "47.07107", "y": 50,
} }
`; `;

View File

@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 400692809, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@ -93,10 +93,9 @@ exports[`multi point mode in linear elements > line 3`] = `
110, 110,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -106,7 +105,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 400692809, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,

View File

@ -79,10 +79,9 @@ exports[`select single element on the scene > arrow escape 1`] = `
50, 50,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -116,9 +115,7 @@ exports[`select single element on the scene > diamond 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -150,9 +147,7 @@ exports[`select single element on the scene > ellipse 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -184,9 +179,7 @@ exports[`select single element on the scene > rectangle 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

View File

@ -110,8 +110,8 @@ describe("contextMenu element", () => {
it("shows context menu for element", () => { it("shows context menu for element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -304,8 +304,8 @@ describe("contextMenu element", () => {
it("selecting 'Copy styles' in context menu copies styles", () => { it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -389,8 +389,8 @@ describe("contextMenu element", () => {
it("selecting 'Delete' in context menu deletes element", () => { it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -405,8 +405,8 @@ describe("contextMenu element", () => {
it("selecting 'Add to library' in context menu adds element to library", async () => { it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -424,8 +424,8 @@ describe("contextMenu element", () => {
it("selecting 'Duplicate' in context menu duplicates element", () => { it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,

View File

@ -31,9 +31,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
], ],
], ],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -193,9 +191,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
], ],
"pressures": [], "pressures": [],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"simulatePressure": true, "simulatePressure": true,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -240,10 +236,9 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
100, 100,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -289,10 +284,9 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
100, 100,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -332,9 +326,7 @@ exports[`restoreElements > should restore text element correctly passing value f
"opacity": 100, "opacity": 100,
"originalText": "text", "originalText": "text",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -376,9 +368,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"opacity": 100, "opacity": 100,
"originalText": "", "originalText": "",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

View File

@ -6,7 +6,10 @@ import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import * as sizeHelpers from "@excalidraw/element"; import * as sizeHelpers from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -163,6 +166,109 @@ describe("restoreElements", () => {
}); });
}); });
it("should remove imperceptibly small elements", () => {
const arrowElement = API.createElement({
type: "arrow",
points: [
[0, 0],
[0.02, 0.05],
] as LocalPoint[],
x: 0,
y: 0,
});
const restoredElements = restore.restoreElements([arrowElement], null);
const restoredArrow = restoredElements[0] as
| ExcalidrawArrowElement
| undefined;
expect(restoredArrow).toBeUndefined();
});
it("should keep 'imperceptibly' small freedraw/line elements", () => {
const freedrawElement = API.createElement({
type: "freedraw",
points: [
[0, 0],
[0.0001, 0.0001],
] as LocalPoint[],
x: 0,
y: 0,
});
const lineElement = API.createElement({
type: "line",
points: [
[0, 0],
[0.0001, 0.0001],
] as LocalPoint[],
x: 0,
y: 0,
});
const restoredElements = restore.restoreElements(
[freedrawElement, lineElement],
null,
);
expect(restoredElements).toEqual([
expect.objectContaining({ id: freedrawElement.id }),
expect.objectContaining({ id: lineElement.id }),
]);
});
it("should restore loop linears correctly", () => {
const linearElement = API.createElement({
type: "line",
points: [
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[],
x: 0,
y: 0,
});
const arrowElement = API.createElement({
type: "arrow",
points: [
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[],
x: 500,
y: 500,
});
const restoredElements = restore.restoreElements(
[linearElement, arrowElement],
null,
);
const restoredLinear = restoredElements[0] as
| ExcalidrawLinearElement
| undefined;
const restoredArrow = restoredElements[1] as
| ExcalidrawArrowElement
| undefined;
expect(restoredLinear?.type).toBe("line");
expect(restoredLinear?.points).toEqual([
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[]);
expect(restoredArrow?.type).toBe("arrow");
expect(restoredArrow?.points).toEqual([
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[]);
});
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => { it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
const arrowElement = API.createElement({ type: "arrow" }); const arrowElement = API.createElement({ type: "arrow" });
const restoredElements = restore.restoreElements([arrowElement], null); const restoredElements = restore.restoreElements([arrowElement], null);

View File

@ -1,5 +1,3 @@
import React from "react";
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { actionSelectAll } from "../actions"; import { actionSelectAll } from "../actions";
@ -10,6 +8,8 @@ import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { render, unmountComponent } from "../tests/test-utils"; import { render, unmountComponent } from "../tests/test-utils";
import { getTextEditor } from "./queries/dom";
unmountComponent(); unmountComponent();
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -245,7 +245,7 @@ describe("element locking", () => {
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore locked text under cursor when clicked with text tool", () => { it("should ignore locked text under cursor when clicked with text tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
text: "ola", text: "ola",
@ -258,16 +258,14 @@ describe("element locking", () => {
API.setElements([text]); API.setElements([text]);
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore text under cursor when double-clicked with selection tool", () => { it("should ignore text under cursor when double-clicked with selection tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
text: "ola", text: "ola",
@ -280,9 +278,7 @@ describe("element locking", () => {
API.setElements([text]); API.setElements([text]);
UI.clickTool("selection"); UI.clickTool("selection");
mouse.doubleClickAt(text.x + 50, text.y + 50); mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
@ -328,7 +324,7 @@ describe("element locking", () => {
]); ]);
}); });
it("bound text shouldn't be editable via double-click", () => { it("bound text shouldn't be editable via double-click", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
width: 100, width: 100,
@ -353,16 +349,14 @@ describe("element locking", () => {
UI.clickTool("selection"); UI.clickTool("selection");
mouse.doubleClickAt(container.width / 2, container.height / 2); mouse.doubleClickAt(container.width / 2, container.height / 2);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.state.editingTextElement?.id).toBe(h.elements[2].id); expect(h.state.editingTextElement?.id).toBe(h.elements[2].id);
}); });
it("bound text shouldn't be editable via text tool", () => { it("bound text shouldn't be editable via text tool", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
width: 100, width: 100,
@ -387,9 +381,7 @@ describe("element locking", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(container.width / 2, container.height / 2); mouse.clickAt(container.width / 2, container.height / 2);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);

View File

@ -1,4 +1,3 @@
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common"; import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common";
@ -37,6 +36,10 @@ import {
waitFor, waitFor,
} from "./test-utils"; } from "./test-utils";
import { getTextEditor } from "./queries/dom";
import { mockHTMLImageElement } from "./helpers/mocks";
import type { NormalizedZoomValue } from "../types"; import type { NormalizedZoomValue } from "../types";
const { h } = window; const { h } = window;
@ -741,6 +744,28 @@ describe("freedraw", () => {
//image //image
//TODO: currently there is no test for pixel colors at flipped positions. //TODO: currently there is no test for pixel colors at flipped positions.
describe("image", () => { describe("image", () => {
const smileyImageDimensions = {
width: 56,
height: 77,
};
beforeEach(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
});
beforeAll(() => {
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
});
afterAll(() => {
vi.unstubAllGlobals();
h.state.height = 0;
});
const createImage = async () => { const createImage = async () => {
const sendPasteEvent = (file?: File) => { const sendPasteEvent = (file?: File) => {
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] }); const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
@ -846,9 +871,7 @@ describe("mutliple elements", () => {
}); });
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
let editor = document.querySelector<HTMLTextAreaElement>( let editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "arrow" } }); fireEvent.input(editor, { target: { value: "arrow" } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@ -860,9 +883,7 @@ describe("mutliple elements", () => {
}); });
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector<HTMLTextAreaElement>( editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "rect\ntext" } }); fireEvent.input(editor, { target: { value: "rect\ntext" } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);

View File

@ -499,13 +499,21 @@ export class API {
value: { value: {
files, files,
getData: (type: string) => { getData: (type: string) => {
if (type === blob.type) { if (type === blob.type || type === "text") {
return text; return text;
} }
return ""; return "";
}, },
types: [blob.type],
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", {
value: 0,
});
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent); await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
}; };

View File

@ -31,3 +31,30 @@ export const mockMermaidToExcalidraw = (opts: {
}); });
} }
}; };
// Mock for HTMLImageElement (use with `vi.unstubAllGlobals()`)
// as jsdom.resources: "usable" throws an error on image load
export const mockHTMLImageElement = (
naturalWidth: number,
naturalHeight: number,
) => {
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
Object.defineProperty(this, "naturalWidth", {
value: naturalWidth,
});
Object.defineProperty(this, "naturalHeight", {
value: naturalHeight,
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};

View File

@ -1,6 +1,10 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
import { getCommonBounds, getElementPointsCoords } from "@excalidraw/element"; import {
elementCenterPoint,
getCommonBounds,
getElementPointsCoords,
} from "@excalidraw/element";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element";
import { import {
getTransformHandles, getTransformHandles,
@ -16,7 +20,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap, elementCenterPoint } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@ -32,10 +36,11 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { createTestHook } from "../../components/App"; import { createTestHook } from "../../components/App";
import { getTextEditor } from "../queries/dom"; import { getTextEditor, TEXT_EDITOR_SELECTOR } from "../queries/dom";
import { act, fireEvent, GlobalTestState, screen } from "../test-utils"; import { act, fireEvent, GlobalTestState, screen } from "../test-utils";
import { API } from "./api"; import { API } from "./api";
@ -146,6 +151,7 @@ export class Keyboard {
const getElementPointForSelection = ( const getElementPointForSelection = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, angle } = element; const { x, y, width, angle } = element;
const target = pointFrom<GlobalPoint>( const target = pointFrom<GlobalPoint>(
@ -162,7 +168,7 @@ const getElementPointForSelection = (
(bounds[1] + bounds[3]) / 2, (bounds[1] + bounds[3]) / 2,
); );
} else { } else {
center = elementCenterPoint(element); center = elementCenterPoint(element, elementsMap);
} }
if (isTextElement(element)) { if (isTextElement(element)) {
@ -299,7 +305,12 @@ export class Pointer {
elements = Array.isArray(elements) ? elements : [elements]; elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => { elements.forEach((element) => {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
}); });
}); });
@ -308,13 +319,23 @@ export class Pointer {
clickOn(element: ExcalidrawElement) { clickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
doubleClickOn(element: ExcalidrawElement) { doubleClickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.doubleClick(...getElementPointForSelection(element)); this.doubleClick(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
} }
@ -532,16 +553,15 @@ export class UI {
static async editText< static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer, T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) { >(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor = const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector); document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) { if (!openedEditor) {
mouse.select(element); mouse.select(element);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
} }
const editor = await getTextEditor(textEditorSelector); const editor = await getTextEditor();
if (!editor) { if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom"); throw new Error("Can't find wysiwyg text editor in the dom");
} }
@ -598,6 +618,7 @@ export class UI {
const mutations = cropElement( const mutations = cropElement(
element, element,
h.scene.getNonDeletedElementsMap(),
handle, handle,
naturalWidth, naturalWidth,
naturalHeight, naturalHeight,

View File

@ -19,6 +19,7 @@ import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@ -35,6 +36,7 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FileId,
FixedPointBinding, FixedPointBinding,
FractionalIndex, FractionalIndex,
SceneElementsMap, SceneElementsMap,
@ -49,12 +51,16 @@ import {
} from "../actions"; } from "../actions";
import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import * as StaticScene from "../renderer/staticScene";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene"; import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { mockHTMLImageElement } from "./helpers/mocks";
import { import {
GlobalTestState, GlobalTestState,
act, act,
@ -63,6 +69,7 @@ import {
togglePopover, togglePopover,
getCloneByOrigId, getCloneByOrigId,
checkpointHistory, checkpointHistory,
unmountComponent,
} from "./test-utils"; } from "./test-utils";
import type { AppState } from "../types"; import type { AppState } from "../types";
@ -106,7 +113,22 @@ const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
describe("history", () => { describe("history", () => {
beforeEach(() => { beforeEach(() => {
unmountComponent();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
}); });
afterEach(() => { afterEach(() => {
@ -221,6 +243,37 @@ describe("history", () => {
]); ]);
}); });
it("should not modify anything on unrelated appstate change", async () => {
const rect = API.createElement({ type: "rectangle" });
await render(
<Excalidraw
handleKeyboardGlobally={true}
initialData={{
elements: [rect],
}}
/>,
);
API.updateScene({
appState: {
viewModeEnabled: true,
},
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect(h.state.viewModeEnabled).toBe(true);
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, isDeleted: false }),
]);
expect(h.store.snapshot.elements.get(rect.id)).toEqual(
expect.objectContaining({ id: rect.id, isDeleted: false }),
);
});
});
it("should not clear the redo stack on standalone appstate change", async () => { it("should not clear the redo stack on standalone appstate change", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
@ -559,6 +612,252 @@ describe("history", () => {
]); ]);
}); });
it("should create new history entry on image drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const deerImageDimensions = {
width: 318,
height: 335,
};
mockHTMLImageElement(
deerImageDimensions.width,
deerImageDimensions.height,
);
await API.drop(await API.loadFile("./fixtures/deer.png"));
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...deerImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...deerImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop(
new Blob([link], {
type: MIME_TYPES.text,
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should create new history entry on image paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const smileyImageDimensions = {
width: 56,
height: 77,
};
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
document.dispatchEvent(
createPasteEvent({
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...smileyImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...smileyImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
document.dispatchEvent(
createPasteEvent({
types: {
"text/plain": link,
},
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should support appstate name or viewBackgroundColor change", async () => { it("should support appstate name or viewBackgroundColor change", async () => {
await render( await render(
<Excalidraw <Excalidraw

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