Compare commits
42 Commits
master
...
zsviczian-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8110de9e70 | ||
![]() |
2d6469a695 | ||
![]() |
23cf5fb475 | ||
![]() |
d56bb4087f | ||
![]() |
ad1c28c27c | ||
![]() |
4dc205537c | ||
![]() |
cc571c4681 | ||
![]() |
14d512f321 | ||
![]() |
41c036e1a5 | ||
![]() |
91d36e9b81 | ||
![]() |
27522110df | ||
![]() |
d645e0ed13 | ||
![]() |
d3ee66b7cc | ||
![]() |
7ce0615411 | ||
![]() |
defdd7977c | ||
![]() |
54b124c4f4 | ||
![]() |
7c3190d4bb | ||
![]() |
e7deda0404 | ||
![]() |
74dcaeebda | ||
![]() |
35fa4fc041 | ||
![]() |
b0174503d0 | ||
![]() |
0f18b9832f | ||
![]() |
d1fa9005b9 | ||
![]() |
ac1ad31921 | ||
![]() |
703e37f84c | ||
![]() |
012076a3e9 | ||
![]() |
b3eb93f130 | ||
![]() |
904c209f96 | ||
![]() |
d0be24bd6a | ||
![]() |
6d6b958f27 | ||
![]() |
f832bf9fde | ||
![]() |
a7b4b08e86 | ||
![]() |
eb619f8fde | ||
![]() |
1fdf8967ed | ||
![]() |
a9a2c953b4 | ||
![]() |
6aea288dcd | ||
![]() |
ce9257b6fc | ||
![]() |
3ee5e62c0e | ||
![]() |
9dc588efa2 | ||
![]() |
bab365bc62 | ||
![]() |
417d6de2e4 | ||
![]() |
54b4a304c9 |
@ -1,3 +1,5 @@
|
|||||||
|
MODE="development"
|
||||||
|
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
MODE="production"
|
||||||
|
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
45
.github/copilot-instructions.md
vendored
Normal file
45
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Project coding standards
|
||||||
|
|
||||||
|
## Generic Communication Guidelines
|
||||||
|
|
||||||
|
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||||
|
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||||
|
- Stop apologising if corrected, just provide the correct information or code
|
||||||
|
- Prefer code unless asked for explanation
|
||||||
|
- Stop summarizing what you've changed after modifications unless asked for
|
||||||
|
|
||||||
|
## TypeScript Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript for all new code
|
||||||
|
- Where possible, prefer implementations without allocation
|
||||||
|
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||||
|
- Prefer immutable data (const, readonly)
|
||||||
|
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||||
|
|
||||||
|
## React Guidelines
|
||||||
|
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Follow the React hooks rules (no conditional hooks)
|
||||||
|
- Keep components small and focused
|
||||||
|
- Use CSS modules for component styling
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Use PascalCase for component names, interfaces, and type aliases
|
||||||
|
- Use camelCase for variables, functions, and methods
|
||||||
|
- Use ALL_CAPS for constants
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Use try/catch blocks for async operations
|
||||||
|
- Implement proper error boundaries in React components
|
||||||
|
- Always log errors with contextual information
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Always attempt to fix #problems
|
||||||
|
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,4 +25,5 @@ packages/excalidraw/types
|
|||||||
coverage
|
coverage
|
||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
meta*.json
|
meta*.json
|
||||||
|
.claude
|
||||||
|
34
CLAUDE.md
Normal file
34
CLAUDE.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||||
|
|
||||||
|
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||||
|
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||||
|
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||||
|
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Package Development**: Work in `packages/*` for editor features
|
||||||
|
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||||
|
3. **Testing**: Always run `yarn test:update` before committing
|
||||||
|
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test:typecheck # TypeScript type checking
|
||||||
|
yarn test:update # Run all tests (with snapshot updates)
|
||||||
|
yarn fix # Auto-fix formatting and linting issues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
### Package System
|
||||||
|
|
||||||
|
- Uses Yarn workspaces for monorepo management
|
||||||
|
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||||
|
- Build system uses esbuild for packages, Vite for the app
|
||||||
|
- TypeScript throughout with strict configuration
|
@ -34,6 +34,9 @@
|
|||||||
<a href="https://discord.gg/UexuTaE">
|
<a href="https://discord.gg/UexuTaE">
|
||||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||||
|
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
|
||||||
|
</a>
|
||||||
<a href="https://twitter.com/excalidraw">
|
<a href="https://twitter.com/excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||||
</a>
|
</a>
|
||||||
|
@ -477,3 +477,10 @@ export enum UserIdleState {
|
|||||||
AWAY = "away",
|
AWAY = "away",
|
||||||
IDLE = "idle",
|
IDLE = "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* distance at which we merge points instead of adding a new merge-point
|
||||||
|
* when converting a line to a polygon (merge currently means overlaping
|
||||||
|
* the start and end points)
|
||||||
|
*/
|
||||||
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
@ -974,6 +974,25 @@ export const updateElbowArrowPoints = (
|
|||||||
),
|
),
|
||||||
"Elbow arrow segments must be either horizontal or vertical",
|
"Elbow arrow segments must be either horizontal or vertical",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
updates.fixedSegments?.find(
|
||||||
|
(segment) =>
|
||||||
|
segment.index === 1 &&
|
||||||
|
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
|
||||||
|
) == null &&
|
||||||
|
updates.fixedSegments?.find(
|
||||||
|
(segment) =>
|
||||||
|
segment.index === (updates.points ?? arrow.points).length - 1 &&
|
||||||
|
pointsEqual(
|
||||||
|
segment.end,
|
||||||
|
(updates.points ?? arrow.points)[
|
||||||
|
(updates.points ?? arrow.points).length - 1
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) == null,
|
||||||
|
"The first and last segments cannot be fixed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||||
|
@ -63,10 +63,13 @@ import {
|
|||||||
getControlPointsForBezierCurve,
|
getControlPointsForBezierCurve,
|
||||||
mapIntervalToBezierT,
|
mapIntervalToBezierT,
|
||||||
getBezierXY,
|
getBezierXY,
|
||||||
|
toggleLinePolygonState,
|
||||||
} from "./shapes";
|
} from "./shapes";
|
||||||
|
|
||||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||||
|
|
||||||
|
import { isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
@ -85,6 +88,35 @@ import type {
|
|||||||
PointsPositionUpdates,
|
PointsPositionUpdates,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes line points so that the start point is at [0,0]. This is
|
||||||
|
* expected in various parts of the codebase.
|
||||||
|
*
|
||||||
|
* Also returns the offsets - [0,0] if no normalization needed.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const getNormalizedPoints = ({
|
||||||
|
points,
|
||||||
|
}: {
|
||||||
|
points: ExcalidrawLinearElement["points"];
|
||||||
|
}): {
|
||||||
|
points: LocalPoint[];
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
} => {
|
||||||
|
const offsetX = points[0][0];
|
||||||
|
const offsetY = points[0][1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: points.map((p) => {
|
||||||
|
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||||
|
}),
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public readonly elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@ -127,7 +159,11 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||||
console.error("Linear element is not normalized", Error().stack);
|
console.error("Linear element is not normalized", Error().stack);
|
||||||
LinearElementEditor.normalizePoints(element, elementsMap);
|
mutateElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.selectedPointsIndices = null;
|
this.selectedPointsIndices = null;
|
||||||
this.lastUncommittedPoint = null;
|
this.lastUncommittedPoint = null;
|
||||||
@ -459,6 +495,18 @@ export class LinearElementEditor {
|
|||||||
selectedPoint === element.points.length - 1
|
selectedPoint === element.points.length - 1
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
|
if (isLineElement(element)) {
|
||||||
|
scene.mutateElement(
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
...toggleLinePolygonState(element, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
informMutation: false,
|
||||||
|
isDragging: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
scene,
|
||||||
@ -946,9 +994,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, app.scene, [
|
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||||
points.length - 1,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@ -999,7 +1045,7 @@ export class LinearElementEditor {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@ -1142,40 +1188,23 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes line points so that the start point is at [0,0]. This is
|
* Normalizes line points so that the start point is at [0,0]. This is
|
||||||
* expected in various parts of the codebase. Also returns new x/y to account
|
* expected in various parts of the codebase.
|
||||||
* for the potential normalization.
|
*
|
||||||
|
* Also returns normalized x and y coords to account for the normalization
|
||||||
|
* of the points.
|
||||||
*/
|
*/
|
||||||
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
|
||||||
points: LocalPoint[];
|
const { points, offsetX, offsetY } = getNormalizedPoints(element);
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} {
|
|
||||||
const { points } = element;
|
|
||||||
|
|
||||||
const offsetX = points[0][0];
|
|
||||||
const offsetY = points[0][1];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points: points.map((p) => {
|
points,
|
||||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
|
||||||
}),
|
|
||||||
x: element.x + offsetX,
|
x: element.x + offsetX,
|
||||||
y: element.y + offsetY,
|
y: element.y + offsetY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// element-mutating methods
|
// element-mutating methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
static normalizePoints(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
) {
|
|
||||||
mutateElement(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
LinearElementEditor.getNormalizedPoints(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||||
invariant(
|
invariant(
|
||||||
appState.editingLinearElement,
|
appState.editingLinearElement,
|
||||||
@ -1254,41 +1283,47 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static deletePoints(
|
static deletePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
scene: Scene,
|
app: AppClassProperties,
|
||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
) {
|
) {
|
||||||
let offsetX = 0;
|
const isUncommittedPoint =
|
||||||
let offsetY = 0;
|
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||||
|
element.points[element.points.length - 1];
|
||||||
|
|
||||||
const isDeletingOriginPoint = pointIndices.includes(0);
|
const isPolygon = isLineElement(element) && element.polygon;
|
||||||
|
|
||||||
// if deleting first point, make the next to be [0,0] and recalculate
|
// break polygon if deleting start/end point
|
||||||
// positions of the rest with respect to it
|
if (
|
||||||
if (isDeletingOriginPoint) {
|
isPolygon &&
|
||||||
const firstNonDeletedPoint = element.points.find((point, idx) => {
|
// don't disable polygon if cleaning up uncommitted point
|
||||||
return !pointIndices.includes(idx);
|
!isUncommittedPoint &&
|
||||||
});
|
(pointIndices.includes(0) ||
|
||||||
if (firstNonDeletedPoint) {
|
pointIndices.includes(element.points.length - 1))
|
||||||
offsetX = firstNonDeletedPoint[0];
|
) {
|
||||||
offsetY = firstNonDeletedPoint[1];
|
app.scene.mutateElement(element, { polygon: false });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
const nextPoints = element.points.filter((_, idx) => {
|
||||||
if (!pointIndices.includes(idx)) {
|
return !pointIndices.includes(idx);
|
||||||
acc.push(
|
});
|
||||||
!acc.length
|
|
||||||
? pointFrom(0, 0)
|
if (isUncommittedPoint && isLineElement(element) && element.polygon) {
|
||||||
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
nextPoints[0] = pointFrom(
|
||||||
);
|
nextPoints[nextPoints.length - 1][0],
|
||||||
}
|
nextPoints[nextPoints.length - 1][1],
|
||||||
return acc;
|
);
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
points: normalizedPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
} = getNormalizedPoints({ points: nextPoints });
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
nextPoints,
|
normalizedPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
);
|
);
|
||||||
@ -1297,16 +1332,27 @@ export class LinearElementEditor {
|
|||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
targetPoints: { point: LocalPoint }[],
|
addedPoints: LocalPoint[],
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const nextPoints = [...element.points, ...addedPoints];
|
||||||
const offsetY = 0;
|
|
||||||
|
if (isLineElement(element) && element.polygon) {
|
||||||
|
nextPoints[0] = pointFrom(
|
||||||
|
nextPoints[nextPoints.length - 1][0],
|
||||||
|
nextPoints[nextPoints.length - 1][1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
points: normalizedPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
} = getNormalizedPoints({ points: nextPoints });
|
||||||
|
|
||||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
scene,
|
||||||
nextPoints,
|
normalizedPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
);
|
);
|
||||||
@ -1323,17 +1369,37 @@ export class LinearElementEditor {
|
|||||||
) {
|
) {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
|
// if polygon, move start and end points together
|
||||||
|
if (isLineElement(element) && element.polygon) {
|
||||||
|
const firstPointUpdate = pointUpdates.get(0);
|
||||||
|
const lastPointUpdate = pointUpdates.get(points.length - 1);
|
||||||
|
|
||||||
|
if (firstPointUpdate) {
|
||||||
|
pointUpdates.set(points.length - 1, {
|
||||||
|
point: pointFrom(
|
||||||
|
firstPointUpdate.point[0],
|
||||||
|
firstPointUpdate.point[1],
|
||||||
|
),
|
||||||
|
isDragging: firstPointUpdate.isDragging,
|
||||||
|
});
|
||||||
|
} else if (lastPointUpdate) {
|
||||||
|
pointUpdates.set(0, {
|
||||||
|
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
|
||||||
|
isDragging: lastPointUpdate.isDragging,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// in case we're moving start point, instead of modifying its position
|
// in case we're moving start point, instead of modifying its position
|
||||||
// which would break the invariant of it being at [0,0], we move
|
// which would break the invariant of it being at [0,0], we move
|
||||||
// all the other points in the opposite direction by delta to
|
// all the other points in the opposite direction by delta to
|
||||||
// offset it. We do the same with actual element.x/y position, so
|
// offset it. We do the same with actual element.x/y position, so
|
||||||
// this hacks are completely transparent to the user.
|
// this hacks are completely transparent to the user.
|
||||||
const [deltaX, deltaY] =
|
|
||||||
|
const updatedOriginPoint =
|
||||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
|
||||||
deltaX - points[0][0],
|
const [offsetX, offsetY] = updatedOriginPoint;
|
||||||
deltaY - points[0][1],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextPoints = isElbowArrow(element)
|
const nextPoints = isElbowArrow(element)
|
||||||
? [
|
? [
|
||||||
@ -1503,6 +1569,7 @@ export class LinearElementEditor {
|
|||||||
isDragging: options?.isDragging ?? false,
|
isDragging: options?.isDragging ?? false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// TODO do we need to get precise coords here just to calc centers?
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
const prevCoords = getElementPointsCoords(element, element.points);
|
const prevCoords = getElementPointsCoords(element, element.points);
|
||||||
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
||||||
@ -1511,7 +1578,7 @@ export class LinearElementEditor {
|
|||||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||||
const dX = prevCenterX - nextCenterX;
|
const dX = prevCenterX - nextCenterX;
|
||||||
const dY = prevCenterY - nextCenterY;
|
const dY = prevCenterY - nextCenterY;
|
||||||
const rotated = pointRotateRads(
|
const rotatedOffset = pointRotateRads(
|
||||||
pointFrom(offsetX, offsetY),
|
pointFrom(offsetX, offsetY),
|
||||||
pointFrom(dX, dY),
|
pointFrom(dX, dY),
|
||||||
element.angle,
|
element.angle,
|
||||||
@ -1519,8 +1586,8 @@ export class LinearElementEditor {
|
|||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
...otherUpdates,
|
...otherUpdates,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
x: element.x + rotated[0],
|
x: element.x + rotatedOffset[0],
|
||||||
y: element.y + rotated[1],
|
y: element.y + rotatedOffset[1],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
|
|||||||
import { normalizeText, measureText } from "./textMeasurements";
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
|
|
||||||
|
import { isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
@ -45,6 +47,7 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
@ -457,9 +460,10 @@ export const newLinearElement = (
|
|||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawLinearElement["type"];
|
type: ExcalidrawLinearElement["type"];
|
||||||
points?: ExcalidrawLinearElement["points"];
|
points?: ExcalidrawLinearElement["points"];
|
||||||
|
polygon?: ExcalidrawLineElement["polygon"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawLinearElement> => {
|
): NonDeleted<ExcalidrawLinearElement> => {
|
||||||
return {
|
const element = {
|
||||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
lastCommittedPoint: null,
|
||||||
@ -468,6 +472,17 @@ export const newLinearElement = (
|
|||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: null,
|
endArrowhead: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLineElement(element)) {
|
||||||
|
const lineElement: NonDeleted<ExcalidrawLineElement> = {
|
||||||
|
...element,
|
||||||
|
polygon: opts.polygon ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return lineElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newArrowElement = <T extends boolean>(
|
export const newArrowElement = <T extends boolean>(
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
invariant,
|
invariant,
|
||||||
elementCenterPoint,
|
elementCenterPoint,
|
||||||
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
isPoint,
|
isPoint,
|
||||||
@ -35,10 +36,13 @@ import { ShapeCache } from "./ShapeCache";
|
|||||||
|
|
||||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
||||||
|
|
||||||
|
import { canBecomePolygon } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -396,3 +400,44 @@ export const isPathALoop = (
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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.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;
|
||||||
|
};
|
||||||
|
@ -3,13 +3,21 @@ import {
|
|||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointsEqual } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isFreeDrawElement,
|
||||||
|
isLinearElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||||
|
|
||||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||||
// - could also be part of `_clearElements`
|
// - could also be part of `_clearElements`
|
||||||
@ -17,8 +25,18 @@ export const isInvisiblySmallElement = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
return element.points.length < 2;
|
return (
|
||||||
|
element.points.length < 2 ||
|
||||||
|
(element.points.length === 2 &&
|
||||||
|
isArrowElement(element) &&
|
||||||
|
pointsEqual(
|
||||||
|
element.points[0],
|
||||||
|
element.points[element.points.length - 1],
|
||||||
|
INVISIBLY_SMALL_ELEMENT_SIZE,
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return element.width === 0 && element.height === 0;
|
return element.width === 0 && element.height === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointsEqual } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||||
@ -25,6 +27,7 @@ import type {
|
|||||||
ExcalidrawMagicFrameElement,
|
ExcalidrawMagicFrameElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
ExcalidrawFlowchartNodeElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
@ -108,6 +111,12 @@ export const isLinearElement = (
|
|||||||
return element != null && isLinearElementType(element.type);
|
return element != null && isLinearElementType(element.type);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isLineElement = (
|
||||||
|
element?: ExcalidrawElement | null,
|
||||||
|
): element is ExcalidrawLineElement => {
|
||||||
|
return element != null && element.type === "line";
|
||||||
|
};
|
||||||
|
|
||||||
export const isArrowElement = (
|
export const isArrowElement = (
|
||||||
element?: ExcalidrawElement | null,
|
element?: ExcalidrawElement | null,
|
||||||
): element is ExcalidrawArrowElement => {
|
): element is ExcalidrawArrowElement => {
|
||||||
@ -372,3 +381,26 @@ export const getLinearElementSubType = (
|
|||||||
}
|
}
|
||||||
return "line";
|
return "line";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if current element points meet all the conditions for polygon=true
|
||||||
|
* (this isn't a element type check, for that use isLineElement).
|
||||||
|
*
|
||||||
|
* If you want to check if points *can* be turned into a polygon, use
|
||||||
|
* canBecomePolygon(points).
|
||||||
|
*/
|
||||||
|
export const isValidPolygon = (
|
||||||
|
points: ExcalidrawLineElement["points"],
|
||||||
|
): boolean => {
|
||||||
|
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canBecomePolygon = (
|
||||||
|
points: ExcalidrawLineElement["points"],
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
points.length > 3 ||
|
||||||
|
// 3-point polygons can't have all points in a single line
|
||||||
|
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -296,8 +296,10 @@ export type FixedPointBinding = Merge<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type Index = number;
|
||||||
|
|
||||||
export type PointsPositionUpdates = Map<
|
export type PointsPositionUpdates = Map<
|
||||||
number,
|
Index,
|
||||||
{ point: LocalPoint; isDragging?: boolean }
|
{ point: LocalPoint; isDragging?: boolean }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@ -326,10 +328,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||||||
endArrowhead: Arrowhead | null;
|
endArrowhead: Arrowhead | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||||
|
Readonly<{
|
||||||
|
type: "line";
|
||||||
|
polygon: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type FixedSegment = {
|
export type FixedSegment = {
|
||||||
start: LocalPoint;
|
start: LocalPoint;
|
||||||
end: LocalPoint;
|
end: LocalPoint;
|
||||||
index: number;
|
index: Index;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||||
|
@ -292,7 +292,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(line.points).toMatchInlineSnapshot(`
|
expect(line.points).toMatchInlineSnapshot(`
|
||||||
@ -333,7 +333,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`9`,
|
`9`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
||||||
h.elements[0] as ExcalidrawLinearElement,
|
h.elements[0] as ExcalidrawLinearElement,
|
||||||
@ -394,7 +394,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
|
|
||||||
expect([line.x, line.y]).toEqual([
|
expect([line.x, line.y]).toEqual([
|
||||||
points[0][0] + deltaX,
|
points[0][0] + deltaX,
|
||||||
@ -462,7 +462,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`16`,
|
`16`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
|
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
@ -513,7 +513,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
@ -554,7 +554,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
@ -602,7 +602,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`18`,
|
`18`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
|
|
||||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
line,
|
line,
|
||||||
@ -660,7 +660,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`16`,
|
`16`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||||
@ -758,7 +758,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
|
@ -258,11 +258,7 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||||
element,
|
|
||||||
app.scene,
|
|
||||||
selectedPointsIndices,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
|
@ -3,10 +3,16 @@ import { pointFrom } from "@excalidraw/math";
|
|||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "@excalidraw/element";
|
isBindingEnabled,
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
} from "@excalidraw/element/binding";
|
||||||
|
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
import {
|
||||||
|
isBindingElement,
|
||||||
|
isFreeDrawElement,
|
||||||
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||||
import { isPathALoop } from "@excalidraw/element";
|
import { isPathALoop } from "@excalidraw/element";
|
||||||
@ -15,6 +21,13 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
NonDeleted,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
@ -28,11 +41,50 @@ export const actionFinalize = register({
|
|||||||
name: "finalize",
|
name: "finalize",
|
||||||
label: "",
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
const { interactiveCanvas, focusContainer, scene } = app;
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
if (data?.event && appState.selectedLinearElement) {
|
||||||
|
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||||
|
data.event,
|
||||||
|
appState.selectedLinearElement,
|
||||||
|
appState,
|
||||||
|
app.scene,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||||
|
const element = app.scene.getElement(linearElementEditor.elementId);
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
bindOrUnbindLinearElement(
|
||||||
|
element,
|
||||||
|
startBindingElement,
|
||||||
|
endBindingElement,
|
||||||
|
app.scene,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linearElementEditor !== appState.selectedLinearElement) {
|
||||||
|
let newElements = elements;
|
||||||
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
|
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
elements: newElements,
|
||||||
|
appState: {
|
||||||
|
selectedLinearElement: {
|
||||||
|
...linearElementEditor,
|
||||||
|
selectedPointsIndices: null,
|
||||||
|
},
|
||||||
|
suggestedBindings: [],
|
||||||
|
},
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
@ -47,6 +99,12 @@ export const actionFinalize = register({
|
|||||||
scene,
|
scene,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
polygon: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
@ -80,75 +138,98 @@ export const actionFinalize = register({
|
|||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
let element: NonDeleted<ExcalidrawElement> | null = null;
|
||||||
? appState.multiElement
|
if (appState.multiElement) {
|
||||||
: appState.newElement?.type === "freedraw"
|
element = appState.multiElement;
|
||||||
? appState.newElement
|
} else if (
|
||||||
: null;
|
appState.newElement?.type === "freedraw" ||
|
||||||
|
isBindingElement(appState.newElement)
|
||||||
|
) {
|
||||||
|
element = appState.newElement;
|
||||||
|
} else if (Object.keys(appState.selectedElementIds).length === 1) {
|
||||||
|
const candidate = elementsMap.get(
|
||||||
|
Object.keys(appState.selectedElementIds)[0],
|
||||||
|
) as NonDeleted<ExcalidrawLinearElement> | undefined;
|
||||||
|
if (candidate) {
|
||||||
|
element = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (multiPointElement) {
|
if (element) {
|
||||||
// pen and mouse have hover
|
// pen and mouse have hover
|
||||||
if (
|
if (
|
||||||
multiPointElement.type !== "freedraw" &&
|
appState.multiElement &&
|
||||||
|
element.type !== "freedraw" &&
|
||||||
appState.lastPointerDownWith !== "touch"
|
appState.lastPointerDownWith !== "touch"
|
||||||
) {
|
) {
|
||||||
const { points, lastCommittedPoint } = multiPointElement;
|
const { points, lastCommittedPoint } = element;
|
||||||
if (
|
if (
|
||||||
!lastCommittedPoint ||
|
!lastCommittedPoint ||
|
||||||
points[points.length - 1] !== lastCommittedPoint
|
points[points.length - 1] !== lastCommittedPoint
|
||||||
) {
|
) {
|
||||||
scene.mutateElement(multiPointElement, {
|
scene.mutateElement(element, {
|
||||||
points: multiPointElement.points.slice(0, -1),
|
points: element.points.slice(0, -1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInvisiblySmallElement(multiPointElement)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.filter(
|
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||||
(el) => el.id !== multiPointElement.id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the multi point line closes the loop,
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
// set the last point to first point.
|
// If the multi point line closes the loop,
|
||||||
// This ensures that loop remains closed at different scales.
|
// set the last point to first point.
|
||||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
// This ensures that loop remains closed at different scales.
|
||||||
if (
|
const isLoop = isPathALoop(element.points, appState.zoom.value);
|
||||||
multiPointElement.type === "line" ||
|
|
||||||
multiPointElement.type === "freedraw"
|
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
|
||||||
) {
|
const linePoints = element.points;
|
||||||
if (isLoop) {
|
|
||||||
const linePoints = multiPointElement.points;
|
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
scene.mutateElement(multiPointElement, {
|
const points: LocalPoint[] = linePoints.map((p, index) =>
|
||||||
points: linePoints.map((p, index) =>
|
index === linePoints.length - 1
|
||||||
index === linePoints.length - 1
|
? pointFrom(firstPoint[0], firstPoint[1])
|
||||||
? pointFrom(firstPoint[0], firstPoint[1])
|
: p,
|
||||||
: p,
|
);
|
||||||
),
|
if (isLineElement(element)) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
points,
|
||||||
|
polygon: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
polygon: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isBindingElement(multiPointElement) &&
|
isBindingElement(element) &&
|
||||||
!isLoop &&
|
!isLoop &&
|
||||||
multiPointElement.points.length > 1
|
element.points.length > 1 &&
|
||||||
) {
|
isBindingEnabled(appState)
|
||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
) {
|
||||||
multiPointElement,
|
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
-1,
|
element,
|
||||||
arrayToMap(elements),
|
-1,
|
||||||
);
|
arrayToMap(elements),
|
||||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
);
|
||||||
|
maybeBindLinearElement(element, appState, { x, y }, scene);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.activeTool.locked &&
|
(!appState.activeTool.locked &&
|
||||||
appState.activeTool.type !== "freedraw") ||
|
appState.activeTool.type !== "freedraw") ||
|
||||||
!multiPointElement
|
!element
|
||||||
) {
|
) {
|
||||||
resetCursor(interactiveCanvas);
|
resetCursor(interactiveCanvas);
|
||||||
}
|
}
|
||||||
@ -175,7 +256,7 @@ export const actionFinalize = register({
|
|||||||
activeTool:
|
activeTool:
|
||||||
(appState.activeTool.locked ||
|
(appState.activeTool.locked ||
|
||||||
appState.activeTool.type === "freedraw") &&
|
appState.activeTool.type === "freedraw") &&
|
||||||
multiPointElement
|
element
|
||||||
? appState.activeTool
|
? appState.activeTool
|
||||||
: activeTool,
|
: activeTool,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
@ -186,21 +267,18 @@ export const actionFinalize = register({
|
|||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
element &&
|
||||||
!appState.activeTool.locked &&
|
!appState.activeTool.locked &&
|
||||||
appState.activeTool.type !== "freedraw"
|
appState.activeTool.type !== "freedraw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[element.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
// To select the linear element when user has finished mutipoint editing
|
// To select the linear element when user has finished mutipoint editing
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
multiPointElement && isLinearElement(multiPointElement)
|
element && isLinearElement(element)
|
||||||
? new LinearElementEditor(
|
? new LinearElementEditor(element, arrayToMap(newElements))
|
||||||
multiPointElement,
|
|
||||||
arrayToMap(newElements),
|
|
||||||
)
|
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
|
@ -1,19 +1,29 @@
|
|||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
import {
|
||||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
isElbowArrow,
|
||||||
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
import type {
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { lineEditorIcon } from "../components/icons";
|
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { ButtonIcon } from "../components/ButtonIcon";
|
||||||
|
|
||||||
|
import { newElementWith } from "../../element/src/mutateElement";
|
||||||
|
|
||||||
|
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
export const actionToggleLinearEditor = register({
|
||||||
@ -83,3 +93,110 @@ export const actionToggleLinearEditor = register({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionTogglePolygon = register({
|
||||||
|
name: "togglePolygon",
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
icon: polygonIcon,
|
||||||
|
keywords: ["loop"],
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPolygons = !selectedElements.some(
|
||||||
|
(element) => !isLineElement(element) || !element.polygon,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allPolygons
|
||||||
|
? "labels.polygon.breakPolygon"
|
||||||
|
: "labels.polygon.convertToPolygon";
|
||||||
|
},
|
||||||
|
trackEvent: {
|
||||||
|
category: "element",
|
||||||
|
},
|
||||||
|
predicate: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
selectedElements.every(
|
||||||
|
(element) => isLineElement(element) && element.points.length >= 4,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform(elements, appState, _, app) {
|
||||||
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
|
if (selectedElements.some((element) => !isLineElement(element))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetElements = selectedElements as ExcalidrawLineElement[];
|
||||||
|
|
||||||
|
// if one element not a polygon, convert all to polygon
|
||||||
|
const nextPolygonState = targetElements.some((element) => !element.polygon);
|
||||||
|
|
||||||
|
const targetElementsMap = arrayToMap(targetElements);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: elements.map((element) => {
|
||||||
|
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(element, {
|
||||||
|
backgroundColor: nextPolygonState
|
||||||
|
? element.backgroundColor
|
||||||
|
: "transparent",
|
||||||
|
...toggleLinePolygonState(element, nextPolygonState),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
appState,
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData, app }) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedElements.length === 0 ||
|
||||||
|
selectedElements.some(
|
||||||
|
(element) =>
|
||||||
|
!isLineElement(element) ||
|
||||||
|
// only show polygon button if every selected element is already
|
||||||
|
// a polygon, effectively showing this button only to allow for
|
||||||
|
// disabling the polygon state
|
||||||
|
!element.polygon ||
|
||||||
|
element.points.length < 3,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPolygon = selectedElements.every(
|
||||||
|
(element) => isLineElement(element) && element.polygon,
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = t(
|
||||||
|
allPolygon
|
||||||
|
? "labels.polygon.breakPolygon"
|
||||||
|
: "labels.polygon.convertToPolygon",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonIcon
|
||||||
|
icon={polygonIcon}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
active={allPolygon}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
style={{ marginLeft: "auto" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -20,10 +20,11 @@ import {
|
|||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
|
isTransparent,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindLinearElement,
|
bindLinearElement,
|
||||||
@ -47,6 +48,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@ -136,6 +138,8 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
|
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
@ -349,22 +353,52 @@ export const actionChangeBackgroundColor = register({
|
|||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
label: "labels.changeBackground",
|
label: "labels.changeBackground",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
if (!value.currentItemBackgroundColor) {
|
||||||
...(value.currentItemBackgroundColor && {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
appState: {
|
||||||
newElementWith(el, {
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextElements;
|
||||||
|
|
||||||
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
const shouldEnablePolygon =
|
||||||
|
!isTransparent(value.currentItemBackgroundColor) &&
|
||||||
|
selectedElements.every(
|
||||||
|
(el) => isLineElement(el) && canBecomePolygon(el.points),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldEnablePolygon) {
|
||||||
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
|
nextElements = elements.map((el) => {
|
||||||
|
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
|
||||||
|
return newElementWith(el, {
|
||||||
backgroundColor: value.currentItemBackgroundColor,
|
backgroundColor: value.currentItemBackgroundColor,
|
||||||
}),
|
...toggleLinePolygonState(el, true),
|
||||||
),
|
});
|
||||||
}),
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nextElements = changeProperty(elements, appState, (el) =>
|
||||||
|
newElementWith(el, {
|
||||||
|
backgroundColor: value.currentItemBackgroundColor,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
captureUpdate: !!value.currentItemBackgroundColor
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
? CaptureUpdateAction.IMMEDIATELY
|
|
||||||
: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
@ -1373,7 +1407,7 @@ export const actionChangeRoundness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
@ -1417,6 +1451,7 @@ export const actionChangeRoundness = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
|
{renderAction("togglePolygon")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
@ -1483,13 +1518,13 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
value: "crowfoot_one",
|
value: "crowfoot_one",
|
||||||
text: t("labels.arrowhead_crowfoot_one"),
|
text: t("labels.arrowhead_crowfoot_one"),
|
||||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||||
keyBinding: "c",
|
keyBinding: "x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "crowfoot_many",
|
value: "crowfoot_many",
|
||||||
text: t("labels.arrowhead_crowfoot_many"),
|
text: t("labels.arrowhead_crowfoot_many"),
|
||||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||||
keyBinding: "x",
|
keyBinding: "c",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "crowfoot_one_or_many",
|
value: "crowfoot_one_or_many",
|
||||||
|
@ -179,6 +179,7 @@ export class ActionManager {
|
|||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
app={this.app}
|
app={this.app}
|
||||||
data={data}
|
data={data}
|
||||||
|
renderAction={this.renderAction}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -142,7 +142,8 @@ export type ActionName =
|
|||||||
| "cropEditor"
|
| "cropEditor"
|
||||||
| "wrapSelectionInFrame"
|
| "wrapSelectionInFrame"
|
||||||
| "toggleLassoTool"
|
| "toggleLassoTool"
|
||||||
| "toggleShapeSwitch";
|
| "toggleShapeSwitch"
|
||||||
|
| "togglePolygon";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -151,6 +152,10 @@ export type PanelComponentProps = {
|
|||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
data?: Record<string, any>;
|
data?: Record<string, any>;
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
|
renderAction: (
|
||||||
|
name: ActionName,
|
||||||
|
data?: PanelComponentProps["data"],
|
||||||
|
) => React.JSX.Element | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
|
@ -107,13 +107,11 @@ import {
|
|||||||
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
fixBindingsAfterDeletion,
|
fixBindingsAfterDeletion,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
isLinearElementSimpleAndAlreadyBound,
|
isLinearElementSimpleAndAlreadyBound,
|
||||||
maybeBindLinearElement,
|
|
||||||
shouldEnableBindingForPointerEvent,
|
shouldEnableBindingForPointerEvent,
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
getSuggestedBindingsForArrows,
|
getSuggestedBindingsForArrows,
|
||||||
@ -2797,7 +2795,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.updateEmbeddables();
|
this.updateEmbeddables();
|
||||||
const elements = this.scene.getElementsIncludingDeleted();
|
const elements = this.scene.getElementsIncludingDeleted();
|
||||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||||
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
if (!this.state.showWelcomeScreen && !elements.length) {
|
if (!this.state.showWelcomeScreen && !elements.length) {
|
||||||
this.setState({ showWelcomeScreen: true });
|
this.setState({ showWelcomeScreen: true });
|
||||||
@ -2944,27 +2941,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ selectedLinearElement: null });
|
this.setState({ selectedLinearElement: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { multiElement } = prevState;
|
|
||||||
if (
|
|
||||||
prevState.activeTool !== this.state.activeTool &&
|
|
||||||
multiElement != null &&
|
|
||||||
isBindingEnabled(this.state) &&
|
|
||||||
isBindingElement(multiElement, false)
|
|
||||||
) {
|
|
||||||
maybeBindLinearElement(
|
|
||||||
multiElement,
|
|
||||||
this.state,
|
|
||||||
tupleToCoors(
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
multiElement,
|
|
||||||
-1,
|
|
||||||
nonDeletedElementsMap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
this.scene,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store.commit(elementsMap, this.state);
|
this.store.commit(elementsMap, this.state);
|
||||||
|
|
||||||
// Do not notify consumers if we're still loading the scene. Among other
|
// Do not notify consumers if we're still loading the scene. Among other
|
||||||
@ -9143,34 +9119,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ selectedLinearElement: null });
|
this.setState({ selectedLinearElement: null });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
this.actionManager.executeAction(actionFinalize, "ui", {
|
||||||
childEvent,
|
event: childEvent,
|
||||||
this.state.selectedLinearElement,
|
});
|
||||||
this.state,
|
|
||||||
this.scene,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { startBindingElement, endBindingElement } =
|
|
||||||
linearElementEditor;
|
|
||||||
const element = this.scene.getElement(linearElementEditor.elementId);
|
|
||||||
if (isBindingElement(element)) {
|
|
||||||
bindOrUnbindLinearElement(
|
|
||||||
element,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
this.scene,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linearElementEditor !== this.state.selectedLinearElement) {
|
|
||||||
this.setState({
|
|
||||||
selectedLinearElement: {
|
|
||||||
...linearElementEditor,
|
|
||||||
selectedPointsIndices: null,
|
|
||||||
},
|
|
||||||
suggestedBindings: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9294,12 +9245,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
isBindingEnabled(this.state) &&
|
isBindingEnabled(this.state) &&
|
||||||
isBindingElement(newElement, false)
|
isBindingElement(newElement, false)
|
||||||
) {
|
) {
|
||||||
maybeBindLinearElement(
|
this.actionManager.executeAction(actionFinalize);
|
||||||
newElement,
|
|
||||||
this.state,
|
|
||||||
pointerCoords,
|
|
||||||
this.scene,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||||
if (!activeTool.locked) {
|
if (!activeTool.locked) {
|
||||||
|
@ -15,6 +15,7 @@ interface ButtonIconProps {
|
|||||||
/** include standalone style (could interfere with parent styles) */
|
/** include standalone style (could interfere with parent styles) */
|
||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||||
@ -30,6 +31,7 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
|||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
className={clsx(className, { standalone, active })}
|
className={clsx(className, { standalone, active })}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
style={props.style}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
|
@ -293,6 +293,7 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.decreaseFontSize,
|
actionManager.actions.decreaseFontSize,
|
||||||
actionManager.actions.toggleLinearEditor,
|
actionManager.actions.toggleLinearEditor,
|
||||||
actionManager.actions.cropEditor,
|
actionManager.actions.cropEditor,
|
||||||
|
actionManager.actions.togglePolygon,
|
||||||
actionLink,
|
actionLink,
|
||||||
actionCopyElementLink,
|
actionCopyElementLink,
|
||||||
actionLinkToElement,
|
actionLinkToElement,
|
||||||
|
@ -564,7 +564,7 @@ export const convertElementTypes = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fixedSegments: FixedSegment[] = [];
|
const fixedSegments: FixedSegment[] = [];
|
||||||
for (let i = 0; i < nextPoints.length - 1; i++) {
|
for (let i = 1; i < nextPoints.length - 2; i++) {
|
||||||
fixedSegments.push({
|
fixedSegments.push({
|
||||||
start: nextPoints[i],
|
start: nextPoints[i],
|
||||||
end: nextPoints[i + 1],
|
end: nextPoints[i + 1],
|
||||||
@ -581,6 +581,7 @@ export const convertElementTypes = (
|
|||||||
);
|
);
|
||||||
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
||||||
...updates,
|
...updates,
|
||||||
|
endArrowhead: "arrow",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// if we're converting to non-elbow linear element, check if
|
// if we're converting to non-elbow linear element, check if
|
||||||
|
@ -129,6 +129,21 @@ export const PinIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const polygonIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M19 8m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M5 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M15 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M6.5 9.5l3.5 -3" />
|
||||||
|
<path d="M14 5.5l3 1.5" />
|
||||||
|
<path d="M18.5 10l-2.5 7" />
|
||||||
|
<path d="M13.5 17.5l-7 -5" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
// tabler-icons: lock-open (via Figma)
|
// tabler-icons: lock-open (via Figma)
|
||||||
export const UnlockedIcon = createIcon(
|
export const UnlockedIcon = createIcon(
|
||||||
<g>
|
<g>
|
||||||
|
@ -948,6 +948,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
@ -995,6 +996,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
normalizeLink,
|
normalizeLink,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
|
||||||
import { normalizeFixedPoint } from "@excalidraw/element";
|
import { normalizeFixedPoint } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
updateElbowArrowPoints,
|
updateElbowArrowPoints,
|
||||||
@ -34,6 +34,7 @@ import {
|
|||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@ -323,7 +324,8 @@ const restoreElement = (
|
|||||||
: element.points;
|
: element.points;
|
||||||
|
|
||||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
({ points, x, y } =
|
||||||
|
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElementWithProperties(element, {
|
return restoreElementWithProperties(element, {
|
||||||
@ -339,6 +341,13 @@ const restoreElement = (
|
|||||||
points,
|
points,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
...(isLineElement(element)
|
||||||
|
? {
|
||||||
|
polygon: isValidPolygon(element.points)
|
||||||
|
? element.polygon ?? false
|
||||||
|
: false,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
});
|
});
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
@ -351,7 +360,8 @@ const restoreElement = (
|
|||||||
: element.points;
|
: element.points;
|
||||||
|
|
||||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
({ points, x, y } =
|
||||||
|
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
|
@ -466,7 +466,7 @@ const bindLinearElementToElement = (
|
|||||||
|
|
||||||
Object.assign(
|
Object.assign(
|
||||||
linearElement,
|
linearElement,
|
||||||
LinearElementEditor.getNormalizedPoints({
|
LinearElementEditor.getNormalizeElementPointsAndCoords({
|
||||||
...linearElement,
|
...linearElement,
|
||||||
points: newPoints,
|
points: newPoints,
|
||||||
}),
|
}),
|
||||||
|
@ -141,6 +141,10 @@
|
|||||||
"edit": "Edit line",
|
"edit": "Edit line",
|
||||||
"editArrow": "Edit arrow"
|
"editArrow": "Edit arrow"
|
||||||
},
|
},
|
||||||
|
"polygon": {
|
||||||
|
"breakPolygon": "Break polygon",
|
||||||
|
"convertToPolygon": "Convert to polygon"
|
||||||
|
},
|
||||||
"elementLock": {
|
"elementLock": {
|
||||||
"lock": "Lock",
|
"lock": "Lock",
|
||||||
"unlock": "Unlock",
|
"unlock": "Unlock",
|
||||||
|
@ -31,11 +31,14 @@ export const fillCircle = (
|
|||||||
cx: number,
|
cx: number,
|
||||||
cy: number,
|
cy: number,
|
||||||
radius: number,
|
radius: number,
|
||||||
stroke = true,
|
stroke: boolean,
|
||||||
|
fill = true,
|
||||||
) => {
|
) => {
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
context.fill();
|
if (fill) {
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
if (stroke) {
|
if (stroke) {
|
||||||
context.stroke();
|
context.stroke();
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
pointFrom,
|
pointFrom,
|
||||||
|
pointsEqual,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
type Radians,
|
type Radians,
|
||||||
@ -28,6 +29,7 @@ import {
|
|||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
@ -161,7 +163,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
point: Point,
|
point: Point,
|
||||||
radius: number,
|
radius: number,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isPhantomPoint = false,
|
isPhantomPoint: boolean,
|
||||||
|
isOverlappingPoint: boolean,
|
||||||
) => {
|
) => {
|
||||||
context.strokeStyle = "#5e5ad8";
|
context.strokeStyle = "#5e5ad8";
|
||||||
context.setLineDash([]);
|
context.setLineDash([]);
|
||||||
@ -176,8 +179,11 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
context,
|
context,
|
||||||
point[0],
|
point[0],
|
||||||
point[1],
|
point[1],
|
||||||
radius / appState.zoom.value,
|
(isOverlappingPoint
|
||||||
|
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||||
|
: radius) / appState.zoom.value,
|
||||||
!isPhantomPoint,
|
!isPhantomPoint,
|
||||||
|
!isOverlappingPoint || isSelected,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,7 +259,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
|
|||||||
index,
|
index,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
fillCircle(context, x, y, threshold);
|
fillCircle(context, x, y, threshold, true);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -442,15 +448,39 @@ const renderLinearPointHandles = (
|
|||||||
const radius = appState.editingLinearElement
|
const radius = appState.editingLinearElement
|
||||||
? POINT_HANDLE_SIZE
|
? POINT_HANDLE_SIZE
|
||||||
: POINT_HANDLE_SIZE / 2;
|
: POINT_HANDLE_SIZE / 2;
|
||||||
|
|
||||||
|
const _isElbowArrow = isElbowArrow(element);
|
||||||
|
const _isLineElement = isLineElement(element);
|
||||||
|
|
||||||
points.forEach((point, idx) => {
|
points.forEach((point, idx) => {
|
||||||
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
|
if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOverlappingPoint =
|
||||||
|
idx > 0 &&
|
||||||
|
(idx !== points.length - 1 ||
|
||||||
|
appState.editingLinearElement ||
|
||||||
|
!_isLineElement ||
|
||||||
|
!element.polygon) &&
|
||||||
|
pointsEqual(
|
||||||
|
point,
|
||||||
|
idx === points.length - 1 ? points[0] : points[idx - 1],
|
||||||
|
2 / appState.zoom.value,
|
||||||
|
);
|
||||||
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||||
|
|
||||||
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
renderSingleLinearPoint(
|
||||||
|
context,
|
||||||
|
appState,
|
||||||
|
point,
|
||||||
|
radius,
|
||||||
|
isSelected,
|
||||||
|
false,
|
||||||
|
isOverlappingPoint,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rendering segment mid points
|
// Rendering segment mid points
|
||||||
@ -477,6 +507,7 @@ const renderLinearPointHandles = (
|
|||||||
POINT_HANDLE_SIZE / 2,
|
POINT_HANDLE_SIZE / 2,
|
||||||
false,
|
false,
|
||||||
!fixedSegments.includes(idx + 1),
|
!fixedSegments.includes(idx + 1),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -500,6 +531,7 @@ const renderLinearPointHandles = (
|
|||||||
POINT_HANDLE_SIZE / 2,
|
POINT_HANDLE_SIZE / 2,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -526,7 +558,7 @@ const renderTransformHandles = (
|
|||||||
context.strokeStyle = renderConfig.selectionColor;
|
context.strokeStyle = renderConfig.selectionColor;
|
||||||
}
|
}
|
||||||
if (key === "rotation") {
|
if (key === "rotation") {
|
||||||
fillCircle(context, x + width / 2, y + height / 2, width / 2);
|
fillCircle(context, x + width / 2, y + height / 2, width / 2, true);
|
||||||
// prefer round corners if roundRect API is available
|
// prefer round corners if roundRect API is available
|
||||||
} else if (context.roundRect) {
|
} else if (context.roundRect) {
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
|
@ -153,6 +153,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||||||
50,
|
50,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
|
@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
|||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 7,
|
||||||
"versionNonce": 745419401,
|
"versionNonce": 1051383431,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"x": 201,
|
"x": 201,
|
||||||
"y": 2,
|
"y": 2,
|
||||||
@ -231,7 +231,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
|||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 11,
|
"version": 11,
|
||||||
"versionNonce": 1051383431,
|
"versionNonce": 1996028265,
|
||||||
"width": "86.85786",
|
"width": "86.85786",
|
||||||
"x": "107.07107",
|
"x": "107.07107",
|
||||||
"y": "47.07107",
|
"y": "47.07107",
|
||||||
|
@ -93,6 +93,7 @@ exports[`multi point mode in linear elements > line 3`] = `
|
|||||||
110,
|
110,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
|
@ -6492,6 +6492,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
10,
|
10,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
@ -6716,6 +6717,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
10,
|
10,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
@ -8954,6 +8956,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
|||||||
10,
|
10,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
@ -9773,6 +9776,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
|||||||
10,
|
10,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
|
@ -79,6 +79,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
|||||||
50,
|
50,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
|
@ -240,6 +240,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
|||||||
100,
|
100,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
@ -289,6 +290,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
|||||||
100,
|
100,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
|
@ -6,7 +6,10 @@ import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
|
|||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import * as sizeHelpers from "@excalidraw/element";
|
import * as sizeHelpers from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@ -163,6 +166,109 @@ describe("restoreElements", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should remove imperceptibly small elements", () => {
|
||||||
|
const arrowElement = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0.02, 0.05],
|
||||||
|
] as LocalPoint[],
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||||
|
|
||||||
|
const restoredArrow = restoredElements[0] as
|
||||||
|
| ExcalidrawArrowElement
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(restoredArrow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||||
|
const freedrawElement = API.createElement({
|
||||||
|
type: "freedraw",
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0.0001, 0.0001],
|
||||||
|
] as LocalPoint[],
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const lineElement = API.createElement({
|
||||||
|
type: "line",
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0.0001, 0.0001],
|
||||||
|
] as LocalPoint[],
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElements = restore.restoreElements(
|
||||||
|
[freedrawElement, lineElement],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restoredElements).toEqual([
|
||||||
|
expect.objectContaining({ id: freedrawElement.id }),
|
||||||
|
expect.objectContaining({ id: lineElement.id }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should restore loop linears correctly", () => {
|
||||||
|
const linearElement = API.createElement({
|
||||||
|
type: "line",
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[100, 100],
|
||||||
|
[100, 200],
|
||||||
|
[0, 0],
|
||||||
|
] as LocalPoint[],
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const arrowElement = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[100, 100],
|
||||||
|
[100, 200],
|
||||||
|
[0, 0],
|
||||||
|
] as LocalPoint[],
|
||||||
|
x: 500,
|
||||||
|
y: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElements = restore.restoreElements(
|
||||||
|
[linearElement, arrowElement],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const restoredLinear = restoredElements[0] as
|
||||||
|
| ExcalidrawLinearElement
|
||||||
|
| undefined;
|
||||||
|
const restoredArrow = restoredElements[1] as
|
||||||
|
| ExcalidrawArrowElement
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(restoredLinear?.type).toBe("line");
|
||||||
|
expect(restoredLinear?.points).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[100, 100],
|
||||||
|
[100, 200],
|
||||||
|
[0, 0],
|
||||||
|
] as LocalPoint[]);
|
||||||
|
expect(restoredArrow?.type).toBe("arrow");
|
||||||
|
expect(restoredArrow?.points).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[100, 100],
|
||||||
|
[100, 200],
|
||||||
|
[0, 0],
|
||||||
|
] as LocalPoint[]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||||
const arrowElement = API.createElement({ type: "arrow" });
|
const arrowElement = API.createElement({ type: "arrow" });
|
||||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||||
|
@ -426,7 +426,7 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
@ -470,7 +470,7 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
|
@ -91,9 +91,10 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
|
|||||||
export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
|
export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
|
||||||
a: Point,
|
a: Point,
|
||||||
b: Point,
|
b: Point,
|
||||||
|
tolerance: number = PRECISION,
|
||||||
): boolean {
|
): boolean {
|
||||||
const abs = Math.abs;
|
const abs = Math.abs;
|
||||||
return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
|
return abs(a[0] - b[0]) < tolerance && abs(a[1] - b[1]) < tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
x
Reference in New Issue
Block a user