Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel Mraz
6bcd5b622c
Coderabbit test 2025-05-22 13:18:12 +02:00
119 changed files with 6085 additions and 8618 deletions

View File

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -40,6 +40,7 @@
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
<img alt"CodeRabbit Reviews" src="https://img.shields.io/coderabbit/prs/github/excalidraw/excalidraw?utm_source=oss&utm_medium=github&utm_campaign=excalidraw%2Fexcalidraw&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews"/>
</p>
<div align="center">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,12 +5,11 @@ import {
isDevEnv,
isShallowEqual,
isTestEnv,
randomInteger,
} from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
@ -19,12 +18,7 @@ import type {
SceneElementsMap,
} from "@excalidraw/element/types";
import type {
DTO,
Mutable,
SubtypeOf,
ValueOf,
} from "@excalidraw/common/utility-types";
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
import type {
AppState,
@ -57,8 +51,6 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement";
@ -81,20 +73,13 @@ export class Delta<T> {
public static create<T>(
deleted: Partial<T>,
inserted: Partial<T>,
modifier?: (
delta: Partial<T>,
partialType: "deleted" | "inserted",
) => Partial<T>,
modifierOptions?: "deleted" | "inserted" | "both",
modifier?: (delta: Partial<T>) => Partial<T>,
modifierOptions?: "deleted" | "inserted",
) {
const modifiedDeleted =
modifier && modifierOptions !== "inserted"
? modifier(deleted, "deleted")
: deleted;
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
const modifiedInserted =
modifier && modifierOptions !== "deleted"
? modifier(inserted, "inserted")
: inserted;
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
return new Delta(modifiedDeleted, modifiedInserted);
}
@ -128,7 +113,11 @@ export class Delta<T> {
// - we do this only on previously detected changed elements
// - we do shallow compare only on the first level of properties (not going any deeper)
// - # of properties is reasonably small
for (const key of this.getDifferences(prevObject, nextObject)) {
for (const key of this.distinctKeysIterator(
"full",
prevObject,
nextObject,
)) {
deleted[key as keyof T] = prevObject[key];
inserted[key as keyof T] = nextObject[key];
}
@ -267,14 +256,12 @@ export class Delta<T> {
arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy),
),
(x) => x,
);
const insertedDifferences = arrayToObject(
Delta.getRightDifferences(
arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy),
),
(x) => x,
);
if (
@ -333,42 +320,6 @@ export class Delta<T> {
return !!anyDistinctKey;
}
/**
* Compares if shared properties of object1 and object2 contain any different value (aka inner join).
*/
public static isInnerDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = !!this.distinctKeysIterator(
"inner",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Compares if any properties of object1 and object2 contain any different value (aka full join).
*/
public static isDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = !!this.distinctKeysIterator(
"full",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Returns sorted object1 keys that have distinct values.
*/
@ -395,32 +346,6 @@ export class Delta<T> {
).sort();
}
/**
* Returns sorted keys of shared object1 and object2 properties that have distinct values (aka inner join).
*/
public static getInnerDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("inner", object1, object2, skipShallowCompare),
).sort();
}
/**
* Returns sorted keys that have distinct values between object1 and object2 (aka full join).
*/
public static getDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("full", object1, object2, skipShallowCompare),
).sort();
}
/**
* Iterator comparing values of object properties based on the passed joining strategy.
*
@ -429,7 +354,7 @@ export class Delta<T> {
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
*/
private static *distinctKeysIterator<T extends {}>(
join: "left" | "right" | "inner" | "full",
join: "left" | "right" | "full",
object1: T,
object2: T,
skipShallowCompare = false,
@ -444,8 +369,6 @@ export class Delta<T> {
keys = Object.keys(object1);
} else if (join === "right") {
keys = Object.keys(object2);
} else if (join === "inner") {
keys = Object.keys(object1).filter((key) => key in object2);
} else if (join === "full") {
keys = Array.from(
new Set([...Object.keys(object1), ...Object.keys(object2)]),
@ -459,17 +382,17 @@ export class Delta<T> {
}
for (const key of keys) {
const value1 = object1[key as keyof T];
const value2 = object2[key as keyof T];
const object1Value = object1[key as keyof T];
const object2Value = object2[key as keyof T];
if (value1 !== value2) {
if (object1Value !== object2Value) {
if (
!skipShallowCompare &&
typeof value1 === "object" &&
typeof value2 === "object" &&
value1 !== null &&
value2 !== null &&
isShallowEqual(value1, value2)
typeof object1Value === "object" &&
typeof object2Value === "object" &&
object1Value !== null &&
object2Value !== null &&
isShallowEqual(object1Value, object2Value)
) {
continue;
}
@ -694,20 +617,14 @@ export class AppStateDelta implements DeltaContainer<AppState> {
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (!croppingElementId) {
// previously there was a croppingElementId (assuming visible), now there is none
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
const element = nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
@ -941,17 +858,10 @@ export class AppStateDelta implements DeltaContainer<AppState> {
}
}
type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>;
};
type ApplyToFlags = {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
};
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
@ -1034,33 +944,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted,
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static satisfiesCommmonInvariants = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
!!(
deleted.version &&
inserted.version &&
// versions are required integers
Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) &&
// versions should be positive, zero included
deleted.version >= 0 &&
inserted.version >= 0 &&
// versions should never be the same
deleted.version !== inserted.version
);
private static validate(
elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated",
satifiesSpecialInvariants: (delta: Delta<ElementPartial>) => boolean,
satifies: (delta: Delta<ElementPartial>) => boolean,
) {
for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (
!this.satisfiesCommmonInvariants(delta) ||
!satifiesSpecialInvariants(delta)
) {
if (!satifies(delta)) {
console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`,
delta,
@ -1096,12 +986,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = {
isDeleted: true,
version: prevElement.version + 1,
versionNonce: randomInteger(),
} as ElementPartial;
const inserted = { isDeleted: true } as ElementPartial;
const delta = Delta.create(
deleted,
@ -1117,12 +1002,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const prevElement = prevElements.get(nextElement.id);
if (!prevElement) {
const deleted = {
isDeleted: true,
version: nextElement.version - 1,
versionNonce: randomInteger(),
} as ElementPartial;
const deleted = { isDeleted: true } as ElementPartial;
const inserted = {
...nextElement,
isDeleted: false,
@ -1207,40 +1087,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
/**
* Update delta/s based on the existing elements.
*
* @param nextElements current elements
* @param elements current elements
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): ElementsDelta {
const modifier =
(
prevElement: OrderedExcalidrawElement | undefined,
nextElement: OrderedExcalidrawElement | undefined,
) =>
(partial: ElementPartial, partialType: "deleted" | "inserted") => {
let element: OrderedExcalidrawElement | undefined;
switch (partialType) {
case "deleted":
element = prevElement;
break;
case "inserted":
element = nextElement;
break;
}
// the element wasn't found -> don't update the partial
if (!element) {
console.error(
`Element not found when trying to apply latest changes`,
);
return partial;
}
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
@ -1264,25 +1120,19 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) {
const prevElement = prevElements.get(id);
const nextElement = nextElements.get(id);
const existingElement = elements.get(id);
let latestDelta: Delta<ElementPartial> | null = null;
if (prevElement || nextElement) {
latestDelta = Delta.create(
if (existingElement) {
const modifiedDelta = Delta.create(
delta.deleted,
delta.inserted,
modifier(prevElement, nextElement),
modifier(existingElement),
modifierOptions,
);
} else {
latestDelta = delta;
}
// it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
modifiedDeltas[id] = latestDelta;
modifiedDeltas[id] = modifiedDelta;
} else {
modifiedDeltas[id] = delta;
}
}
@ -1300,15 +1150,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo(
elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
const flags: ApplyToFlags = {
const flags = {
containsVisibleDifference: false,
containsZindexDifference: false,
};
@ -1317,14 +1164,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
try {
const applyDeltas = ElementsDelta.createApplier(
nextElements,
snapshot,
options,
elementsSnapshot,
flags,
);
const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated);
const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas("updated", this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements);
@ -1383,12 +1229,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createApplier =
(
nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(deltas: Record<string, Delta<ElementPartial>>) => {
(
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter(
type,
nextElements,
snapshot,
flags,
@ -1398,13 +1250,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted);
if (element) {
const newElement = ElementsDelta.applyDelta(
element,
delta,
options,
flags,
);
const newElement = ElementsDelta.applyDelta(element, delta, flags);
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
}
@ -1415,9 +1261,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createGetter =
(
type: "added" | "removed" | "updated",
elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"],
flags: ApplyToFlags,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(id: string, partial: ElementPartial) => {
let element = elements.get(id);
@ -1431,7 +1281,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
if (
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true;
}
} else {
@ -1451,28 +1304,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta(
element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>,
options: ApplyToOptions,
flags: ApplyToFlags,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) {
const directlyApplicablePartial: Mutable<ElementPartial> = {};
// some properties are not directly applicable, such as:
// - boundElements which contains only diff)
// - version & versionNonce, if we don't want to return to previous versions
for (const key of Object.keys(delta.inserted) as Array<
keyof typeof delta.inserted
>) {
if (key === "boundElements") {
continue;
}
if (options.excludedProperties.has(key)) {
continue;
}
const value = delta.inserted[key];
Reflect.set(directlyApplicablePartial, key, value);
}
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
if (
delta.deleted.boundElements?.length ||
@ -1490,6 +1331,19 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
});
}
// TODO: this looks wrong, shouldn't be here
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
@ -1796,29 +1650,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
): [ElementPartial, ElementPartial] {
try {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
// don't diff the points as:
// - we can't ensure the multiplayer order consistency without fractional index on each point
// - we prefer to not merge the points, as it might just lead to unexpected / incosistent results
const deletedPoints =
(
deleted as ElementPartial<
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
>
).points ?? [];
const insertedPoints =
(
inserted as ElementPartial<
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
>
).points ?? [];
if (!Delta.isDifferent(deletedPoints, insertedPoints)) {
// delete the points from delta if there is no difference, otherwise leave them as they were captured due to consistency
Reflect.deleteProperty(deleted, "points");
Reflect.deleteProperty(inserted, "points");
}
} catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements delta.`);
@ -1834,7 +1665,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { id, updated, ...strippedPartial } = partial;
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,19 +19,9 @@ import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import {
syncInvalidIndicesImmutable,
hashElementsVersion,
hashString,
isInitializedImageElement,
isImageElement,
} from "./index";
import { hashElementsVersion, hashString } from "./index";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
export const CaptureUpdateAction = {
/**
@ -115,7 +105,7 @@ export class Store {
params:
| {
action: CaptureUpdateActionType;
elements: readonly ExcalidrawElement[] | undefined;
elements: SceneElementsMap | undefined;
appState: AppState | ObservedAppState | undefined;
}
| {
@ -139,21 +129,13 @@ export class Store {
} else {
// immediately create an immutable change of the scheduled updates,
// compared to the current state, so that they won't mutate later on during batching
// also, we have to compare against the current state,
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
const currentSnapshot = StoreSnapshot.create(
this.app.scene.getElementsMapIncludingDeleted(),
this.app.state,
);
const scheduledSnapshot = currentSnapshot.maybeClone(
action,
// let's sync invalid indices first, so that we could detect this change
// also have the synced elements immutable, so that we don't mutate elements,
// that are already in the scene, otherwise we wouldn't see any change
params.elements
? syncInvalidIndicesImmutable(params.elements)
: undefined,
params.elements,
params.appState,
);
@ -231,7 +213,16 @@ export class Store {
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
storeDelta = delta;
} else {
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
// calculate the deltas based on the previous and next snapshot
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
}
if (!storeDelta.isEmpty()) {
@ -514,24 +505,6 @@ export class StoreDelta {
return new this(opts.id, elements, appState);
}
/**
* Calculate the delta between the previous and next snapshot.
*/
public static calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const elementsDelta = nextSnapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsDelta.empty();
const appStateDelta = nextSnapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateDelta.empty();
return this.create(elementsDelta, appStateDelta);
}
/**
* Restore a store delta instance from a DTO.
*/
@ -551,7 +524,9 @@ export class StoreDelta {
id,
elements: { added, removed, updated },
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated);
const elements = ElementsDelta.create(added, removed, updated, {
shouldRedistribute: false,
});
return new this(id, elements, AppStateDelta.empty());
}
@ -559,10 +534,27 @@ export class StoreDelta {
/**
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta) {
public static inverse(delta: StoreDelta): StoreDelta {
return this.create(delta.elements.inverse(), delta.appState.inverse());
}
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(elements, modifierOptions),
delta.appState,
{
id: delta.id,
},
);
}
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
@ -570,9 +562,12 @@ export class StoreDelta {
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
delta.elements.applyTo(elements);
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements);
@ -583,28 +578,6 @@ export class StoreDelta {
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(
prevElements,
nextElements,
modifierOptions,
),
delta.appState,
{
id: delta.id,
},
);
}
public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty();
}
@ -714,10 +687,11 @@ export class StoreSnapshot {
nextElements.set(id, changedElement);
}
const nextAppState = getObservedAppState({
...this.appState,
...change.appState,
});
const nextAppState = Object.assign(
{},
this.appState,
change.appState,
) as ObservedAppState;
return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot
@ -873,7 +847,7 @@ export class StoreSnapshot {
}
/**
* Detect if there are any changed elements.
* Detect if there any changed elements.
*/
private detectChangedElements(
nextElements: SceneElementsMap,
@ -908,14 +882,6 @@ export class StoreSnapshot {
!prevElement || // element was added
prevElement.version < nextElement.version // element was updated
) {
if (
isImageElement(nextElement) &&
!isInitializedImageElement(nextElement)
) {
// ignore any updates on uninitialized image elements
continue;
}
changedElements.set(nextElement.id, nextElement);
}
}
@ -978,26 +944,18 @@ const getDefaultObservedAppState = (): ObservedAppState => {
};
};
export const getObservedAppState = (
appState: AppState | ObservedAppState,
): ObservedAppState => {
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections,
editingLinearElementId:
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
null,
selectedLinearElementId:
(appState as AppState).selectedLinearElement?.elementId ??
(appState as ObservedAppState).selectedLinearElementId ??
null,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

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

View File

@ -1,7 +1,5 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@ -27,7 +25,6 @@ import type {
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
@ -111,12 +108,6 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type);
};
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => {
@ -129,15 +120,6 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed;
};
/**
* sharp or curved arrow, but not elbow
*/
export const isSimpleArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed;
};
export const isSharpArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
@ -390,26 +372,3 @@ export const getLinearElementSubType = (
}
return "line";
};
/**
* Checks if current element points meet all the conditions for polygon=true
* (this isn't a element type check, for that use isLineElement).
*
* If you want to check if points *can* be turned into a polygon, use
* canBecomePolygon(points).
*/
export const isValidPolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
};
export const canBecomePolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return (
points.length > 3 ||
// 3-point polygons can't have all points in a single line
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import {
@ -32,11 +33,6 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -256,49 +252,7 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
// ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
it("should enter line editor when using double clicked with ctrl key", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
@ -308,37 +262,6 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(null);
await getTextEditor();
});
it("shouldn't create text element on double click in line editor (arrow)", async () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
});
describe("Inside editor", () => {
it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
createTwoPointerLinearElement("line");
@ -423,12 +346,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[
[
"54.27552",
"46.16120",
"55.96978",
"47.44233",
],
[
"76.95494",
"44.56052",
"76.08587",
"43.29417",
],
]
`);
@ -488,12 +411,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"104.27552",
"66.16120",
"105.96978",
"67.44233",
],
[
"126.95494",
"64.56052",
"126.08587",
"63.29417",
],
]
`);
@ -804,12 +727,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"29.28349",
"20.91105",
"31.88408",
"23.13276",
],
[
"78.86048",
"46.12277",
"77.74793",
"44.57841",
],
]
`);
@ -893,12 +816,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"54.27552",
"46.16120",
"55.96978",
"47.44233",
],
[
"76.95494",
"44.56052",
"76.08587",
"43.29417",
],
]
`);
@ -1060,17 +983,19 @@ describe("Test Linear Elements", () => {
);
expect(position).toMatchInlineSnapshot(`
{
"x": "86.17305",
"y": "76.11251",
"x": "85.82202",
"y": "75.63461",
}
`);
});
});
it("should match styles for text editor", async () => {
it("should match styles for text editor", () => {
createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).toMatchSnapshot();
});
@ -1087,7 +1012,9 @@ describe("Test Linear Elements", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id);
mouse.down();
const editor = await getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: { value: DEFAULT_TEXT },
@ -1115,7 +1042,9 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id);
const editor = await getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: { value: DEFAULT_TEXT },
@ -1134,7 +1063,13 @@ describe("Test Linear Elements", () => {
expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y);
expect(h.elements.length).toBe(1);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBeNull();
expect(line.boundElements).toBeNull();
});
// TODO fix #7029 and rewrite this test
@ -1299,7 +1234,9 @@ describe("Test Linear Elements", () => {
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
Keyboard.exitTextEditor(editor);
@ -1325,7 +1262,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(200, 0);
expect(arrow.width).toBeCloseTo(204, 0);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@ -1474,55 +1411,5 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).toBe(-20);
});
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(line);
const elementsMap = arrayToMap(h.elements);
const points = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate original angle between first and last point
const originalAngle = Math.atan2(
points[1][1] - points[0][1],
points[1][0] - points[0][0],
);
// Drag the second point (endpoint) with SHIFT key pressed
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
const endPoint = pointFrom<GlobalPoint>(
startPoint[0] + 4,
startPoint[1] + 4,
);
// Perform drag with SHIFT key modifier
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(startPoint[0], startPoint[1]);
mouse.moveTo(endPoint[0], endPoint[1]);
mouse.upAt(endPoint[0], endPoint[1]);
});
// Get updated points after drag
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate new angle
const newAngle = Math.atan2(
updatedPoints[1][1] - updatedPoints[0][1],
updatedPoints[1][0] - updatedPoints[0][0],
);
// The angle should be preserved (within a small tolerance for floating point precision)
const angleDifference = Math.abs(newAngle - originalAngle);
const tolerance = 0.01; // Small tolerance for floating point precision
expect(angleDifference).toBeLessThan(tolerance);
});
});
});

View File

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

View File

@ -1,5 +1,7 @@
import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3;
@ -55,4 +57,13 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
});
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
});
});
});

View File

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

View File

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

View File

@ -1,30 +1,19 @@
import { LinearElementEditor } from "@excalidraw/element";
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common";
import {
toggleLinePolygonState,
CaptureUpdateAction,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon, polygonIcon } from "../components/icons";
import { lineEditorIcon } from "../components/icons";
import { t } from "../i18n";
import { ButtonIcon } from "../components/ButtonIcon";
import { newElementWith } from "../../element/src/mutateElement";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@ -94,110 +83,3 @@ export const actionToggleLinearEditor = register({
);
},
});
export const actionTogglePolygon = register({
name: "togglePolygon",
category: DEFAULT_CATEGORIES.elements,
icon: polygonIcon,
keywords: ["loop"],
label: (elements, appState, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
const allPolygons = !selectedElements.some(
(element) => !isLineElement(element) || !element.polygon,
);
return allPolygons
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon";
},
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
return (
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 4,
)
);
},
perform(elements, appState, _, app) {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.some((element) => !isLineElement(element))) {
return false;
}
const targetElements = selectedElements as ExcalidrawLineElement[];
// if one element not a polygon, convert all to polygon
const nextPolygonState = targetElements.some((element) => !element.polygon);
const targetElementsMap = arrayToMap(targetElements);
return {
elements: elements.map((element) => {
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
return element;
}
return newElementWith(element, {
backgroundColor: nextPolygonState
? element.backgroundColor
: "transparent",
...toggleLinePolygonState(element, nextPolygonState),
});
}),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
if (
selectedElements.length === 0 ||
selectedElements.some(
(element) =>
!isLineElement(element) ||
// only show polygon button if every selected element is already
// a polygon, effectively showing this button only to allow for
// disabling the polygon state
!element.polygon ||
element.points.length < 3,
)
) {
return null;
}
const allPolygon = selectedElements.every(
(element) => isLineElement(element) && element.polygon,
);
const label = t(
allPolygon
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon",
);
return (
<ButtonIcon
icon={polygonIcon}
title={label}
aria-label={label}
active={allPolygon}
onClick={() => updateData(null)}
style={{ marginLeft: "auto" }}
/>
);
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,12 +2,7 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
import { useMemo } from "react";
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
import {
getElementsInResizingFrame,
isFrameLikeElement,
replaceAllElementsInFrame,
updateBoundElements,
} from "@excalidraw/element";
import { updateBoundElements } from "@excalidraw/element";
import {
rescalePointsInElement,
resizeSingleElement,
@ -30,10 +25,7 @@ import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
@ -161,8 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
nextValue,
scene,
property,
setAppState,
app,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
@ -249,25 +239,6 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false,
},
);
// Handle frame membership update for resized frames
if (isFrameLikeElement(latestElement)) {
const nextElementsInFrame = getElementsInResizingFrame(
scene.getElementsIncludingDeleted(),
latestElement,
originalAppState,
scene.getNonDeletedElementsMap(),
);
const updatedElements = replaceAllElementsInFrame(
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
}
}
}
}
@ -279,7 +250,6 @@ const handleDimensionChange: DragInputCallbackType<
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
const elementsToHighlight: ExcalidrawElement[] = [];
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
@ -372,63 +342,13 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false,
},
);
// Handle highlighting frame element candidates
if (isFrameLikeElement(latestElement)) {
const nextElementsInFrame = getElementsInResizingFrame(
scene.getElementsIncludingDeleted(),
latestElement,
originalAppState,
scene.getNonDeletedElementsMap(),
);
elementsToHighlight.push(...nextElementsInFrame);
}
}
}
}
setAppState({
elementsToHighlight,
});
scene.triggerUpdate();
};
const handleDragFinished: DragFinishedCallbackType = ({
setAppState,
app,
originalElements,
originalAppState,
}) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const origElement = originalElements?.[0];
const latestElement = origElement && elementsMap.get(origElement.id);
// Handle frame membership update for resized frames
if (latestElement && isFrameLikeElement(latestElement)) {
const nextElementsInFrame = getElementsInResizingFrame(
app.scene.getElementsIncludingDeleted(),
latestElement,
originalAppState,
app.scene.getNonDeletedElementsMap(),
);
const updatedElements = replaceAllElementsInFrame(
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
setAppState({
elementsToHighlight: null,
});
}
};
const MultiDimension = ({
property,
elements,
@ -476,7 +396,6 @@ const MultiDimension = ({
appState={appState}
property={property}
scene={scene}
dragFinishedCallback={handleDragFinished}
/>
);
};

View File

@ -133,6 +133,7 @@ describe("binding with linear elements", () => {
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204"));
@ -381,7 +382,8 @@ describe("stats for a non-generic element", () => {
it("text element", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = await getTextEditor();
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
@ -401,23 +403,11 @@ describe("stats for a non-generic element", () => {
UI.updateInput(input, "36");
expect(text.fontSize).toBe(36);
// can change width or height
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
const textHeightBeforeWrapping = text.height;
const textBeforeWrapping = text.text;
const originalTextBeforeWrapping = textBeforeWrapping;
UI.updateInput(width, "30");
expect(text.height).toBeGreaterThan(textHeightBeforeWrapping);
expect(text.text).not.toBe(textBeforeWrapping);
expect(text.originalText).toBe(originalTextBeforeWrapping);
// cannot change width or height
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined();
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined();
// min font size is 4
UI.updateInput(input, "0");
@ -586,7 +576,8 @@ describe("stats for multiple elements", () => {
// text, rectangle, frame
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = await getTextEditor();
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
@ -639,11 +630,12 @@ describe("stats for multiple elements", () => {
) as HTMLInputElement;
expect(fontSize).toBeDefined();
// changing width does not affect text
UI.updateInput(width, "200");
expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200);
expect(text?.width).toBe(200);
expect(text?.width).not.toBe(200);
UI.updateInput(angle, "40");
@ -665,7 +657,6 @@ describe("stats for multiple elements", () => {
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -737,196 +728,3 @@ describe("stats for multiple elements", () => {
expect(newGroupHeight).toBeCloseTo(500, 4);
});
});
describe("frame resizing behavior", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should add shapes to frame when resizing frame to encompass them", () => {
// Create a frame
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
// Create a rectangle outside the frame
const rectangle = API.createElement({
type: "rectangle",
x: 150,
y: 50,
width: 50,
height: 50,
});
API.setElements([frame, rectangle]);
// Initially, rectangle should not be in the frame
expect(rectangle.frameId).toBe(null);
// Select the frame
API.setAppState({
selectedElementIds: {
[frame.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
// Find the width input and update it to encompass the rectangle
const widthInput = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(widthInput).toBeDefined();
expect(widthInput.value).toBe("100");
// Resize frame to width 250, which should encompass the rectangle
UI.updateInput(widthInput, "250");
// After resizing, the rectangle should now be part of the frame
expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe(
frame.id,
);
});
it("should add multiple shapes when frame encompasses them through height resize", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 200,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 50,
y: 150,
width: 50,
height: 50,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 100,
y: 180,
width: 40,
height: 40,
});
API.setElements([frame, rectangle1, rectangle2]);
// Initially, rectangles should not be in the frame
expect(rectangle1.frameId).toBe(null);
expect(rectangle2.frameId).toBe(null);
// Select the frame
API.setAppState({
selectedElementIds: {
[frame.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
// Resize frame height to encompass both rectangles
const heightInput = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
// Resize frame to height 250, which should encompass both rectangles
UI.updateInput(heightInput, "250");
// After resizing, both rectangles should now be part of the frame
expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe(
frame.id,
);
expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe(
frame.id,
);
});
it("should not affect shapes that remain outside frame after resize", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const insideRect = API.createElement({
type: "rectangle",
x: 120,
y: 50,
width: 30,
height: 30,
});
const outsideRect = API.createElement({
type: "rectangle",
x: 300,
y: 50,
width: 30,
height: 30,
});
API.setElements([frame, insideRect, outsideRect]);
// Initially, both rectangles should not be in the frame
expect(insideRect.frameId).toBe(null);
expect(outsideRect.frameId).toBe(null);
// Select the frame
API.setAppState({
selectedElementIds: {
[frame.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
// Resize frame width to 200, which should only encompass insideRect
const widthInput = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
UI.updateInput(widthInput, "200");
// After resizing, only insideRect should be in the frame
expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe(
frame.id,
);
expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe(
null,
);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -129,21 +129,6 @@ export const PinIcon = createIcon(
tablerIconProps,
);
export const polygonIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 8m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M15 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M6.5 9.5l3.5 -3" />
<path d="M14 5.5l3 1.5" />
<path d="M18.5 10l-2.5 7" />
<path d="M13.5 17.5l-7 -5" />
</g>,
tablerIconProps,
);
// tabler-icons: lock-open (via Figma)
export const UnlockedIcon = createIcon(
<g>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,81 +4,14 @@ import {
CaptureUpdateAction,
StoreChange,
StoreDelta,
type Store,
} from "@excalidraw/element";
import type { StoreSnapshot, Store } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import type { AppState } from "./types";
export class HistoryDelta extends StoreDelta {
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
public applyTo(
elements: SceneElementsMap,
appState: AppState,
snapshot: StoreSnapshot,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
elements,
// used to fallback into local snapshot in case we couldn't apply the delta
// due to a missing (force deleted) elements in the scene
snapshot.elements,
// we don't want to apply the `version` and `versionNonce` properties for history
// as we always need to end up with a new version due to collaboration,
// approaching each undo / redo as a new user action
{
excludedProperties: new Set(["version", "versionNonce"]),
},
);
const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
appState,
nextElements,
);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override inverse(delta: StoreDelta): HistoryDelta {
return super.inverse(delta) as HistoryDelta;
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
) {
return super.applyLatestChanges(
delta,
prevElements,
nextElements,
modifierOptions,
) as HistoryDelta;
}
}
class HistoryEntry extends StoreDelta {}
export class HistoryChangedEvent {
constructor(
@ -92,8 +25,8 @@ export class History {
[HistoryChangedEvent]
>();
public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryDelta[] = [];
public readonly undoStack: HistoryEntry[] = [];
public readonly redoStack: HistoryEntry[] = [];
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
@ -115,16 +48,16 @@ export class History {
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
*/
public record(delta: StoreDelta) {
if (delta.isEmpty() || delta instanceof HistoryDelta) {
if (delta.isEmpty() || delta instanceof HistoryEntry) {
return;
}
// construct history entry, so once it's emitted, it's not recorded again
const historyDelta = HistoryDelta.inverse(delta);
const entry = HistoryEntry.inverse(delta);
this.undoStack.push(historyDelta);
this.undoStack.push(entry);
if (!historyDelta.elements.isEmpty()) {
if (!entry.elements.isEmpty()) {
// don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes!
@ -141,7 +74,7 @@ export class History {
elements,
appState,
() => History.pop(this.undoStack),
(entry: HistoryDelta) => History.push(this.redoStack, entry),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
);
}
@ -150,20 +83,20 @@ export class History {
elements,
appState,
() => History.pop(this.redoStack),
(entry: HistoryDelta) => History.push(this.undoStack, entry),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
);
}
private perform(
elements: SceneElementsMap,
appState: AppState,
pop: () => HistoryDelta | null,
push: (entry: HistoryDelta) => void,
pop: () => HistoryEntry | null,
push: (entry: HistoryEntry) => void,
): [SceneElementsMap, AppState] | void {
try {
let historyDelta = pop();
let historyEntry = pop();
if (historyDelta === null) {
if (historyEntry === null) {
return;
}
@ -175,47 +108,41 @@ export class History {
let nextAppState = appState;
let containsVisibleChange = false;
// iterate through the history entries in case ;they result in no visible changes
while (historyDelta) {
// iterate through the history entries in case they result in no visible changes
while (historyEntry) {
try {
[nextElements, nextAppState, containsVisibleChange] =
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
StoreDelta.applyTo(
historyEntry,
nextElements,
nextAppState,
prevSnapshot,
);
const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone(
action,
nextElements,
nextAppState,
);
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const delta = HistoryDelta.applyLatestChanges(
historyDelta,
prevElements,
nextElements,
);
if (!delta.isEmpty()) {
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({
action,
change,
delta,
});
historyDelta = delta;
}
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({
action,
change: StoreChange.create(prevSnapshot, nextSnapshot),
delta: historyEntry,
});
prevSnapshot = nextSnapshot;
} finally {
push(historyDelta);
// make sure to always push, even if the delta is corrupted
push(historyEntry);
}
if (containsVisibleChange) {
break;
}
historyDelta = pop();
historyEntry = pop();
}
return [nextElements, nextAppState];
@ -228,7 +155,7 @@ export class History {
}
}
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
private static pop(stack: HistoryEntry[]): HistoryEntry | null {
if (!stack.length) {
return null;
}
@ -242,8 +169,18 @@ export class History {
return null;
}
private static push(stack: HistoryDelta[], entry: HistoryDelta) {
const inversedEntry = HistoryDelta.inverse(entry);
return stack.push(inversedEntry);
private static push(
stack: HistoryEntry[],
entry: HistoryEntry,
prevElements: SceneElementsMap,
) {
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry);
}
}

View File

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

View File

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

View File

@ -141,10 +141,6 @@
"edit": "Edit line",
"editArrow": "Edit arrow"
},
"polygon": {
"breakPolygon": "Break polygon",
"convertToPolygon": "Convert to polygon"
},
"elementLock": {
"lock": "Lock",
"unlock": "Unlock",
@ -344,9 +340,9 @@
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating",
"lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
"lineEditor_line_info": "Double-click or press Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually",
"publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text",
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,10 +6,7 @@ import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element";
import * as sizeHelpers from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
@ -166,109 +163,6 @@ 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', () => {
const arrowElement = API.createElement({ type: "arrow" });
const restoredElements = restore.restoreElements([arrowElement], null);

View File

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

View File

@ -1,3 +1,4 @@
import React from "react";
import { vi } from "vitest";
import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common";
@ -36,10 +37,6 @@ import {
waitFor,
} from "./test-utils";
import { getTextEditor } from "./queries/dom";
import { mockHTMLImageElement } from "./helpers/mocks";
import type { NormalizedZoomValue } from "../types";
const { h } = window;
@ -744,28 +741,6 @@ describe("freedraw", () => {
//image
//TODO: currently there is no test for pixel colors at flipped positions.
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 sendPasteEvent = (file?: File) => {
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
@ -871,7 +846,9 @@ describe("mutliple elements", () => {
});
Keyboard.keyPress(KEYS.ENTER);
let editor = await getTextEditor();
let editor = document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "arrow" } });
Keyboard.exitTextEditor(editor);
@ -883,7 +860,9 @@ describe("mutliple elements", () => {
});
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor();
editor = document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "rect\ntext" } });
Keyboard.exitTextEditor(editor);

View File

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

View File

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

View File

@ -19,7 +19,6 @@ import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
} from "@excalidraw/common";
import "@excalidraw/utils/test-utils";
@ -36,7 +35,6 @@ import type {
ExcalidrawGenericElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FileId,
FixedPointBinding,
FractionalIndex,
SceneElementsMap,
@ -51,16 +49,12 @@ import {
} from "../actions";
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import * as StaticScene from "../renderer/staticScene";
import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index";
import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { mockHTMLImageElement } from "./helpers/mocks";
import {
GlobalTestState,
act,
@ -69,7 +63,6 @@ import {
togglePopover,
getCloneByOrigId,
checkpointHistory,
unmountComponent,
} from "./test-utils";
import type { AppState } from "../types";
@ -113,22 +106,7 @@ const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
describe("history", () => {
beforeEach(() => {
unmountComponent();
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(() => {
@ -243,37 +221,6 @@ 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 () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
@ -612,252 +559,6 @@ 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 () => {
await render(
<Excalidraw

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