Compare commits
30 Commits
master
...
mtolmacs/f
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6919373c63 | ||
![]() |
71e13c363e | ||
![]() |
83abf2cd94 | ||
![]() |
02b4fa1ca7 | ||
![]() |
3363e0e289 | ||
![]() |
076b1e0e31 | ||
![]() |
6e06fe9fda | ||
![]() |
6ab3b6c029 | ||
![]() |
37ca66044e | ||
![]() |
cc82cc9671 | ||
![]() |
d214404244 | ||
![]() |
0c76d6b681 | ||
![]() |
b63e285f93 | ||
![]() |
94ed8313f4 | ||
![]() |
0050a856e5 | ||
![]() |
bb4d9649a6 | ||
![]() |
14817c1b2d | ||
![]() |
d681869c4c | ||
![]() |
ec255cbe53 | ||
![]() |
d5af9421f0 | ||
![]() |
795a5c16c8 | ||
![]() |
8469c6670a | ||
![]() |
6c93d6e997 | ||
![]() |
db3e5c63ef | ||
![]() |
508d4c3681 | ||
![]() |
2082ef149c | ||
![]() |
3b1c6444e2 | ||
![]() |
cd3ca3b4ca | ||
![]() |
14a0cd3a97 | ||
![]() |
0c5d3850d0 |
@ -1,5 +1,5 @@
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||
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_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
@ -19,7 +19,7 @@ services:
|
||||
- ./:/opt/node_app/app:delegated
|
||||
- ./package.json:/opt/node_app/package.json
|
||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||
# - notused:/opt/node_app/app/node_modules
|
||||
- notused:/opt/node_app/app/node_modules
|
||||
|
||||
# volumes:
|
||||
# notused:
|
||||
volumes:
|
||||
notused:
|
||||
|
@ -926,22 +926,17 @@ const ExcalidrawWrapper = () => {
|
||||
<ShareDialog
|
||||
collabAPI={collabAPI}
|
||||
onExportToBackend={async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
await onExportToBackend(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
setLatestShareableLink(url);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -41,8 +41,8 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "vite build",
|
||||
"build:app": "vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
|
@ -1,95 +0,0 @@
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { _generateElementShape } from "./Shape";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
||||
|
||||
import type { Drawable } from "roughjs/bin/core";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
@ -27,8 +27,6 @@ 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";
|
||||
@ -39,9 +37,10 @@ import {
|
||||
getCenterForBounds,
|
||||
getElementBounds,
|
||||
doBoundsIntersect,
|
||||
aabbForElement,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
headingForPointFromElement,
|
||||
headingIsHorizontal,
|
||||
@ -63,7 +62,6 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
||||
|
||||
export const FIXED_BINDING_DISTANCE = 5;
|
||||
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
||||
export const BINDING_HIGHLIGHT_OFFSET = 4;
|
||||
|
||||
const getNonDeletedElements = (
|
||||
scene: Scene,
|
||||
@ -441,22 +438,13 @@ export const maybeBindLinearElement = (
|
||||
const normalizePointBinding = (
|
||||
binding: { focus: number; gap: number },
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
) => {
|
||||
let gap = binding.gap;
|
||||
const maxGap = maxBindingGap(
|
||||
hoveredElement,
|
||||
hoveredElement.width,
|
||||
hoveredElement.height,
|
||||
);
|
||||
|
||||
if (gap > maxGap) {
|
||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
||||
}
|
||||
return {
|
||||
) => ({
|
||||
...binding,
|
||||
gap,
|
||||
};
|
||||
};
|
||||
gap: Math.min(
|
||||
binding.gap,
|
||||
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
|
||||
),
|
||||
});
|
||||
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
@ -704,7 +692,7 @@ const calculateFocusAndGap = (
|
||||
|
||||
return {
|
||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||
gap: Math.max(1, distanceToElement(hoveredElement, edgePoint)),
|
||||
};
|
||||
};
|
||||
|
||||
@ -898,7 +886,7 @@ const getDistanceForBinding = (
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
const distance = distanceToBindableElement(bindableElement, point);
|
||||
const distance = distanceToElement(bindableElement, point);
|
||||
const bindDistance = maxBindingGap(
|
||||
bindableElement,
|
||||
bindableElement.width,
|
||||
@ -961,6 +949,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
otherPoint,
|
||||
),
|
||||
),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
)[0];
|
||||
} else {
|
||||
intersection = intersectElementWithLineSegment(
|
||||
@ -991,24 +980,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
return edgePoint;
|
||||
}
|
||||
|
||||
if (elbowed) {
|
||||
const scalar =
|
||||
pointDistanceSq(edgePoint, center) -
|
||||
pointDistanceSq(intersection, center) >
|
||||
0
|
||||
? FIXED_BINDING_DISTANCE
|
||||
: -FIXED_BINDING_DISTANCE;
|
||||
|
||||
return pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
|
||||
scalar,
|
||||
),
|
||||
intersection,
|
||||
);
|
||||
}
|
||||
|
||||
return edgePoint;
|
||||
return elbowed ? intersection : edgePoint;
|
||||
};
|
||||
|
||||
export const avoidRectangularCorner = (
|
||||
@ -1119,55 +1091,83 @@ export const snapToMid = (
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
const verticalThrehsold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
|
||||
const verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads(
|
||||
const otherPoint = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
return (
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
lineSegment(center, otherPoint),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
)[0] ?? otherPoint
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
const otherPoint = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
return (
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
lineSegment(center, otherPoint),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
)[0] ?? otherPoint
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
const otherPoint = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
return (
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
lineSegment(center, otherPoint),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
)[0] ?? otherPoint
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
const otherPoint = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
return (
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
lineSegment(center, otherPoint),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
)[0] ?? otherPoint
|
||||
);
|
||||
} else if (element.type === "diamond") {
|
||||
const distance = FIXED_BINDING_DISTANCE - 1;
|
||||
const distance = FIXED_BINDING_DISTANCE;
|
||||
const topLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + height / 4 - distance,
|
||||
@ -1184,27 +1184,28 @@ export const snapToMid = (
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(topLeft, nonRotated) <
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(topRight, nonRotated) <
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topRight, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomLeft, nonRotated) <
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomRight, nonRotated) <
|
||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomRight, center, angle);
|
||||
}
|
||||
@ -1548,14 +1549,22 @@ 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 shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
||||
!isFrameLikeElement(element);
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
lineSegment(elementCenterPoint(element), p),
|
||||
);
|
||||
const distance = distanceToElement(element, p);
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= threshold
|
||||
: intersections.length > 0 && distance <= threshold;
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
@ -1575,7 +1584,7 @@ export const maxBindingGap = (
|
||||
// bigger bindable boundary for bigger elements
|
||||
Math.min(0.25 * smallerDimension, 32),
|
||||
// keep in sync with the zoomed highlight
|
||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -2,6 +2,7 @@ import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
elementCenterPoint,
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
@ -33,8 +34,7 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions, getElementShape, ShapeCache } from "./shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@ -45,8 +45,6 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shapes";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
@ -1146,3 +1144,67 @@ export const doBoundsIntersect = (
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
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];
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
@ -1,52 +1,67 @@
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import {
|
||||
isTransparent,
|
||||
elementCenterPoint,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isCurve,
|
||||
isLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseLineIntersectionPoints,
|
||||
ellipseSegmentInterceptPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Polygon,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
isPathALoop,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
@ -72,45 +87,49 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
export type HitTestArgs = {
|
||||
point: GlobalPoint;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
element,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
}: HitTestArgs) => {
|
||||
// First check if the element is in the bounding box because it's MUCH faster
|
||||
// than checking if the point is in the element's shape
|
||||
let hit = hitElementBoundingBox(
|
||||
point,
|
||||
element,
|
||||
arrayToMap([element]),
|
||||
threshold,
|
||||
)
|
||||
? shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
isPointInShape(point, element) ||
|
||||
isPointOnShape(point, element, threshold)
|
||||
: isPointOnShape(point, element, threshold)
|
||||
: false;
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
const x1 = frameNameBound.x - threshold;
|
||||
const y1 = frameNameBound.y - threshold;
|
||||
const x2 = frameNameBound.x + frameNameBound.width + threshold;
|
||||
const y2 = frameNameBound.y + frameNameBound.height + threshold;
|
||||
hit = isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
}
|
||||
|
||||
return hit;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
x: number,
|
||||
y: number,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
@ -120,37 +139,45 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
hitArgs: HitTestArgs,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(
|
||||
hitArgs.x,
|
||||
hitArgs.y,
|
||||
getBoundTextShape(hitArgs.element, elementsMap),
|
||||
) &&
|
||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap)
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
export const hitElementBoundText = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
|
||||
return isPointInShape(point, boundTextElement);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -173,17 +200,51 @@ export const intersectElementWithLineSegment = (
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "selection":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line);
|
||||
}
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
): GlobalPoint[] => {
|
||||
const shapes = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const shape of shapes) {
|
||||
switch (true) {
|
||||
case isCurve(shape):
|
||||
intersections.push(
|
||||
...curveIntersectLineSegment(shape as Curve<GlobalPoint>, segment),
|
||||
);
|
||||
continue;
|
||||
case isLineSegment(shape):
|
||||
const point = lineSegmentIntersectionPoints(
|
||||
segment,
|
||||
shape as LineSegment<GlobalPoint>,
|
||||
);
|
||||
|
||||
if (point) {
|
||||
intersections.push(point);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
@ -301,8 +362,46 @@ const intersectEllipseWithLineSegment = (
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseLineIntersectionPoints(
|
||||
return ellipseSegmentInterceptPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
line(rotatedA, rotatedB),
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
||||
|
||||
// check if the given point is considered on the given shape's border
|
||||
const isPointOnShape = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
tolerance = 1,
|
||||
) => distanceToElement(element, point) <= tolerance;
|
||||
|
||||
// check if the given point is considered inside the element's border
|
||||
export const isPointInShape = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
) => {
|
||||
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, new Map());
|
||||
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,
|
||||
intersector,
|
||||
).filter((item, pos, arr) => arr.indexOf(item) === pos);
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
@ -187,12 +187,10 @@ export class Delta<T> {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDeletedObject =
|
||||
deleted[property] !== null && typeof deleted[property] === "object";
|
||||
const isInsertedObject =
|
||||
inserted[property] !== null && typeof inserted[property] === "object";
|
||||
|
||||
if (isDeletedObject || isInsertedObject) {
|
||||
if (
|
||||
typeof deleted[property] === "object" ||
|
||||
typeof inserted[property] === "object"
|
||||
) {
|
||||
type RecordLike = Record<string, V | undefined>;
|
||||
|
||||
const deletedObject: RecordLike = deleted[property] ?? {};
|
||||
@ -224,9 +222,6 @@ export class Delta<T> {
|
||||
Reflect.deleteProperty(deleted, property);
|
||||
Reflect.deleteProperty(inserted, property);
|
||||
}
|
||||
} else if (deleted[property] === inserted[property]) {
|
||||
Reflect.deleteProperty(deleted, property);
|
||||
Reflect.deleteProperty(inserted, property);
|
||||
}
|
||||
}
|
||||
|
||||
@ -663,24 +658,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "lockedMultiSelections": {
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
key,
|
||||
@ -776,8 +753,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
...standaloneProps
|
||||
} = delta as ObservedAppState;
|
||||
|
||||
@ -822,18 +797,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"selectedGroupIds",
|
||||
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} 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 appstate change deltas.`);
|
||||
|
@ -1,6 +1,8 @@
|
||||
import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
isCurve,
|
||||
isLineSegment,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
@ -8,25 +10,34 @@ import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
export const distanceToElement = (
|
||||
element: ExcalidrawElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
@ -39,6 +50,10 @@ export const distanceToBindableElement = (
|
||||
return distanceToDiamondElement(element, p);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
@ -117,3 +132,36 @@ const distanceToEllipseElement = (
|
||||
ellipse(center, element.width / 2, element.height / 2),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceToLinearOrFreeDraElement = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const shapes = deconstructLinearOrFreeDrawElement(element);
|
||||
let distance = Infinity;
|
||||
|
||||
for (const shape of shapes) {
|
||||
switch (true) {
|
||||
case isCurve(shape): {
|
||||
const d = curvePointDistance(shape as Curve<GlobalPoint>, p);
|
||||
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
case isLineSegment(shape): {
|
||||
const d = distanceToLineSegment(p, shape as LineSegment<GlobalPoint>);
|
||||
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
};
|
||||
|
@ -32,7 +32,7 @@ import {
|
||||
snapToMid,
|
||||
getHoveredElementForBinding,
|
||||
} from "./binding";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
flipHeading,
|
||||
@ -52,9 +52,8 @@ import {
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
import { aabbForElement, pointInsideBounds, type Bounds } from "./bounds";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
Arrowhead,
|
||||
@ -2234,8 +2233,7 @@ const getGlobalPoint = (
|
||||
|
||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
||||
return Math.abs(
|
||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||
FIXED_BINDING_DISTANCE,
|
||||
distanceToElement(element, fixedGlobalPoint) - FIXED_BINDING_DISTANCE,
|
||||
) > 0.01
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||
: fixedGlobalPoint;
|
||||
@ -2257,7 +2255,7 @@ const getBindPointHeading = (
|
||||
hoveredElement &&
|
||||
aabbForElement(
|
||||
hoveredElement,
|
||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
||||
Array(4).fill(distanceToElement(hoveredElement, p)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
@ -21,7 +21,6 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
@ -38,6 +37,7 @@ import {
|
||||
type Ordered,
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { aabbForElement } from "./bounds";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
|
@ -102,9 +102,7 @@ export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./shape";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
|
@ -7,6 +7,10 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
isCurve,
|
||||
isLineSegment,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@ -20,9 +24,14 @@ import {
|
||||
tupleToCoors,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Store } from "@excalidraw/element";
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
isPathALoop,
|
||||
ShapeCache,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
import type { Curve, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@ -55,16 +64,6 @@ import {
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import {
|
||||
isPathALoop,
|
||||
getBezierCurveLength,
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
} from "./shapes";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
@ -567,10 +566,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
@ -672,7 +668,14 @@ export class LinearElementEditor {
|
||||
|
||||
let distance = pointDistance(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
const segments = deconstructLinearOrFreeDrawElement(element);
|
||||
|
||||
invariant(
|
||||
segments.length >= index,
|
||||
"Invalid segment index while calculating segment length",
|
||||
);
|
||||
|
||||
distance = curveLength(segments[index] as Curve<GlobalPoint>);
|
||||
}
|
||||
|
||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||
@ -680,39 +683,39 @@ export class LinearElementEditor {
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: GlobalPoint,
|
||||
endPoint: GlobalPoint,
|
||||
endPointIndex: number,
|
||||
elementsMap: ElementsMap,
|
||||
index: number,
|
||||
): GlobalPoint {
|
||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const controlPoints = getControlPointsForBezierCurve(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
);
|
||||
if (controlPoints) {
|
||||
const t = mapIntervalToBezierT(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
0.5,
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
element.points.length >= index,
|
||||
"Invalid segment index while calculating elbow arrow mid point",
|
||||
);
|
||||
|
||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
}
|
||||
|
||||
return segmentMidPoint;
|
||||
const segments = deconstructLinearOrFreeDrawElement(element);
|
||||
|
||||
invariant(
|
||||
segments.length >= index,
|
||||
"Invalid segment index while calculating mid point",
|
||||
);
|
||||
|
||||
const shape = segments[index - 1];
|
||||
|
||||
switch (true) {
|
||||
case isCurve(shape):
|
||||
return curvePointAtLength(shape as Curve<GlobalPoint>, 0.5);
|
||||
case isLineSegment(shape):
|
||||
return pointCenter(shape[0] as GlobalPoint, shape[1] as GlobalPoint);
|
||||
}
|
||||
|
||||
invariant(
|
||||
false,
|
||||
`Invalid segment type while calculating mid point ${shape}`,
|
||||
);
|
||||
}
|
||||
|
||||
static getSegmentMidPointIndex(
|
||||
@ -1592,10 +1595,7 @@ export class LinearElementEditor {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
|
@ -8,11 +8,10 @@ import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import { isElbowArrow } from "./typeChecks";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
|
@ -54,9 +54,9 @@ import {
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { getCornerRadius } from "./utils";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
|
@ -1,12 +1,43 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
isTransparent,
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
isElbowArrow,
|
||||
@ -15,12 +46,19 @@ import {
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
||||
import { elementWithCanvasCache, generateFreeDrawShape } from "./renderElement";
|
||||
import {
|
||||
getArrowheadPoints,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
|
||||
import { shouldTestInside } from "./collision";
|
||||
import { getCornerRadius, isPathALoop } from "./utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -28,10 +66,11 @@ import type {
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
} 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";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
@ -303,6 +342,125 @@ 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,
|
||||
};
|
||||
|
||||
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)];
|
||||
const [x1, y1, x2, y2] = getElementBounds(
|
||||
{
|
||||
...element,
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
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, arr) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the roughjs shape for given element.
|
||||
*
|
||||
@ -611,3 +769,134 @@ 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 class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
@ -1,398 +0,0 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
getCurvePathOps,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
type GeometricShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { shouldTestInside } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* get the pure geometric shape of an excalidraw elementw
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "iframe":
|
||||
case "text":
|
||||
case "selection":
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
);
|
||||
}
|
||||
|
||||
case "ellipse":
|
||||
return getEllipseShape(element);
|
||||
|
||||
case "freedraw": {
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> | null => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
if (element.type === "arrow") {
|
||||
return getElementShape(
|
||||
{
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return getElementShape(boundTextElement, elementsMap);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getControlPointsForBezierCurve = <
|
||||
P extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const shape = ShapeCache.generateElementShape(element, null);
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
let currentP = pointFrom<P>(0, 0);
|
||||
let index = 0;
|
||||
let minDistance = Infinity;
|
||||
let controlPoints: P[] | null = null;
|
||||
|
||||
while (index < ops.length) {
|
||||
const { op, data } = ops[index];
|
||||
if (op === "move") {
|
||||
invariant(
|
||||
isPoint(data),
|
||||
"The returned ops is not compatible with a point",
|
||||
);
|
||||
currentP = pointFromPair(data);
|
||||
}
|
||||
if (op === "bcurveTo") {
|
||||
const p0 = currentP;
|
||||
const p1 = pointFrom<P>(data[0], data[1]);
|
||||
const p2 = pointFrom<P>(data[2], data[3]);
|
||||
const p3 = pointFrom<P>(data[4], data[5]);
|
||||
const distance = pointDistance(p3, endPoint);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
controlPoints = [p0, p1, p2, p3];
|
||||
}
|
||||
currentP = p3;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return controlPoints;
|
||||
};
|
||||
|
||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
||||
p0: P,
|
||||
p1: P,
|
||||
p2: P,
|
||||
p3: P,
|
||||
t: number,
|
||||
): P => {
|
||||
const equation = (t: number, idx: number) =>
|
||||
Math.pow(1 - t, 3) * p3[idx] +
|
||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
const tx = equation(t, 0);
|
||||
const ty = equation(t, 1);
|
||||
return pointFrom(tx, ty);
|
||||
};
|
||||
|
||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
||||
if (!controlPoints) {
|
||||
return [];
|
||||
}
|
||||
const pointsOnCurve: P[] = [];
|
||||
let t = 1;
|
||||
// Take 20 points on curve for better accuracy
|
||||
while (t > 0) {
|
||||
const p = getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
);
|
||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
||||
t -= 0.05;
|
||||
}
|
||||
if (pointsOnCurve.length) {
|
||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
||||
}
|
||||
}
|
||||
return pointsOnCurve;
|
||||
};
|
||||
|
||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const arcLengths: number[] = [];
|
||||
arcLengths[0] = 0;
|
||||
const points = getPointsInBezierCurve(element, endPoint);
|
||||
let index = 0;
|
||||
let distance = 0;
|
||||
while (index < points.length - 1) {
|
||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
||||
distance += segmentDistance;
|
||||
arcLengths.push(distance);
|
||||
index++;
|
||||
}
|
||||
|
||||
return arcLengths;
|
||||
};
|
||||
|
||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||
return arcLengths.at(-1) as number;
|
||||
};
|
||||
|
||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
||||
) => {
|
||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||
const pointsCount = arcLengths.length - 1;
|
||||
const curveLength = arcLengths.at(-1) as number;
|
||||
const targetLength = interval * curveLength;
|
||||
let low = 0;
|
||||
let high = pointsCount;
|
||||
let index = 0;
|
||||
// Doing a binary search to find the largest length that is less than the target length
|
||||
while (low < high) {
|
||||
index = Math.floor(low + (high - low) / 2);
|
||||
if (arcLengths[index] < targetLength) {
|
||||
low = index + 1;
|
||||
} else {
|
||||
high = index;
|
||||
}
|
||||
}
|
||||
if (arcLengths[index] > targetLength) {
|
||||
index--;
|
||||
}
|
||||
if (arcLengths[index] === targetLength) {
|
||||
return index / pointsCount;
|
||||
}
|
||||
|
||||
return (
|
||||
1 -
|
||||
(index +
|
||||
(targetLength - arcLengths[index]) /
|
||||
(arcLengths[index + 1] - arcLengths[index])) /
|
||||
pointsCount
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box for a given element
|
||||
*/
|
||||
export const aabbForElement = (
|
||||
element: Readonly<ExcalidrawElement>,
|
||||
offset?: [number, number, number, number],
|
||||
) => {
|
||||
const bbox = {
|
||||
minX: element.x,
|
||||
minY: element.y,
|
||||
maxX: element.x + element.width,
|
||||
maxY: element.y + element.height,
|
||||
midX: element.x + element.width / 2,
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const bounds = [
|
||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
] as Bounds;
|
||||
|
||||
if (offset) {
|
||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||
return [
|
||||
bounds[0] - leftOffset,
|
||||
bounds[1] - topOffset,
|
||||
bounds[2] + rightOffset,
|
||||
bounds[3] + downOffset,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||
element.roundness?.type === ROUNDNESS.LEGACY
|
||||
) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||
|
||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||
|
||||
if (x <= CUTOFF_SIZE) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
return fixedRadiusSize;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
/** supply if you want the loop detection to account for current zoom */
|
||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [first, last] = [points[0], points[points.length - 1]];
|
||||
const distance = pointDistance(first, last);
|
||||
|
||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||
// really close we make the threshold smaller, and vice versa.
|
||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||
}
|
||||
return false;
|
||||
};
|
@ -939,8 +939,6 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
};
|
||||
|
||||
@ -954,8 +952,6 @@ export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
@ -326,10 +326,7 @@ export const getContainerCenter = (
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawSelectionElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
|
@ -1,28 +1,106 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointFromArray,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
): (Curve<GlobalPoint> | LineSegment<GlobalPoint>)[] {
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const components = [];
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
components.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");
|
||||
}
|
||||
|
||||
components.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves.
|
||||
@ -35,175 +113,123 @@ export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const roundness = getCornerRadius(
|
||||
let radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (roundness <= 0) {
|
||||
const r = rectangle(
|
||||
pointFrom(element.x - offset, element.y - offset),
|
||||
pointFrom(
|
||||
element.x + element.width + offset,
|
||||
element.y + element.height + offset,
|
||||
),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
const sides = [top, right, bottom, left];
|
||||
|
||||
return [sides, []];
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
}
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const r = rectangle(
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width, element.y + element.height),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||
);
|
||||
|
||||
const offsets = [
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), // TOP LEFT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), //TOP RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners = [
|
||||
const baseCorners = [
|
||||
curve(
|
||||
pointFromVector(offsets[0], left[1]),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
left[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], top[0]),
|
||||
top[0],
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
pointFromVector(offsets[1], top[1]),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
top[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], right[0]),
|
||||
right[0],
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[2], right[1]),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
right[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], bottom[1]),
|
||||
bottom[1],
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[3], bottom[0]),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
bottom[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], left[0]),
|
||||
left[0],
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
];
|
||||
|
||||
return [sides, corners.flat()];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -220,40 +246,12 @@ export function deconstructDiamondElement(
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
||||
|
||||
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 verticalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||
: (topX - leftX) * 0.01;
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
@ -262,94 +260,131 @@ export function deconstructDiamondElement(
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const offsets = [
|
||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
||||
];
|
||||
|
||||
const corners = [
|
||||
const baseCorners = [
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
right,
|
||||
right,
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // RIGHT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
bottom,
|
||||
bottom,
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // BOTTOM
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
left,
|
||||
left,
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // LEFT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
top,
|
||||
top,
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
];
|
||||
|
||||
return [sides, corners.flat()];
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@ -202,6 +204,7 @@ describe("aligning", () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@ -215,6 +218,7 @@ describe("aligning", () => {
|
||||
// Add the created group to the current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@ -316,6 +320,7 @@ describe("aligning", () => {
|
||||
// The second rectangle is already selected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@ -330,7 +335,7 @@ describe("aligning", () => {
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
mouse.restorePosition(200, 200);
|
||||
mouse.restorePosition(210, 200);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@ -341,6 +346,7 @@ describe("aligning", () => {
|
||||
// The second group is already selected because it was the last group created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@ -454,6 +460,7 @@ describe("aligning", () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
@ -466,7 +473,7 @@ describe("aligning", () => {
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
mouse.restorePosition(10, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@ -482,6 +489,7 @@ describe("aligning", () => {
|
||||
// Select the nested group, the rectangle is already selected
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
@ -172,13 +172,13 @@ describe("element binding", () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
size: 49,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(51, 0);
|
||||
mouse.moveTo(57, 0);
|
||||
mouse.up(0, 0);
|
||||
|
||||
// Test sticky connection
|
||||
|
@ -16,8 +16,6 @@ describe("AppStateDelta", () => {
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
@ -59,8 +57,6 @@ describe("AppStateDelta", () => {
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
@ -106,8 +102,6 @@ describe("AppStateDelta", () => {
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
|
@ -346,12 +346,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@ -411,12 +411,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"105.96978",
|
||||
"67.44233",
|
||||
"104.27552",
|
||||
"66.16120",
|
||||
],
|
||||
[
|
||||
"126.08587",
|
||||
"63.29417",
|
||||
"126.95494",
|
||||
"64.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@ -727,12 +727,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"31.88408",
|
||||
"23.13276",
|
||||
"29.28349",
|
||||
"20.91105",
|
||||
],
|
||||
[
|
||||
"77.74793",
|
||||
"44.57841",
|
||||
"78.86048",
|
||||
"46.12277",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@ -816,12 +816,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@ -983,8 +983,8 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": "85.82202",
|
||||
"y": "75.63461",
|
||||
"x": "86.17305",
|
||||
"y": "76.11251",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -1262,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(204, 0);
|
||||
expect(arrow.width).toBeCloseTo(200, 0);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
@ -510,12 +510,12 @@ describe("arrow element", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize(rectangle, "se", [-200, -150]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
@ -538,11 +538,11 @@ describe("arrow element", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
@ -819,7 +819,7 @@ describe("image element", () => {
|
||||
|
||||
UI.resize(image, "ne", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
|
||||
const imageWidth = image.width;
|
||||
const scale = 20 / image.height;
|
||||
@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementsAreInSameGroup,
|
||||
newElementWith,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
@ -16,8 +14,6 @@ import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
|
||||
@ -28,10 +24,15 @@ export const actionToggleElementLock = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lock"
|
||||
: "labels.elementLock.unlock";
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
icon: (appState, elements) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
@ -58,84 +59,19 @@ export const actionToggleElementLock = register({
|
||||
|
||||
const nextLockState = shouldLock(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
const isAGroup =
|
||||
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
|
||||
const isASingleUnit = selectedElements.length === 1 || isAGroup;
|
||||
const newGroupId = isASingleUnit ? null : randomId();
|
||||
|
||||
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
|
||||
|
||||
if (nextLockState) {
|
||||
nextLockedMultiSelections = {
|
||||
...appState.lockedMultiSelections,
|
||||
...(newGroupId ? { [newGroupId]: true } : {}),
|
||||
};
|
||||
} else if (isAGroup) {
|
||||
const groupId = selectedElements[0].groupIds.at(-1)!;
|
||||
delete nextLockedMultiSelections[groupId];
|
||||
}
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
let nextGroupIds = element.groupIds;
|
||||
|
||||
// if locking together, add to group
|
||||
// if unlocking, remove the temporary group
|
||||
if (nextLockState) {
|
||||
if (newGroupId) {
|
||||
nextGroupIds = [...nextGroupIds, newGroupId];
|
||||
}
|
||||
} else {
|
||||
nextGroupIds = nextGroupIds.filter(
|
||||
(groupId) => !appState.lockedMultiSelections[groupId],
|
||||
);
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: nextLockState,
|
||||
// do not recreate the array unncessarily
|
||||
groupIds:
|
||||
nextGroupIds.length !== element.groupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
|
||||
? {}
|
||||
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
|
||||
const unlockedSelectedElements = selectedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
const nextSelectedGroupIds = nextLockState
|
||||
? {}
|
||||
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
|
||||
|
||||
const activeLockedId = nextLockState
|
||||
? newGroupId
|
||||
? newGroupId
|
||||
: isAGroup
|
||||
? selectedElements[0].groupIds.at(-1)!
|
||||
: selectedElements[0].id
|
||||
: null;
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
|
||||
return newElementWith(element, { locked: nextLockState });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: nextSelectedElementIds,
|
||||
selectedGroupIds: nextSelectedGroupIds,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
lockedMultiSelections: nextLockedMultiSelections,
|
||||
activeLockedId,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@ -168,44 +104,18 @@ export const actionUnlockAllElements = register({
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (element.locked) {
|
||||
// remove the temporary groupId if it exists
|
||||
const nextGroupIds = element.groupIds.filter(
|
||||
(gid) => !appState.lockedMultiSelections[gid],
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: false,
|
||||
groupIds:
|
||||
// do not recreate the array unncessarily
|
||||
element.groupIds.length !== nextGroupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
return newElementWith(element, { locked: false });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
|
||||
const unlockedElements = lockedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: Object.fromEntries(
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
selectedGroupIds: selectGroupsFromGivenElements(
|
||||
unlockedElements,
|
||||
appState,
|
||||
),
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
@ -18,7 +18,6 @@ import {
|
||||
arrayToMap,
|
||||
getFontFamilyString,
|
||||
getShortcutKey,
|
||||
tupleToCoors,
|
||||
getLineHeight,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
@ -27,9 +26,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bindLinearElement,
|
||||
bindPointToSnapToElementOutline,
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@ -1626,64 +1623,17 @@ export const actionChangeArrowType = register({
|
||||
-1,
|
||||
elementsMap,
|
||||
);
|
||||
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 &&
|
||||
const startElement =
|
||||
newElement.startBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.startBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
const endElement = endHoveredElement
|
||||
? endHoveredElement
|
||||
: newElement.endBinding &&
|
||||
const endElement =
|
||||
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
|
||||
? {
|
||||
@ -1714,7 +1664,7 @@ export const actionChangeArrowType = register({
|
||||
startBinding,
|
||||
endBinding,
|
||||
...updateElbowArrowPoints(newElement, elementsMap, {
|
||||
points: [finalStartPoint, finalEndPoint].map(
|
||||
points: [startGlobalPoint, endGlobalPoint].map(
|
||||
(p): LocalPoint =>
|
||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||
),
|
||||
|
@ -122,8 +122,6 @@ export const getDefaultAppState = (): Omit<
|
||||
isCropping: false,
|
||||
croppingElementId: null,
|
||||
searchMatches: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -248,8 +246,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||
activeLockedId: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@ -17,8 +17,6 @@ import {
|
||||
vectorDot,
|
||||
vectorNormalize,
|
||||
} from "@excalidraw/math";
|
||||
import { isPointInShape } from "@excalidraw/utils/collision";
|
||||
import { getSelectionBoxShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
@ -104,9 +102,9 @@ import {
|
||||
Emitter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
bindOrUnbindLinearElement,
|
||||
bindOrUnbindLinearElements,
|
||||
fixBindingsAfterDeletion,
|
||||
@ -117,13 +115,8 @@ import {
|
||||
shouldEnableBindingForPointerEvent,
|
||||
updateBoundElements,
|
||||
getSuggestedBindingsForArrows,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newEmbeddableElement,
|
||||
@ -135,11 +128,9 @@ import {
|
||||
newLinearElement,
|
||||
newTextElement,
|
||||
refreshTextDimensions,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { deepCopyElement, duplicateElements } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
deepCopyElement,
|
||||
duplicateElements,
|
||||
isPointInShape,
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBindingElement,
|
||||
@ -160,48 +151,27 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
isBindableElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getLockedLinearCursorAlignSize,
|
||||
getNormalizedDimensions,
|
||||
isElementCompletelyInViewport,
|
||||
isElementInViewport,
|
||||
isInvisiblySmallElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextShape,
|
||||
getCornerRadius,
|
||||
getElementShape,
|
||||
isPathALoop,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
createSrcDoc,
|
||||
embeddableURLValidator,
|
||||
maybeParseEmbedSrc,
|
||||
getEmbedLink,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getInitializedImageElements,
|
||||
loadHTMLImageElement,
|
||||
normalizeSVG,
|
||||
updateImageCache as _updateImageCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerElement,
|
||||
isValidTextContainer,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { shouldShowBoundingBox } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
shouldShowBoundingBox,
|
||||
getFrameChildren,
|
||||
isCursorInFrame,
|
||||
addElementsToFrame,
|
||||
@ -216,29 +186,17 @@ import {
|
||||
getFrameLikeTitle,
|
||||
getElementsOverlappingFrame,
|
||||
filterElementsEligibleAsFrameChildren,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hitElementBoundText,
|
||||
hitElementBoundingBoxOnly,
|
||||
hitElementItself,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getVisibleSceneBounds,
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
getLinkDirectionFromKey,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { cropElement } from "@excalidraw/element";
|
||||
|
||||
import { wrapText } from "@excalidraw/element";
|
||||
|
||||
import { isElementLink, parseElementLinkFromURL } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
cropElement,
|
||||
wrapText,
|
||||
isElementLink,
|
||||
parseElementLinkFromURL,
|
||||
isMeasureTextSupported,
|
||||
normalizeText,
|
||||
measureText,
|
||||
@ -246,13 +204,8 @@ import {
|
||||
getApproxMinLineWidth,
|
||||
getApproxMinLineHeight,
|
||||
getMinTextElementWidth,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { ShapeCache } from "@excalidraw/element";
|
||||
|
||||
import { getRenderOpacity } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
ShapeCache,
|
||||
getRenderOpacity,
|
||||
editGroupForSelectedElement,
|
||||
getElementsInGroup,
|
||||
getSelectedGroupIdForElement,
|
||||
@ -260,42 +213,27 @@ import {
|
||||
isElementInGroup,
|
||||
isSelectedViaGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { syncInvalidIndices, syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectionStateForElements,
|
||||
makeNextSelectedElementIds,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getResizeOffsetXY,
|
||||
getResizeArrowDirection,
|
||||
transformElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getCursorForResizingElement,
|
||||
getElementWithTransformHandleType,
|
||||
getTransformHandleTypeFromCoords,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
dragNewElement,
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
isNonDeletedElement,
|
||||
Scene,
|
||||
Store,
|
||||
CaptureUpdateAction,
|
||||
type ElementUpdate,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { isNonDeletedElement } from "@excalidraw/element";
|
||||
|
||||
import { Scene } from "@excalidraw/element";
|
||||
|
||||
import { Store, CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ElementUpdate } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
@ -485,8 +423,6 @@ import { Toast } from "./Toast";
|
||||
|
||||
import { findShapeByKey } from "./shapes";
|
||||
|
||||
import UnlockPopup from "./UnlockPopup";
|
||||
|
||||
import type {
|
||||
RenderInteractiveSceneCallback,
|
||||
ScrollBars,
|
||||
@ -1878,12 +1814,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
/>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
{this.state.activeLockedId && (
|
||||
<UnlockPopup
|
||||
app={this}
|
||||
activeLockedId={this.state.activeLockedId}
|
||||
/>
|
||||
)}
|
||||
{showShapeSwitchPanel && (
|
||||
<ConvertElementTypePopup app={this} />
|
||||
)}
|
||||
@ -5122,27 +5052,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private getElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: (
|
||||
| {
|
||||
opts?: {
|
||||
preferSelected?: boolean;
|
||||
includeBoundTextElement?: boolean;
|
||||
includeLockedElements?: boolean;
|
||||
}
|
||||
| {
|
||||
allHitElements: NonDeleted<ExcalidrawElement>[];
|
||||
}
|
||||
) & {
|
||||
preferSelected?: boolean;
|
||||
},
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
let allHitElements: NonDeleted<ExcalidrawElement>[] = [];
|
||||
if (opts && "allHitElements" in opts) {
|
||||
allHitElements = opts?.allHitElements || [];
|
||||
} else {
|
||||
allHitElements = this.getElementsAtPosition(x, y, {
|
||||
includeBoundTextElement: opts?.includeBoundTextElement,
|
||||
includeLockedElements: opts?.includeLockedElements,
|
||||
});
|
||||
}
|
||||
const allHitElements = this.getElementsAtPosition(
|
||||
x,
|
||||
y,
|
||||
opts?.includeBoundTextElement,
|
||||
opts?.includeLockedElements,
|
||||
);
|
||||
|
||||
if (allHitElements.length > 1) {
|
||||
if (opts?.preferSelected) {
|
||||
@ -5158,13 +5079,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// If we're hitting element with highest z-index only on its bounding box
|
||||
// while also hitting other element figure, the latter should be considered.
|
||||
return hitElementItself({
|
||||
x,
|
||||
y,
|
||||
point: pointFrom(x, y),
|
||||
element: elementWithHighestZIndex,
|
||||
shape: getElementShape(
|
||||
elementWithHighestZIndex,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
// when overlapping, we would like to be more precise
|
||||
// this also avoids the need to update past tests
|
||||
threshold: this.getElementHitThreshold() / 2,
|
||||
@ -5185,24 +5101,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private getElementsAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: {
|
||||
includeBoundTextElement?: boolean;
|
||||
includeLockedElements?: boolean;
|
||||
},
|
||||
includeBoundTextElement: boolean = false,
|
||||
includeLockedElements: boolean = false,
|
||||
): NonDeleted<ExcalidrawElement>[] {
|
||||
const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
|
||||
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
const elements = (
|
||||
opts?.includeBoundTextElement && opts?.includeLockedElements
|
||||
includeBoundTextElement && includeLockedElements
|
||||
? this.scene.getNonDeletedElements()
|
||||
: this.scene
|
||||
.getNonDeletedElements()
|
||||
.filter(
|
||||
(element) =>
|
||||
(opts?.includeLockedElements || !element.locked) &&
|
||||
(opts?.includeBoundTextElement ||
|
||||
(includeLockedElements || !element.locked) &&
|
||||
(includeBoundTextElement ||
|
||||
!(isTextElement(element) && element.containerId)),
|
||||
)
|
||||
)
|
||||
@ -5248,34 +5162,26 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.selectedElementIds[element.id] &&
|
||||
shouldShowBoundingBox([element], this.state)
|
||||
) {
|
||||
const selectionShape = getSelectionBoxShape(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
isImageElement(element) ? 0 : this.getElementHitThreshold(),
|
||||
);
|
||||
|
||||
// if hitting the bounding box, return early
|
||||
// but if not, we should check for other cases as well (e.g. frame name)
|
||||
if (isPointInShape(pointFrom(x, y), selectionShape)) {
|
||||
if (isPointInShape(pointFrom(x, y), element)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// take bound text element into consideration for hit collision as well
|
||||
const hitBoundTextOfElement = hitElementBoundText(
|
||||
x,
|
||||
y,
|
||||
getBoundTextShape(element, this.scene.getNonDeletedElementsMap()),
|
||||
pointFrom(x, y),
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (hitBoundTextOfElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hitElementItself({
|
||||
x,
|
||||
y,
|
||||
point: pointFrom(x, y),
|
||||
element,
|
||||
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
frameNameBound: isFrameLikeElement(element)
|
||||
? this.frameNameBoundsCache.get(element)
|
||||
@ -5304,13 +5210,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
hitElementItself({
|
||||
x,
|
||||
y,
|
||||
point: pointFrom(x, y),
|
||||
element: elements[index],
|
||||
shape: getElementShape(
|
||||
elements[index],
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
})
|
||||
) {
|
||||
@ -5656,13 +5557,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
hasBoundTextElement(container) ||
|
||||
!isTransparent(container.backgroundColor) ||
|
||||
hitElementItself({
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
point: pointFrom(sceneX, sceneY),
|
||||
element: container,
|
||||
shape: getElementShape(
|
||||
container,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
})
|
||||
) {
|
||||
@ -5688,21 +5584,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private getElementLinkAtPosition = (
|
||||
scenePointer: Readonly<{ x: number; y: number }>,
|
||||
hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
|
||||
hitElement: NonDeletedExcalidrawElement | null,
|
||||
): ExcalidrawElement | undefined => {
|
||||
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
let hitElementIndex = -1;
|
||||
|
||||
for (let index = elements.length - 1; index >= 0; index--) {
|
||||
const element = elements[index];
|
||||
if (
|
||||
hitElementMightBeLocked &&
|
||||
element.id === hitElementMightBeLocked.id
|
||||
) {
|
||||
if (hitElement && element.id === hitElement.id) {
|
||||
hitElementIndex = index;
|
||||
}
|
||||
if (
|
||||
@ -6184,25 +6073,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
const hitElementMightBeLocked = this.getElementAtPosition(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
{
|
||||
preferSelected: true,
|
||||
includeLockedElements: true,
|
||||
},
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
|
||||
let hitElement: ExcalidrawElement | null = null;
|
||||
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
|
||||
hitElement = null;
|
||||
} else {
|
||||
hitElement = hitElementMightBeLocked;
|
||||
}
|
||||
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
scenePointer,
|
||||
hitElementMightBeLocked,
|
||||
hitElement,
|
||||
);
|
||||
if (isEraserActive(this.state)) {
|
||||
return;
|
||||
@ -6295,7 +6173,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: prevState.editingGroupId,
|
||||
selectedElementIds: { [hitElement!.id]: true },
|
||||
selectedElementIds: { [hitElement.id]: true },
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
prevState,
|
||||
@ -6353,13 +6231,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let segmentMidPointHoveredCoords = null;
|
||||
if (
|
||||
hitElementItself({
|
||||
x: scenePointerX,
|
||||
y: scenePointerY,
|
||||
point: pointFrom(scenePointerX, scenePointerY),
|
||||
element,
|
||||
shape: getElementShape(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
})
|
||||
) {
|
||||
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
@ -6809,9 +6682,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
{
|
||||
includeLockedElements: true,
|
||||
},
|
||||
);
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
scenePointer,
|
||||
@ -7247,45 +7117,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const allHitElements = this.getElementsAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
{
|
||||
includeLockedElements: true,
|
||||
},
|
||||
);
|
||||
const unlockedHitElements = allHitElements.filter((e) => !e.locked);
|
||||
|
||||
// Cannot set preferSelected in getElementAtPosition as we do in pointer move; consider:
|
||||
// A & B: both unlocked, A selected, B on top, A & B overlaps in some way
|
||||
// we want to select B when clicking on the overlapping area
|
||||
const hitElementMightBeLocked = this.getElementAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
{
|
||||
allHitElements,
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
!hitElementMightBeLocked ||
|
||||
hitElementMightBeLocked.id !== this.state.activeLockedId
|
||||
) {
|
||||
this.setState({
|
||||
activeLockedId: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hitElementMightBeLocked &&
|
||||
hitElementMightBeLocked.locked &&
|
||||
!unlockedHitElements.some(
|
||||
(el) => this.state.selectedElementIds[el.id],
|
||||
)
|
||||
) {
|
||||
pointerDownState.hit.element = null;
|
||||
} else {
|
||||
// hitElement may already be set above, so check first
|
||||
pointerDownState.hit.element =
|
||||
pointerDownState.hit.element ??
|
||||
@ -7293,11 +7124,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
}
|
||||
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
pointerDownState.origin,
|
||||
hitElementMightBeLocked,
|
||||
pointerDownState.hit.element,
|
||||
);
|
||||
|
||||
if (this.hitLinkElement) {
|
||||
@ -7327,7 +7157,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// For overlapped elements one position may hit
|
||||
// multiple elements
|
||||
pointerDownState.hit.allHitElements = unlockedHitElements;
|
||||
pointerDownState.hit.allHitElements = this.getElementsAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
const someHitElementIsSelected =
|
||||
@ -8143,12 +7976,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
|
||||
|
||||
if (this.state.activeLockedId) {
|
||||
this.setState({
|
||||
activeLockedId: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement &&
|
||||
this.state.selectedLinearElement.elbowed &&
|
||||
@ -9030,49 +8857,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
||||
|
||||
// if current elements are still selected
|
||||
// and the pointer is just over a locked element
|
||||
// do not allow activeLockedId to be set
|
||||
|
||||
const hitElements = pointerDownState.hit.allHitElements;
|
||||
|
||||
if (
|
||||
this.state.activeTool.type === "selection" &&
|
||||
!pointerDownState.boxSelection.hasOccurred &&
|
||||
!pointerDownState.resize.isResizing &&
|
||||
!hitElements.some((el) => this.state.selectedElementIds[el.id])
|
||||
) {
|
||||
const sceneCoords = viewportCoordsToSceneCoords(
|
||||
{ clientX: childEvent.clientX, clientY: childEvent.clientY },
|
||||
this.state,
|
||||
);
|
||||
const hitLockedElement = this.getElementAtPosition(
|
||||
sceneCoords.x,
|
||||
sceneCoords.y,
|
||||
{
|
||||
includeLockedElements: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.store.scheduleCapture();
|
||||
if (hitLockedElement?.locked) {
|
||||
this.setState({
|
||||
activeLockedId:
|
||||
hitLockedElement.groupIds.length > 0
|
||||
? hitLockedElement.groupIds.at(-1) || ""
|
||||
: hitLockedElement.id,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
activeLockedId: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
activeLockedId: null,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selectedElementsAreBeingDragged: false,
|
||||
});
|
||||
@ -9822,13 +9606,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
((hitElement &&
|
||||
hitElementBoundingBoxOnly(
|
||||
{
|
||||
x: pointerDownState.origin.x,
|
||||
y: pointerDownState.origin.y,
|
||||
element: hitElement,
|
||||
shape: getElementShape(
|
||||
hitElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
point: pointFrom(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
),
|
||||
element: hitElement,
|
||||
threshold: this.getElementHitThreshold(),
|
||||
frameNameBound: isFrameLikeElement(hitElement)
|
||||
? this.frameNameBoundsCache.get(hitElement)
|
||||
|
@ -133,10 +133,9 @@ 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"));
|
||||
UI.updateInput(inputX, String("199"));
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
@ -657,6 +656,7 @@ describe("stats for multiple elements", () => {
|
||||
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
|
@ -1,40 +0,0 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.UnlockPopup {
|
||||
position: absolute;
|
||||
z-index: var(--zIndex-interactiveCanvas);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
padding: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-gray-60);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
svg {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import {
|
||||
getCommonBounds,
|
||||
getElementsInGroup,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "@excalidraw/element";
|
||||
import { sceneCoordsToViewportCoords } from "@excalidraw/common";
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { actionToggleElementLock } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./UnlockPopup.scss";
|
||||
|
||||
import { LockedIconFilled } from "./icons";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const UnlockPopup = ({
|
||||
app,
|
||||
activeLockedId,
|
||||
}: {
|
||||
app: App;
|
||||
activeLockedId: NonNullable<AppState["activeLockedId"]>;
|
||||
}) => {
|
||||
const element = app.scene.getElement(activeLockedId);
|
||||
|
||||
const elements = element
|
||||
? [element]
|
||||
: getElementsInGroup(app.scene.getNonDeletedElementsMap(), activeLockedId);
|
||||
|
||||
if (elements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [x, y] = getCommonBounds(elements);
|
||||
const { x: viewX, y: viewY } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x, sceneY: y },
|
||||
app.state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="UnlockPopup"
|
||||
style={{
|
||||
bottom: `${app.state.height + 12 - viewY + app.state.offsetTop}px`,
|
||||
left: `${viewX - app.state.offsetLeft}px`,
|
||||
}}
|
||||
onClick={() => {
|
||||
flushSync(() => {
|
||||
const groupIds = selectGroupsFromGivenElements(elements, app.state);
|
||||
app.setState({
|
||||
selectedElementIds: elements.reduce(
|
||||
(acc, element) => ({
|
||||
...acc,
|
||||
[element.id]: true,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
selectedGroupIds: groupIds,
|
||||
activeLockedId: null,
|
||||
});
|
||||
});
|
||||
app.actionManager.executeAction(actionToggleElementLock);
|
||||
}}
|
||||
title={t("labels.elementLock.unlock")}
|
||||
>
|
||||
{LockedIconFilled}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnlockPopup;
|
@ -215,7 +215,6 @@ const getRelevantAppStateProps = (
|
||||
isCropping: appState.isCropping,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
searchMatches: appState.searchMatches,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
|
||||
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
|
||||
if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
|
||||
return false;
|
||||
}
|
||||
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
@ -92,7 +92,7 @@ export const isPointHittingLink = (
|
||||
if (
|
||||
!isMobile &&
|
||||
appState.viewModeEnabled &&
|
||||
hitElementBoundingBox(x, y, element, elementsMap)
|
||||
hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
@ -178,16 +178,6 @@ export const LockedIcon = createIcon(
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const LockedIconFilled = createIcon(
|
||||
<g fill="currentColor">
|
||||
<path d="M12 2a5 5 0 0 1 5 5v3a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-10a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3v-3a5 5 0 0 1 5 -5m0 12a2 2 0 0 0 -1.995 1.85l-.005 .15a2 2 0 1 0 2 -2m0 -10a3 3 0 0 0 -3 3v3h6v-3a3 3 0 0 0 -3 -3" />
|
||||
</g>,
|
||||
{
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
);
|
||||
|
||||
// custom
|
||||
export const WelcomeScreenMenuArrow = createIcon(
|
||||
<>
|
||||
|
@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startBinding": {
|
||||
"elementId": "diamond-1",
|
||||
"focus": 0,
|
||||
"gap": 4.545343408287929,
|
||||
"gap": 4.535423522449215,
|
||||
},
|
||||
"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": 14,
|
||||
"gap": 16,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -1538,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"endBinding": {
|
||||
"elementId": "B",
|
||||
"focus": 0,
|
||||
"gap": 14,
|
||||
"gap": 32,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -1789,7 +1789,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 120,
|
||||
"x": 187.7545,
|
||||
"x": 187.75450000000004,
|
||||
"y": 44.5,
|
||||
}
|
||||
`;
|
||||
|
@ -781,7 +781,7 @@ describe("Test Transform", () => {
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: -0,
|
||||
gap: 14,
|
||||
gap: 25,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
||||
import { getElementLineSegments } from "@excalidraw/element";
|
||||
import { getElementLineSegments, isPointInShape } from "@excalidraw/element";
|
||||
import {
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
@ -8,9 +8,7 @@ import {
|
||||
|
||||
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";
|
||||
|
||||
@ -208,15 +206,8 @@ const eraserTest = (
|
||||
elementsMap: ElementsMap,
|
||||
app: App,
|
||||
): boolean => {
|
||||
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)) {
|
||||
if (shouldTestInside(element) && isPointInShape(lastPoint, element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -37,10 +37,9 @@ export const getLassoSelectedElementIds = (input: {
|
||||
if (simplifyDistance) {
|
||||
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
|
||||
}
|
||||
const unlockedElements = elements.filter((el) => !el.locked);
|
||||
// as the path might not enclose a shape anymore, clear before checking
|
||||
enclosedElements.clear();
|
||||
for (const element of unlockedElements) {
|
||||
for (const element of elements) {
|
||||
if (
|
||||
!intersectedElements.has(element.id) &&
|
||||
!enclosedElements.has(element.id)
|
||||
|
@ -5,17 +5,14 @@ import { getDiamondPoints } from "@excalidraw/element";
|
||||
import { getCornerRadius } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
curve,
|
||||
curveTangent,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveCatmullRomQuadraticApproxPoints,
|
||||
curveOffsetPoints,
|
||||
type GlobalPoint,
|
||||
offsetPointsForQuadraticBezier,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
vector,
|
||||
vectorNormal,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
@ -99,25 +96,14 @@ export const bootstrapCanvas = ({
|
||||
function drawCatmullRomQuadraticApprox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: GlobalPoint[],
|
||||
segments = 20,
|
||||
tension = 0.5,
|
||||
) {
|
||||
ctx.lineTo(points[0][0], points[0][1]);
|
||||
const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
|
||||
if (pointSets) {
|
||||
for (let i = 0; i < pointSets.length - 1; i++) {
|
||||
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
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);
|
||||
ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,35 +111,13 @@ function drawCatmullRomQuadraticApprox(
|
||||
function drawCatmullRomCubicApprox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: GlobalPoint[],
|
||||
segments = 20,
|
||||
tension = 0.5,
|
||||
) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -184,25 +148,25 @@ export const drawHighlightForRectWithRotation = (
|
||||
context.beginPath();
|
||||
|
||||
{
|
||||
const topLeftApprox = offsetQuadraticBezier(
|
||||
const topLeftApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(0, 0 + radius),
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0 + radius, 0),
|
||||
padding,
|
||||
);
|
||||
const topRightApprox = offsetQuadraticBezier(
|
||||
const topRightApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(element.width - radius, 0),
|
||||
pointFrom(element.width, 0),
|
||||
pointFrom(element.width, radius),
|
||||
padding,
|
||||
);
|
||||
const bottomRightApprox = offsetQuadraticBezier(
|
||||
const bottomRightApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(element.width, element.height - radius),
|
||||
pointFrom(element.width, element.height),
|
||||
pointFrom(element.width - radius, element.height),
|
||||
padding,
|
||||
);
|
||||
const bottomLeftApprox = offsetQuadraticBezier(
|
||||
const bottomLeftApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(radius, element.height),
|
||||
pointFrom(0, element.height),
|
||||
pointFrom(0, element.height - radius),
|
||||
@ -227,25 +191,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 = offsetQuadraticBezier(
|
||||
const topLeftApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(0 + radius, 0),
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, 0 + radius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const topRightApprox = offsetQuadraticBezier(
|
||||
const topRightApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(element.width, radius),
|
||||
pointFrom(element.width, 0),
|
||||
pointFrom(element.width - radius, 0),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const bottomRightApprox = offsetQuadraticBezier(
|
||||
const bottomRightApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(element.width - radius, element.height),
|
||||
pointFrom(element.width, element.height),
|
||||
pointFrom(element.width, element.height - radius),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const bottomLeftApprox = offsetQuadraticBezier(
|
||||
const bottomLeftApprox = offsetPointsForQuadraticBezier(
|
||||
pointFrom(0, element.height - radius),
|
||||
pointFrom(0, element.height),
|
||||
pointFrom(radius, element.height),
|
||||
@ -340,32 +304,40 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
const topApprox = offsetCubicBezier(
|
||||
const topApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||
),
|
||||
padding,
|
||||
);
|
||||
const rightApprox = offsetCubicBezier(
|
||||
const rightApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||
),
|
||||
padding,
|
||||
);
|
||||
const bottomApprox = offsetCubicBezier(
|
||||
const bottomApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||
),
|
||||
padding,
|
||||
);
|
||||
const leftApprox = offsetCubicBezier(
|
||||
const leftApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||
),
|
||||
padding,
|
||||
);
|
||||
|
||||
@ -373,13 +345,13 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
topApprox[topApprox.length - 1][0],
|
||||
topApprox[topApprox.length - 1][1],
|
||||
);
|
||||
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
|
||||
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, rightApprox);
|
||||
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
|
||||
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, bottomApprox);
|
||||
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
|
||||
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, leftApprox);
|
||||
context.lineTo(topApprox[0][0], topApprox[0][1]);
|
||||
context.lineTo(topApprox[1][0], topApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, topApprox);
|
||||
}
|
||||
|
||||
@ -395,32 +367,40 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
const topApprox = offsetCubicBezier(
|
||||
const topApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX, topY),
|
||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||
),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const rightApprox = offsetCubicBezier(
|
||||
const rightApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX, rightY),
|
||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||
),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const bottomApprox = offsetCubicBezier(
|
||||
const bottomApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX, bottomY),
|
||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||
),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
const leftApprox = offsetCubicBezier(
|
||||
const leftApprox = curveOffsetPoints(
|
||||
curve(
|
||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX, leftY),
|
||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||
),
|
||||
-FIXED_BINDING_DISTANCE,
|
||||
);
|
||||
|
||||
@ -428,66 +408,16 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
topApprox[topApprox.length - 1][0],
|
||||
topApprox[topApprox.length - 1][1],
|
||||
);
|
||||
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
|
||||
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, leftApprox);
|
||||
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
|
||||
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, bottomApprox);
|
||||
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
|
||||
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
|
||||
drawCatmullRomCubicApprox(context, rightApprox);
|
||||
context.lineTo(topApprox[0][0], topApprox[0][1]);
|
||||
context.lineTo(topApprox[1][0], topApprox[1][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;
|
||||
}
|
||||
|
@ -187,16 +187,10 @@ const renderBindingHighlightForBindableElement = (
|
||||
elementsMap: ElementsMap,
|
||||
zoom: InteractiveCanvasAppState["zoom"],
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
|
||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||
context.fillStyle = "rgba(0,0,0,.05)";
|
||||
|
||||
// To ensure the binding highlight doesn't overlap the element itself
|
||||
const padding = maxBindingGap(element, element.width, element.height, zoom);
|
||||
|
||||
context.fillStyle = "rgba(0,0,0,.05)";
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
@ -211,9 +205,12 @@ const renderBindingHighlightForBindableElement = (
|
||||
drawHighlightForDiamondWithRotation(context, padding, element);
|
||||
break;
|
||||
case "ellipse":
|
||||
context.lineWidth =
|
||||
maxBindingGap(element, element.width, element.height, zoom) -
|
||||
FIXED_BINDING_DISTANCE;
|
||||
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;
|
||||
|
||||
strokeEllipseWithRotation(
|
||||
context,
|
||||
@ -377,9 +374,7 @@ const renderElementsBoxHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
config?: { colors?: string[]; dashed?: boolean },
|
||||
) => {
|
||||
const { colors = ["rgb(0,118,255)"], dashed = false } = config || {};
|
||||
const individualElements = elements.filter(
|
||||
(element) => element.groupIds.length === 0,
|
||||
);
|
||||
@ -396,8 +391,8 @@ const renderElementsBoxHighlight = (
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
selectionColors: colors,
|
||||
dashed,
|
||||
selectionColors: ["rgb(0,118,255)"],
|
||||
dashed: false,
|
||||
cx: x1 + (x2 - x1) / 2,
|
||||
cy: y1 + (y2 - y1) / 2,
|
||||
activeEmbeddable: false,
|
||||
@ -789,17 +784,6 @@ const _renderInteractiveScene = ({
|
||||
renderElementsBoxHighlight(context, appState, appState.elementsToHighlight);
|
||||
}
|
||||
|
||||
if (appState.activeLockedId) {
|
||||
const element = allElementsMap.get(appState.activeLockedId);
|
||||
const elements = element
|
||||
? [element]
|
||||
: getElementsInGroup(allElementsMap, appState.activeLockedId);
|
||||
renderElementsBoxHighlight(context, appState, elements, {
|
||||
colors: ["#ced4da"],
|
||||
dashed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const isFrameSelected = selectedElements.some((element) =>
|
||||
isFrameLikeElement(element),
|
||||
);
|
||||
@ -914,8 +898,8 @@ const _renderInteractiveScene = ({
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
selectionColors: element.locked ? ["#ced4da"] : selectionColors,
|
||||
dashed: !!remoteClients || element.locked,
|
||||
selectionColors,
|
||||
dashed: !!remoteClients,
|
||||
cx,
|
||||
cy,
|
||||
activeEmbeddable:
|
||||
@ -939,9 +923,7 @@ const _renderInteractiveScene = ({
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
selectionColors: groupElements.some((el) => el.locked)
|
||||
? ["#ced4da"]
|
||||
: [oc.black],
|
||||
selectionColors: [oc.black],
|
||||
dashed: true,
|
||||
cx: x1 + (x2 - x1) / 2,
|
||||
cy: y1 + (y2 - y1) / 2,
|
||||
@ -1005,11 +987,7 @@ const _renderInteractiveScene = ({
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selectedElements.length > 1 &&
|
||||
!appState.isRotating &&
|
||||
!selectedElements.some((el) => el.locked)
|
||||
) {
|
||||
} else if (selectedElements.length > 1 && !appState.isRotating) {
|
||||
const dashedLinePadding =
|
||||
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
||||
context.fillStyle = oc.white;
|
||||
|
@ -3,7 +3,6 @@
|
||||
exports[`contextMenu element > right-clicking on a group should select whole group > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -936,7 +935,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1080,7 +1078,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -1139,7 +1136,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1225,7 +1221,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"version": 3,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -1278,7 +1274,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -1296,7 +1292,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -1355,7 +1350,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1629,7 +1623,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -1688,7 +1681,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1962,7 +1954,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2021,7 +2012,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2107,7 +2097,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"version": 3,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -2160,7 +2150,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -2178,7 +2168,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2237,7 +2226,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2319,7 +2307,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"version": 4,
|
||||
"versionNonce": 1014066025,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -2372,7 +2360,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -2419,7 +2407,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2478,7 +2465,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2562,7 +2548,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"version": 3,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -2596,7 +2582,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"version": 5,
|
||||
"versionNonce": 400692809,
|
||||
"width": 20,
|
||||
"x": 0,
|
||||
"x": 3,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
@ -2649,7 +2635,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"x": -7,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -2703,7 +2689,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"width": 20,
|
||||
"x": 0,
|
||||
"x": 3,
|
||||
"y": 10,
|
||||
},
|
||||
"inserted": {
|
||||
@ -2721,7 +2707,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2780,7 +2765,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -3093,7 +3077,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3152,7 +3135,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -3576,7 +3558,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3635,7 +3616,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -3901,7 +3881,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3960,7 +3939,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -4226,7 +4204,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -4285,7 +4262,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -4633,7 +4609,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -5566,7 +5541,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -5854,7 +5828,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -6787,7 +6760,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -7122,7 +7094,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -7722,7 +7693,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -7789,7 +7759,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] und
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -8670,8 +8639,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
},
|
||||
},
|
||||
],
|
||||
"left": -17,
|
||||
"top": -7,
|
||||
"left": 10,
|
||||
"top": 20,
|
||||
},
|
||||
"croppingElementId": null,
|
||||
"currentChartType": "bar",
|
||||
@ -8722,7 +8691,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -8780,7 +8748,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] appState 2`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -9713,7 +9680,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = `
|
||||
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTTW/TQFx1MDAxML3zKyz3iqiTqlx1MDAxY3IrUERcdTAwMGblQJA4IFx1MDAwZVvvxFx1MDAxZWWyu9pcdTAwMWQ3XHRVJH5cdTAwMDY3/lwiP4HZjeuNnWJLlvbtfLx58/z0qihK3jsoXHUwMDE3RVx0u1pcdTAwMTFqr7bl64g/glx1MDAwZmiNXFzN0znYztcpsmV2i8tLspLQ2sCLq6qqjklAsFx1MDAwMcNBwr7LuSie0lduUMfUm1x1MDAxNJaA575cZjvO6E6gajjtR6ctam5cdTAwMDWZvVx1MDAxZKBcdTAwMTawaXmMKdNcdTAwMTCMXHUwMDEyXHUwMDAze7uG95asj1x1MDAxZC9mXHUwMDEw39z0QdXrxtvO6CGGvTLBKS/D5LhcdTAwMTVcdTAwMTIteZ+qi1x1MDAxZaJWOenxrac4n+D/y5KmTWsgRMFmXHUwMDAzap2qkePwsypPXHUwMDExXHUwMDE5ujudtP2ROXm1gbsorumIXHUwMDA2XHUwMDE4jYbdXHUwMDE0TCP23Z5cdTAwMTeTN3HVI4fMXHUwMDFkQI+IZU+cYZ+tqceqY/ggduBUYqUoQNY78rjNVlx1MDAxOZHsnFY86Uto1tM4sd/6hdrJTlwi9N8/v3+dbM5cdTAwMWFe4s9IcF6N0I9qg1x1MDAxNKW+XHUwMDFllbghbOKcJcHqxFx1MDAwMTIso9h+uGbr8m0t9Vx1MDAxNFx1MDAxYfDn+7BcdTAwMWVcdTAwMWI0ir6+SE91bL9AOFx1MDAxMmTfwenk8Gkw+Zv5dbo4yDdZoFTOLVn0XHUwMDFhNio2QT1cdTAwMTn1iDG4PGaC7q2GW6NcdTAwMWVoqmP5iLB9d/5XXFys0tNcdTAwMTPvV3DfXHUwMDEx41JWXbP4IHkr8ks2ir9cZvTQ4Vx1MDAxZvRzK/4ifQ==<!-- payload-end --></metadata><defs><style class="style-fonts">
|
||||
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTTU/jMFx1MDAxML33V0TmulrSXCL20Fx1MDAxYrtcdTAwMGKCw+5hi8RcdTAwMDFxMPE0XHUwMDE51bUte0JbUCV+xt72L/JcdTAwMTNcdTAwMTi7JW5SNpFcIvnN15vnl5dRUVxi2jhcdTAwMTDTQsC6klx1MDAxYZWXK/El4k/gXHUwMDAzWsOhSTpcdTAwMDfb+iplNkRuenqqLVx1MDAxNzQ20PSsLMtdXHUwMDExaFiCocBp93wuipf05Vxiqlh6kdJcdTAwMTLwMZdgTVx1MDAxOV0zVHanTe+0QkVcciPjb1x1MDAxZNRcdTAwMDDWXHL1MWlqXHK9wkDeLuCH1dbHiSdjiG9cdTAwMWX6KKtF7W1rVJdDXprgpOdlct5cdTAwMWO1ntEmdWc9WC0xmHG3pzhcdTAwMTng/6vioXVjIETBxlx1MDAxZGqdrJDi8uMyb1x1MDAxMVx1MDAxObpcdTAwMWKVtH3InLxcXMJNXHUwMDE017RadzBcdTAwMWFcdTAwMDXrIZhW3E/7uJh8XHUwMDEzZ3tkm7lcdTAwMDOoXHUwMDFlseyJI+y3NVVfdVxmP9lcdTAwMGWUWsylXHUwMDBlkPWOPC6zVXokW6ckXHLmajSLYVx1MDAxZdtv8UnvZCdcdTAwMTb67d/f14Obs4Zm+Fx1MDAxY1x0TspcdTAwMWV6JZeoo9TnvVx1MDAxNlx1MDAxN1x1MDAxYeu4p9AwP3BcdTAwMDAvS8i278JkXY5W3E+iXHUwMDAxf3xcdTAwMWbWY41G6ttP6cmW7Fx1MDAxZlxiO4LkWzjcXHUwMDFjrjuTf52cp8CWv8lcdTAwMDJCOjcj1qu7UbZcdKrBqjuMwOU1XHUwMDEz9MsquDTyUVx1MDAwZnVcdTAwMTRPXGKr78d/xck8PWK0d0n8IyC5aTvavlx1MDAwM9lcdTAwMDIhXHUwMDFjIn0=<!-- payload-end --></metadata><defs><style class="style-fonts">
|
||||
</style></defs><rect x="0" y="0" width="36" height="36" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 8 8)" data-id="A"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">😀</text></g></svg>"
|
||||
`;
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -196,7 +196,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "87.29887",
|
||||
"height": "81.40630",
|
||||
"id": "id6",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@ -210,8 +210,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
0,
|
||||
],
|
||||
[
|
||||
"86.85786",
|
||||
"87.29887",
|
||||
"81.00000",
|
||||
"81.40630",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1051383431,
|
||||
"width": "86.85786",
|
||||
"x": "107.07107",
|
||||
"y": "47.07107",
|
||||
"width": "81.00000",
|
||||
"x": "110.00000",
|
||||
"y": 50,
|
||||
}
|
||||
`;
|
||||
|
@ -3,7 +3,6 @@
|
||||
exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -62,7 +61,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -425,7 +423,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -484,7 +481,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -837,7 +833,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -896,7 +891,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1393,7 +1387,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -1452,7 +1445,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1598,7 +1590,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -1657,7 +1648,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -1978,7 +1968,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] undo s
|
||||
exports[`regression tests > alt-drag duplicates an element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2037,7 +2026,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2214,7 +2202,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
|
||||
exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2273,7 +2260,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2394,7 +2380,6 @@ exports[`regression tests > arrow keys > [end of test] undo stack 1`] = `
|
||||
exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2453,7 +2438,6 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2481,7 +2465,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": null,
|
||||
"selectedElementIds": {
|
||||
"id3": true,
|
||||
"id0": true,
|
||||
},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@ -2683,7 +2667,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedElementIds": {
|
||||
"id3": true,
|
||||
"id0": true,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
@ -2697,7 +2681,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"added": {},
|
||||
"removed": {},
|
||||
"updated": {
|
||||
"id3": {
|
||||
"id0": {
|
||||
"deleted": {
|
||||
"x": 300,
|
||||
"y": 300,
|
||||
@ -2717,7 +2701,6 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
exports[`regression tests > change the properties of a shape > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -2776,7 +2759,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -2966,7 +2948,6 @@ exports[`regression tests > change the properties of a shape > [end of test] und
|
||||
exports[`regression tests > click on an element and drag it > [dragged] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3025,7 +3006,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -3207,7 +3187,6 @@ exports[`regression tests > click on an element and drag it > [dragged] undo sta
|
||||
exports[`regression tests > click on an element and drag it > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3266,7 +3245,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -3439,7 +3417,6 @@ exports[`regression tests > click on an element and drag it > [end of test] undo
|
||||
exports[`regression tests > click to select a shape > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3498,7 +3475,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -3697,7 +3673,6 @@ exports[`regression tests > click to select a shape > [end of test] undo stack 1
|
||||
exports[`regression tests > click-drag to select a group > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -3756,7 +3731,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -4011,7 +3985,6 @@ exports[`regression tests > click-drag to select a group > [end of test] undo st
|
||||
exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -4070,7 +4043,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -4441,7 +4413,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -4500,7 +4471,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -4726,7 +4696,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -4785,7 +4754,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -4975,34 +4943,12 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
},
|
||||
"id": "id8",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedElementIds": {},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id3": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"elements": {
|
||||
"added": {},
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id11",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -5061,7 +5007,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -5212,7 +5157,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -5271,7 +5215,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -5412,7 +5355,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
exports[`regression tests > double click to edit a group > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -5471,7 +5413,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -5799,7 +5740,6 @@ exports[`regression tests > double click to edit a group > [end of test] undo st
|
||||
exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -5858,7 +5798,6 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -6092,7 +6031,6 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
exports[`regression tests > draw every type of shape > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -6151,7 +6089,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -6912,7 +6849,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -6971,7 +6907,6 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -7246,7 +7181,6 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -7305,7 +7239,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -7525,7 +7458,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
exports[`regression tests > given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -7584,7 +7516,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -7760,7 +7691,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
exports[`regression tests > given selected element A with lower z-index than unselected element B and given B is partially over A when dragging on intersection between A and B A should be dragged and keep being selected > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -7819,7 +7749,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -7998,7 +7927,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
exports[`regression tests > key 2 selects rectangle tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -8057,7 +7985,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -8178,7 +8105,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
|
||||
exports[`regression tests > key 3 selects diamond tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -8237,7 +8163,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -8358,7 +8283,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
|
||||
exports[`regression tests > key 4 selects ellipse tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -8417,7 +8341,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -8538,7 +8461,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
|
||||
exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -8597,7 +8519,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -8761,7 +8682,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -8820,7 +8740,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -8983,7 +8902,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
exports[`regression tests > key 7 selects freedraw tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -9042,7 +8960,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -9177,7 +9094,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
|
||||
exports[`regression tests > key a selects arrow tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -9236,7 +9152,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -9400,7 +9315,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
exports[`regression tests > key d selects diamond tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -9459,7 +9373,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -9580,7 +9493,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
|
||||
exports[`regression tests > key l selects line tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -9639,7 +9551,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -9802,7 +9713,6 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
exports[`regression tests > key o selects ellipse tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -9861,7 +9771,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -9982,7 +9891,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
|
||||
exports[`regression tests > key p selects freedraw tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -10041,7 +9949,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -10176,7 +10083,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
|
||||
exports[`regression tests > key r selects rectangle tool > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -10235,7 +10141,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -10356,7 +10261,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
|
||||
exports[`regression tests > make a group and duplicate it > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -10415,7 +10319,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -10856,7 +10759,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
|
||||
exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -10915,7 +10817,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -11136,7 +11037,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -11195,7 +11095,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "touch",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -11259,7 +11158,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] undo stack 1`] =
|
||||
exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -11318,7 +11216,6 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -11459,7 +11356,6 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
exports[`regression tests > shift-click to multiselect, then drag > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -11518,7 +11414,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -11774,7 +11669,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
exports[`regression tests > should group elements and ungroup them > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -11833,7 +11727,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -12191,7 +12084,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -12250,7 +12142,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -12815,7 +12706,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -12874,7 +12764,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -12941,7 +12830,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] u
|
||||
exports[`regression tests > supports nested groups > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -13000,7 +12888,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -13400,15 +13287,16 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"editingGroupId": null,
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {},
|
||||
"selectedElementIds": {
|
||||
"id3": true,
|
||||
},
|
||||
"selectedGroupIds": {
|
||||
"id11": true,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"editingGroupId": "id11",
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id6": true,
|
||||
},
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {
|
||||
"id19": true,
|
||||
},
|
||||
@ -13420,33 +13308,7 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id24",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id3": true,
|
||||
"id6": true,
|
||||
},
|
||||
"selectedGroupIds": {
|
||||
"id11": true,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
"elements": {
|
||||
"added": {},
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id27",
|
||||
"id": "id25",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
@ -13474,34 +13336,34 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id27",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"editingGroupId": "id19",
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {},
|
||||
},
|
||||
"inserted": {
|
||||
"editingGroupId": "id11",
|
||||
"selectedElementIds": {
|
||||
"id6": true,
|
||||
},
|
||||
"selectedGroupIds": {
|
||||
"id19": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"elements": {
|
||||
"added": {},
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id29",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"editingGroupId": "id19",
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {},
|
||||
},
|
||||
"inserted": {
|
||||
"editingGroupId": "id11",
|
||||
"selectedElementIds": {
|
||||
"id6": true,
|
||||
},
|
||||
"selectedGroupIds": {
|
||||
"id19": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"elements": {
|
||||
"added": {},
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id31",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
@ -13527,7 +13389,7 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id34",
|
||||
"id": "id32",
|
||||
},
|
||||
{
|
||||
"appState": AppStateDelta {
|
||||
@ -13554,7 +13416,7 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
|
||||
"removed": {},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id36",
|
||||
"id": "id34",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@ -13562,7 +13424,6 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
|
||||
exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -13621,7 +13482,6 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -13903,7 +13763,6 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
exports[`regression tests > switches selected element on pointer down > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -13962,7 +13821,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -14169,7 +14027,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
exports[`regression tests > two-finger scroll works > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -14228,7 +14085,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "touch",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -14292,7 +14148,6 @@ exports[`regression tests > two-finger scroll works > [end of test] undo stack 1
|
||||
exports[`regression tests > undo/redo drawing an element > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -14351,7 +14206,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -14677,7 +14531,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] undo st
|
||||
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -14736,7 +14589,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
@ -14800,7 +14652,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -14859,7 +14710,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"newElement": null,
|
||||
|
@ -115,9 +115,10 @@ describe("contextMenu element", () => {
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
clientX: 30,
|
||||
clientY: 30,
|
||||
});
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
@ -304,12 +305,12 @@ describe("contextMenu element", () => {
|
||||
|
||||
it("selecting 'Copy styles' in context menu copies styles", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.down(13, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientX: 13,
|
||||
clientY: 3,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
@ -389,12 +390,12 @@ describe("contextMenu element", () => {
|
||||
|
||||
it("selecting 'Delete' in context menu deletes element", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.down(13, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientX: 13,
|
||||
clientY: 3,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
@ -405,12 +406,12 @@ describe("contextMenu element", () => {
|
||||
|
||||
it("selecting 'Add to library' in context menu adds element to library", async () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.down(13, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientX: 13,
|
||||
clientY: 3,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
@ -424,12 +425,12 @@ describe("contextMenu element", () => {
|
||||
|
||||
it("selecting 'Duplicate' in context menu duplicates element", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.down(13, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientX: 13,
|
||||
clientY: 3,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
|
@ -72,7 +72,7 @@ describe("element locking", () => {
|
||||
expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
|
||||
});
|
||||
|
||||
it("dragging element that's below a locked element", () => {
|
||||
it("you can drag element that's below a locked element", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
@ -89,14 +89,6 @@ describe("element locking", () => {
|
||||
|
||||
API.setElements([rectangle, lockedRectangle]);
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.upAt(100, 100);
|
||||
expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
|
||||
expect(rectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
|
||||
|
||||
// once selected, the locked element above should be ignored
|
||||
API.setSelectedElements([rectangle]);
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.upAt(100, 100);
|
||||
@ -115,7 +107,7 @@ describe("element locking", () => {
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
});
|
||||
|
||||
it("clicking on a locked element should not select the unlocked element beneath it", () => {
|
||||
it("clicking on a locked element should select the unlocked element beneath it", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
@ -133,8 +125,8 @@ describe("element locking", () => {
|
||||
API.setElements([rectangle, lockedRectangle]);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
mouse.clickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
expect(h.state.activeLockedId).toBe(lockedRectangle.id);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
expect(API.getSelectedElement().id).toBe(rectangle.id);
|
||||
});
|
||||
|
||||
it("right-clicking on a locked element should select it & open its contextMenu", () => {
|
||||
|
@ -383,9 +383,7 @@ const proxy = <T extends ExcalidrawElement>(
|
||||
the proxy */
|
||||
get(): typeof element;
|
||||
} => {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
return new Proxy(element, {
|
||||
get(target, prop) {
|
||||
const currentElement = h.elements.find(
|
||||
({ id }) => id === element.id,
|
||||
@ -400,8 +398,7 @@ const proxy = <T extends ExcalidrawElement>(
|
||||
}
|
||||
return currentElement[prop];
|
||||
},
|
||||
},
|
||||
) as any;
|
||||
}) as any;
|
||||
};
|
||||
|
||||
/** Tools that can be used to draw shapes */
|
||||
|
@ -124,8 +124,8 @@ describe("move element", () => {
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([201, 2]);
|
||||
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]);
|
||||
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
@ -704,7 +704,7 @@ describe("regression tests", () => {
|
||||
|
||||
// pointer down on rectangle
|
||||
mouse.reset();
|
||||
mouse.down(100, 100);
|
||||
mouse.down(110, 100); // Rectangle is rounded, there is no selection at the corner
|
||||
mouse.up(200, 200);
|
||||
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
@ -989,6 +989,7 @@ describe("regression tests", () => {
|
||||
|
||||
// select rectangle
|
||||
mouse.reset();
|
||||
mouse.moveTo(30, 0); // Rectangle is rounded, there is no selection at the corner
|
||||
mouse.click();
|
||||
|
||||
// click on intersection between ellipse and rectangle
|
||||
@ -1155,6 +1156,7 @@ it(
|
||||
// Select first rectangle while keeping third one selected.
|
||||
// Third rectangle is selected because it was the last element to be created.
|
||||
mouse.reset();
|
||||
mouse.moveTo(30, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
@ -1176,6 +1178,7 @@ it(
|
||||
|
||||
// Pointer down o first rectangle that is part of the group
|
||||
mouse.reset();
|
||||
mouse.moveTo(30, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.down();
|
||||
});
|
||||
|
@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
expect(arrow.x).toBeCloseTo(-80);
|
||||
expect(arrow.y).toBeCloseTo(50);
|
||||
expect(arrow.width).toBeCloseTo(116.7, 1);
|
||||
expect(arrow.width).toBeCloseTo(110.7, 1);
|
||||
expect(arrow.height).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
|
@ -235,7 +235,6 @@ export type InteractiveCanvasAppState = Readonly<
|
||||
croppingElementId: AppState["croppingElementId"];
|
||||
// Search matches
|
||||
searchMatches: AppState["searchMatches"];
|
||||
activeLockedId: AppState["activeLockedId"];
|
||||
}
|
||||
>;
|
||||
|
||||
@ -256,8 +255,6 @@ export type ObservedElementsAppState = {
|
||||
// Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId
|
||||
selectedLinearElementId: LinearElementEditor["elementId"] | null;
|
||||
croppingElementId: AppState["croppingElementId"];
|
||||
lockedMultiSelections: AppState["lockedMultiSelections"];
|
||||
activeLockedId: AppState["activeLockedId"];
|
||||
};
|
||||
|
||||
export interface AppState {
|
||||
@ -440,14 +437,6 @@ export interface AppState {
|
||||
focusedId: ExcalidrawElement["id"] | null;
|
||||
matches: readonly SearchMatch[];
|
||||
}> | null;
|
||||
|
||||
/** the locked element/group that's active and shows unlock popup */
|
||||
activeLockedId: string | null;
|
||||
// when locking multiple units of elements together, we assign a temporary
|
||||
// groupId to them so we can unlock them together;
|
||||
// as elements are unlocked, we remove the groupId from the elements
|
||||
// and also remove groupId from this map
|
||||
lockedMultiSelections: { [groupId: string]: true };
|
||||
}
|
||||
|
||||
export type SearchMatch = {
|
||||
|
@ -682,7 +682,7 @@ describe("textWysiwyg", () => {
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
|
||||
it("should bind text to container when double clicked on center of transparent container", async () => {
|
||||
it("should bind text to container when double clicked inside of the transparent container", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
@ -693,7 +693,7 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
API.setElements([rectangle]);
|
||||
|
||||
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||
mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
|
||||
expect(h.elements.length).toBe(2);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
|
@ -1,4 +1,8 @@
|
||||
# @excalidraw/math
|
||||
# @excalidraw/math - 2D Vector Graphics Math Library
|
||||
|
||||
The package contains a collection of (mostly) independent functions providing the mathematical basis for Excalidraw's rendering, hit detection, bounds checking and anything using math underneath.
|
||||
|
||||
The philosophy of the library is to be self-contained and therefore there is no dependency on any other package. It only contains pure functions. It also prefers analytical solutions vs numberical wherever possible. Since this library is used in a high performance context, we might chose to use a numerical approximation, even if an analytical solution is available to preserve performance.
|
||||
|
||||
## Install
|
||||
|
||||
|
3
packages/math/global.d.ts
vendored
3
packages/math/global.d.ts
vendored
@ -1,3 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/math",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/math/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@ -19,7 +19,7 @@
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"description": "Excalidraw math functions",
|
||||
"description": "Excalidraw math library for 2D vector graphics.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PRECISION } from "./utils";
|
||||
import { PRECISION } from "./constants";
|
||||
|
||||
import type {
|
||||
Degrees,
|
||||
|
57
packages/math/src/constants.ts
Normal file
57
packages/math/src/constants.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export const PRECISION = 10e-5;
|
||||
|
||||
// Legendre-Gauss abscissae (x values) and weights for n=24
|
||||
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
|
||||
export const LegendreGaussN24TValues = [
|
||||
-0.0640568928626056260850430826247450385909,
|
||||
0.0640568928626056260850430826247450385909,
|
||||
-0.1911188674736163091586398207570696318404,
|
||||
0.1911188674736163091586398207570696318404,
|
||||
-0.3150426796961633743867932913198102407864,
|
||||
0.3150426796961633743867932913198102407864,
|
||||
-0.4337935076260451384870842319133497124524,
|
||||
0.4337935076260451384870842319133497124524,
|
||||
-0.5454214713888395356583756172183723700107,
|
||||
0.5454214713888395356583756172183723700107,
|
||||
-0.6480936519369755692524957869107476266696,
|
||||
0.6480936519369755692524957869107476266696,
|
||||
-0.7401241915785543642438281030999784255232,
|
||||
0.7401241915785543642438281030999784255232,
|
||||
-0.8200019859739029219539498726697452080761,
|
||||
0.8200019859739029219539498726697452080761,
|
||||
-0.8864155270044010342131543419821967550873,
|
||||
0.8864155270044010342131543419821967550873,
|
||||
-0.9382745520027327585236490017087214496548,
|
||||
0.9382745520027327585236490017087214496548,
|
||||
-0.9747285559713094981983919930081690617411,
|
||||
0.9747285559713094981983919930081690617411,
|
||||
-0.9951872199970213601799974097007368118745,
|
||||
0.9951872199970213601799974097007368118745,
|
||||
];
|
||||
|
||||
export const LegendreGaussN24CValues = [
|
||||
0.1279381953467521569740561652246953718517,
|
||||
0.1279381953467521569740561652246953718517,
|
||||
0.1258374563468282961213753825111836887264,
|
||||
0.1258374563468282961213753825111836887264,
|
||||
0.121670472927803391204463153476262425607,
|
||||
0.121670472927803391204463153476262425607,
|
||||
0.1155056680537256013533444839067835598622,
|
||||
0.1155056680537256013533444839067835598622,
|
||||
0.1074442701159656347825773424466062227946,
|
||||
0.1074442701159656347825773424466062227946,
|
||||
0.0976186521041138882698806644642471544279,
|
||||
0.0976186521041138882698806644642471544279,
|
||||
0.086190161531953275917185202983742667185,
|
||||
0.086190161531953275917185202983742667185,
|
||||
0.0733464814110803057340336152531165181193,
|
||||
0.0733464814110803057340336152531165181193,
|
||||
0.0592985849154367807463677585001085845412,
|
||||
0.0592985849154367807463677585001085845412,
|
||||
0.0442774388174198061686027482113382288593,
|
||||
0.0442774388174198061686027482113382288593,
|
||||
0.0285313886289336631813078159518782864491,
|
||||
0.0285313886289336631813078159518782864491,
|
||||
0.0123412297999871995468056670700372915759,
|
||||
0.0123412297999871995468056670700372915759,
|
||||
];
|
@ -1,8 +1,7 @@
|
||||
import type { Bounds } from "@excalidraw/element";
|
||||
|
||||
import { isPoint, pointDistance, pointFrom } from "./point";
|
||||
import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
|
||||
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
|
||||
import { vector } from "./vector";
|
||||
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
|
||||
import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants";
|
||||
|
||||
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
||||
|
||||
@ -23,81 +22,6 @@ export function curve<Point extends GlobalPoint | LocalPoint>(
|
||||
return [a, b, c, d] as Curve<Point>;
|
||||
}
|
||||
|
||||
function gradient(
|
||||
f: (t: number, s: number) => number,
|
||||
t0: number,
|
||||
s0: number,
|
||||
delta: number = 1e-6,
|
||||
): number[] {
|
||||
return [
|
||||
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
|
||||
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
|
||||
];
|
||||
}
|
||||
|
||||
function solve(
|
||||
f: (t: number, s: number) => [number, number],
|
||||
t0: number,
|
||||
s0: number,
|
||||
tolerance: number = 1e-3,
|
||||
iterLimit: number = 10,
|
||||
): number[] | null {
|
||||
let error = Infinity;
|
||||
let iter = 0;
|
||||
|
||||
while (error >= tolerance) {
|
||||
if (iter >= iterLimit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const y0 = f(t0, s0);
|
||||
const jacobian = [
|
||||
gradient((t, s) => f(t, s)[0], t0, s0),
|
||||
gradient((t, s) => f(t, s)[1], t0, s0),
|
||||
];
|
||||
const b = [[-y0[0]], [-y0[1]]];
|
||||
const det =
|
||||
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
|
||||
|
||||
if (det === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iJ = [
|
||||
[jacobian[1][1] / det, -jacobian[0][1] / det],
|
||||
[-jacobian[1][0] / det, jacobian[0][0] / det],
|
||||
];
|
||||
const h = [
|
||||
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
|
||||
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
|
||||
];
|
||||
|
||||
t0 = t0 + h[0][0];
|
||||
s0 = s0 + h[1][0];
|
||||
|
||||
const [tErr, sErr] = f(t0, s0);
|
||||
error = Math.max(Math.abs(tErr), Math.abs(sErr));
|
||||
iter += 1;
|
||||
}
|
||||
|
||||
return [t0, s0];
|
||||
}
|
||||
|
||||
export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
t: number,
|
||||
) =>
|
||||
pointFrom<Point>(
|
||||
(1 - t) ** 3 * c[0][0] +
|
||||
3 * (1 - t) ** 2 * t * c[1][0] +
|
||||
3 * (1 - t) * t ** 2 * c[2][0] +
|
||||
t ** 3 * c[3][0],
|
||||
(1 - t) ** 3 * c[0][1] +
|
||||
3 * (1 - t) ** 2 * t * c[1][1] +
|
||||
3 * (1 - t) * t ** 2 * c[2][1] +
|
||||
t ** 3 * c[3][1],
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes the intersection between a cubic spline and a line segment.
|
||||
*/
|
||||
@ -105,12 +29,19 @@ export function curveIntersectLineSegment<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const bounds = curveBounds(c);
|
||||
const [p0, p1, p2, p3] = c;
|
||||
|
||||
if (
|
||||
rectangleIntersectLineSegment(
|
||||
rectangle(
|
||||
pointFrom(bounds[0], bounds[1]),
|
||||
pointFrom(bounds[2], bounds[3]),
|
||||
pointFrom(
|
||||
Math.min(p0[0], p1[0], p2[0], p3[0]),
|
||||
Math.min(p0[1], p1[1], p2[1], p3[1]),
|
||||
),
|
||||
pointFrom(
|
||||
Math.max(p0[0], p1[0], p2[0], p3[0]),
|
||||
Math.max(p0[1], p1[1], p2[1], p3[1]),
|
||||
),
|
||||
),
|
||||
l,
|
||||
).length === 0
|
||||
@ -295,11 +226,303 @@ export function curveTangent<Point extends GlobalPoint | LocalPoint>(
|
||||
);
|
||||
}
|
||||
|
||||
function curveBounds<Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
): Bounds {
|
||||
const [P0, P1, P2, P3] = c;
|
||||
const x = [P0[0], P1[0], P2[0], P3[0]];
|
||||
const y = [P0[1], P1[1], P2[1], P3[1]];
|
||||
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
|
||||
export function curveCatmullRomQuadraticApproxPoints(
|
||||
points: GlobalPoint[],
|
||||
tension = 0.5,
|
||||
) {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointSets: [GlobalPoint, GlobalPoint][] = [];
|
||||
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 cpX = p1[0] + ((p2[0] - p0[0]) * tension) / 2;
|
||||
const cpY = p1[1] + ((p2[1] - p0[1]) * tension) / 2;
|
||||
|
||||
pointSets.push([
|
||||
pointFrom<GlobalPoint>(cpX, cpY),
|
||||
pointFrom<GlobalPoint>(p2[0], p2[1]),
|
||||
]);
|
||||
}
|
||||
|
||||
return pointSets;
|
||||
}
|
||||
|
||||
export function curveCatmullRomCubicApproxPoints<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(points: Point[], tension = 0.5) {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointSets: Curve<Point>[] = [];
|
||||
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];
|
||||
const tangent1 = [(p2[0] - p0[0]) * tension, (p2[1] - p0[1]) * tension];
|
||||
const tangent2 = [(p3[0] - p1[0]) * tension, (p3[1] - p1[1]) * tension];
|
||||
const cp1x = p1[0] + tangent1[0] / 3;
|
||||
const cp1y = p1[1] + tangent1[1] / 3;
|
||||
const cp2x = p2[0] - tangent2[0] / 3;
|
||||
const cp2y = p2[1] - tangent2[1] / 3;
|
||||
|
||||
pointSets.push(
|
||||
curve(
|
||||
pointFrom(p1[0], p1[1]),
|
||||
pointFrom(cp1x, cp1y),
|
||||
pointFrom(cp2x, cp2y),
|
||||
pointFrom(p2[0], p2[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return pointSets;
|
||||
}
|
||||
|
||||
export function curveOffsetPoints(
|
||||
[p0, p1, p2, p3]: Curve<GlobalPoint>,
|
||||
offset: number,
|
||||
steps = 50,
|
||||
) {
|
||||
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, offset), point));
|
||||
}
|
||||
|
||||
return offsetPoints;
|
||||
}
|
||||
|
||||
export function offsetPointsForQuadraticBezier(
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
offsetDist: number,
|
||||
steps = 50,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation based on Legendre-Gauss quadrature for more accurate arc
|
||||
* length calculation.
|
||||
*
|
||||
* Reference: https://pomax.github.io/bezierinfo/#arclength
|
||||
*
|
||||
* @param c The curve to calculate the length of
|
||||
* @returns The approximated length of the curve
|
||||
*/
|
||||
export function curveLength<P extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<P>,
|
||||
): number {
|
||||
const z2 = 0.5;
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const t = z2 * LegendreGaussN24TValues[i] + z2;
|
||||
const derivativeVector = curveTangent(c, t);
|
||||
const magnitude = Math.sqrt(
|
||||
derivativeVector[0] * derivativeVector[0] +
|
||||
derivativeVector[1] * derivativeVector[1],
|
||||
);
|
||||
sum += LegendreGaussN24CValues[i] * magnitude;
|
||||
}
|
||||
|
||||
return z2 * sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the curve length from t=0 to t=parameter using the same
|
||||
* Legendre-Gauss quadrature method used in curveLength
|
||||
*
|
||||
* @param c The curve to calculate the partial length for
|
||||
* @param t The parameter value (0 to 1) to calculate length up to
|
||||
* @returns The length of the curve from beginning to parameter t
|
||||
*/
|
||||
export function curveLengthAtParameter<P extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<P>,
|
||||
t: number,
|
||||
): number {
|
||||
if (t <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (t >= 1) {
|
||||
return curveLength(c);
|
||||
}
|
||||
|
||||
// Scale and shift the integration interval from [0,t] to [-1,1]
|
||||
// which is what the Legendre-Gauss quadrature expects
|
||||
const z1 = t / 2;
|
||||
const z2 = t / 2;
|
||||
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const parameter = z1 * LegendreGaussN24TValues[i] + z2;
|
||||
const derivativeVector = curveTangent(c, parameter);
|
||||
const magnitude = Math.sqrt(
|
||||
derivativeVector[0] * derivativeVector[0] +
|
||||
derivativeVector[1] * derivativeVector[1],
|
||||
);
|
||||
sum += LegendreGaussN24CValues[i] * magnitude;
|
||||
}
|
||||
|
||||
return z1 * sum; // Scale the result back to the original interval
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the point at a specific percentage of a curve's total length
|
||||
* using binary search for improved efficiency and accuracy.
|
||||
*
|
||||
* @param c The curve to calculate point on
|
||||
* @param percent A value between 0 and 1 representing the percentage of the curve's length
|
||||
* @returns The point at the specified percentage of curve length
|
||||
*/
|
||||
export function curvePointAtLength<P extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<P>,
|
||||
percent: number,
|
||||
): P {
|
||||
if (percent <= 0) {
|
||||
return bezierEquation(c, 0);
|
||||
}
|
||||
|
||||
if (percent >= 1) {
|
||||
return bezierEquation(c, 1);
|
||||
}
|
||||
|
||||
const totalLength = curveLength(c);
|
||||
const targetLength = totalLength * percent;
|
||||
|
||||
// Binary search to find parameter t where length at t equals target length
|
||||
let tMin = 0;
|
||||
let tMax = 1;
|
||||
let t = percent; // Start with a reasonable guess (t = percent)
|
||||
let currentLength = 0;
|
||||
|
||||
// Tolerance for length comparison and iteration limit to avoid infinite loops
|
||||
const tolerance = totalLength * 0.0001;
|
||||
const maxIterations = 20;
|
||||
|
||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||
currentLength = curveLengthAtParameter(c, t);
|
||||
const error = Math.abs(currentLength - targetLength);
|
||||
|
||||
if (error < tolerance) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentLength < targetLength) {
|
||||
tMin = t;
|
||||
} else {
|
||||
tMax = t;
|
||||
}
|
||||
|
||||
t = (tMin + tMax) / 2;
|
||||
}
|
||||
|
||||
return bezierEquation(c, t);
|
||||
}
|
||||
|
||||
function bezierEquation<Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
t: number,
|
||||
) {
|
||||
return pointFrom<Point>(
|
||||
(1 - t) ** 3 * c[0][0] +
|
||||
3 * (1 - t) ** 2 * t * c[1][0] +
|
||||
3 * (1 - t) * t ** 2 * c[2][0] +
|
||||
t ** 3 * c[3][0],
|
||||
(1 - t) ** 3 * c[0][1] +
|
||||
3 * (1 - t) ** 2 * t * c[1][1] +
|
||||
3 * (1 - t) * t ** 2 * c[2][1] +
|
||||
t ** 3 * c[3][1],
|
||||
);
|
||||
}
|
||||
|
||||
function gradient(
|
||||
f: (t: number, s: number) => number,
|
||||
t0: number,
|
||||
s0: number,
|
||||
delta: number = 1e-6,
|
||||
): number[] {
|
||||
return [
|
||||
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
|
||||
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
|
||||
];
|
||||
}
|
||||
|
||||
function solve(
|
||||
f: (t: number, s: number) => [number, number],
|
||||
t0: number,
|
||||
s0: number,
|
||||
tolerance: number = 1e-3,
|
||||
iterLimit: number = 10,
|
||||
): number[] | null {
|
||||
let error = Infinity;
|
||||
let iter = 0;
|
||||
|
||||
while (error >= tolerance) {
|
||||
if (iter >= iterLimit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const y0 = f(t0, s0);
|
||||
const jacobian = [
|
||||
gradient((t, s) => f(t, s)[0], t0, s0),
|
||||
gradient((t, s) => f(t, s)[1], t0, s0),
|
||||
];
|
||||
const b = [[-y0[0]], [-y0[1]]];
|
||||
const det =
|
||||
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
|
||||
|
||||
if (det === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iJ = [
|
||||
[jacobian[1][1] / det, -jacobian[0][1] / det],
|
||||
[-jacobian[1][0] / det, jacobian[0][0] / det],
|
||||
];
|
||||
const h = [
|
||||
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
|
||||
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
|
||||
];
|
||||
|
||||
t0 = t0 + h[0][0];
|
||||
s0 = s0 + h[1][0];
|
||||
|
||||
const [tErr, sErr] = f(t0, s0);
|
||||
error = Math.max(Math.abs(tErr), Math.abs(sErr));
|
||||
iter += 1;
|
||||
}
|
||||
|
||||
return [t0, s0];
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
pointFromVector,
|
||||
pointsEqual,
|
||||
} from "./point";
|
||||
import { PRECISION } from "./utils";
|
||||
import { PRECISION } from "./constants";
|
||||
import {
|
||||
vector,
|
||||
vectorAdd,
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./angle";
|
||||
export * from "./constants";
|
||||
export * from "./curve";
|
||||
export * from "./line";
|
||||
export * from "./point";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { degreesToRadians } from "./angle";
|
||||
import { PRECISION } from "./utils";
|
||||
import { PRECISION } from "./constants";
|
||||
import { vectorFromPoint, vectorScale } from "./vector";
|
||||
|
||||
import type {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { pointsEqual } from "./point";
|
||||
import { lineSegment, pointOnLineSegment } from "./segment";
|
||||
import { PRECISION } from "./utils";
|
||||
import { PRECISION } from "./constants";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Polygon } from "./types";
|
||||
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
} from "./point";
|
||||
import { PRECISION } from "./utils";
|
||||
import { PRECISION } from "./constants";
|
||||
import {
|
||||
vectorAdd,
|
||||
vectorCross,
|
||||
|
@ -1,4 +1,4 @@
|
||||
export const PRECISION = 10e-5;
|
||||
import { PRECISION } from "./constants";
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
|
@ -21,13 +21,23 @@ export function vector(
|
||||
*
|
||||
* @param p The point to turn into a vector
|
||||
* @param origin The origin point in a given coordiante system
|
||||
* @returns The created vector from the point and the origin
|
||||
* @param threshold The threshold to consider the vector as 'undefined'
|
||||
* @param defaultValue The default value to return if the vector is 'undefined'
|
||||
* @returns The created vector from the point and the origin or default
|
||||
*/
|
||||
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
|
||||
p: Point,
|
||||
origin: Point = [0, 0] as Point,
|
||||
threshold?: number,
|
||||
defaultValue: Vector = [0, 1] as Vector,
|
||||
): Vector {
|
||||
return vector(p[0] - origin[0], p[1] - origin[1]);
|
||||
const vec = vector(p[0] - origin[0], p[1] - origin[1]);
|
||||
|
||||
if (threshold && vectorMagnitudeSq(vec) < threshold * threshold) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return vec;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -4,6 +4,9 @@ import {
|
||||
curve,
|
||||
curveClosestPoint,
|
||||
curveIntersectLineSegment,
|
||||
curveLength,
|
||||
curveLengthAtParameter,
|
||||
curvePointAtLength,
|
||||
curvePointDistance,
|
||||
} from "../src/curve";
|
||||
import { pointFrom } from "../src/point";
|
||||
@ -99,4 +102,45 @@ describe("Math curve", () => {
|
||||
expect(curvePointDistance(c, p)).toBeCloseTo(6.695873043213627);
|
||||
});
|
||||
});
|
||||
|
||||
describe("length", () => {
|
||||
it("can be determined", () => {
|
||||
const c = curve(
|
||||
pointFrom(-50, -50),
|
||||
pointFrom(10, -50),
|
||||
pointFrom(10, 50),
|
||||
pointFrom(50, 50),
|
||||
);
|
||||
|
||||
expect(curveLength(c)).toBeCloseTo(150.0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("point at given parameter", () => {
|
||||
it("can be determined", () => {
|
||||
const c = curve(
|
||||
pointFrom(-50, -50),
|
||||
pointFrom(10, -50),
|
||||
pointFrom(10, 50),
|
||||
pointFrom(50, 50),
|
||||
);
|
||||
|
||||
expect(curveLengthAtParameter(c, 0.5)).toBeCloseTo(80.83);
|
||||
});
|
||||
});
|
||||
|
||||
describe("point at given length", () => {
|
||||
it("can be determined", () => {
|
||||
const c = curve(
|
||||
pointFrom(-50, -50),
|
||||
pointFrom(10, -50),
|
||||
pointFrom(10, 50),
|
||||
pointFrom(50, 50),
|
||||
);
|
||||
|
||||
expect(curvePointAtLength(c, 0.5)).toEqual([
|
||||
4.802938740176614, -5.301185927237384,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -3,6 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
||||
|
@ -1,135 +0,0 @@
|
||||
import {
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
polygonIncludesPoint,
|
||||
pointOnLineSegment,
|
||||
pointOnPolygon,
|
||||
polygonFromPoints,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type Polygon,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
|
||||
import { pointInEllipse, pointOnEllipse } from "./shape";
|
||||
|
||||
import type { Polycurve, Polyline, GeometricShape } from "./shape";
|
||||
|
||||
// check if the given point is considered on the given shape's border
|
||||
export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
shape: GeometricShape<Point>,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
// get the distance from the given point to the given element
|
||||
// check if the distance is within the given epsilon range
|
||||
switch (shape.type) {
|
||||
case "polygon":
|
||||
return pointOnPolygon(point, shape.data, tolerance);
|
||||
case "ellipse":
|
||||
return pointOnEllipse(point, shape.data, tolerance);
|
||||
case "line":
|
||||
return pointOnLineSegment(point, shape.data, tolerance);
|
||||
case "polyline":
|
||||
return pointOnPolyline(point, shape.data, tolerance);
|
||||
case "curve":
|
||||
return pointOnCurve(point, shape.data, tolerance);
|
||||
case "polycurve":
|
||||
return pointOnPolycurve(point, shape.data, tolerance);
|
||||
default:
|
||||
throw Error(`shape ${shape} is not implemented`);
|
||||
}
|
||||
};
|
||||
|
||||
// check if the given point is considered inside the element's border
|
||||
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
shape: GeometricShape<Point>,
|
||||
) => {
|
||||
switch (shape.type) {
|
||||
case "polygon":
|
||||
return polygonIncludesPoint(point, shape.data);
|
||||
case "line":
|
||||
return false;
|
||||
case "curve":
|
||||
return false;
|
||||
case "ellipse":
|
||||
return pointInEllipse(point, shape.data);
|
||||
case "polyline": {
|
||||
const polygon = polygonFromPoints(shape.data.flat());
|
||||
return polygonIncludesPoint(point, polygon);
|
||||
}
|
||||
case "polycurve": {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
throw Error(`shape ${shape} is not implemented`);
|
||||
}
|
||||
};
|
||||
|
||||
// check if the given element is in the given bounds
|
||||
export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
bounds: Polygon<Point>,
|
||||
) => {
|
||||
return polygonIncludesPoint(point, bounds);
|
||||
};
|
||||
|
||||
const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
polycurve: Polycurve<Point>,
|
||||
tolerance: number,
|
||||
) => {
|
||||
return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
|
||||
};
|
||||
|
||||
const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
|
||||
curve: Curve<Point>,
|
||||
) => {
|
||||
const [p0, p1, p2, p3] = curve;
|
||||
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
||||
return (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 polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
|
||||
curve: Curve<Point>,
|
||||
segments = 10,
|
||||
): Polyline<Point> => {
|
||||
const equation = cubicBezierEquation(curve);
|
||||
let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
|
||||
const lineSegments: Polyline<Point> = [];
|
||||
let t = 0;
|
||||
const increment = 1 / segments;
|
||||
|
||||
for (let i = 0; i < segments; i++) {
|
||||
t += increment;
|
||||
if (t <= 1) {
|
||||
const nextPoint: Point = pointFrom(equation(t, 0), equation(t, 1));
|
||||
lineSegments.push(lineSegment(startingPoint, nextPoint));
|
||||
startingPoint = nextPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return lineSegments;
|
||||
};
|
||||
|
||||
export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
curve: Curve<Point>,
|
||||
threshold: number,
|
||||
) => {
|
||||
return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
|
||||
};
|
||||
|
||||
export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
polyline: Polyline<Point>,
|
||||
threshold = 10e-5,
|
||||
) => {
|
||||
return polyline.some((line) => pointOnLineSegment(point, line, threshold));
|
||||
};
|
@ -3,7 +3,6 @@
|
||||
exports[`exportToSvg > with default arguments 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
@ -62,7 +61,6 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "name",
|
||||
"newElement": null,
|
||||
|
@ -1,90 +0,0 @@
|
||||
import {
|
||||
curve,
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
lineSegmentRotate,
|
||||
pointFrom,
|
||||
pointRotateDegs,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import { pointOnCurve, pointOnPolyline } from "../src/collision";
|
||||
|
||||
import type { Polyline } from "../src/shape";
|
||||
|
||||
describe("point and curve", () => {
|
||||
const c: Curve<GlobalPoint> = curve(
|
||||
pointFrom(1.4, 1.65),
|
||||
pointFrom(1.9, 7.9),
|
||||
pointFrom(5.9, 1.65),
|
||||
pointFrom(6.44, 4.84),
|
||||
);
|
||||
|
||||
it("point on curve", () => {
|
||||
expect(pointOnCurve(c[0], c, 10e-5)).toBe(true);
|
||||
expect(pointOnCurve(c[3], c, 10e-5)).toBe(true);
|
||||
|
||||
expect(pointOnCurve(pointFrom(2, 4), c, 0.1)).toBe(true);
|
||||
expect(pointOnCurve(pointFrom(4, 4.4), c, 0.1)).toBe(true);
|
||||
expect(pointOnCurve(pointFrom(5.6, 3.85), c, 0.1)).toBe(true);
|
||||
|
||||
expect(pointOnCurve(pointFrom(5.6, 4), c, 0.1)).toBe(false);
|
||||
expect(pointOnCurve(c[1], c, 0.1)).toBe(false);
|
||||
expect(pointOnCurve(c[2], c, 0.1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("point and polylines", () => {
|
||||
const polyline: Polyline<GlobalPoint> = [
|
||||
lineSegment(pointFrom(1, 0), pointFrom(1, 2)),
|
||||
lineSegment(pointFrom(1, 2), pointFrom(2, 2)),
|
||||
lineSegment(pointFrom(2, 2), pointFrom(2, 1)),
|
||||
lineSegment(pointFrom(2, 1), pointFrom(3, 1)),
|
||||
];
|
||||
|
||||
it("point on the line", () => {
|
||||
expect(pointOnPolyline(pointFrom(1, 0), polyline)).toBe(true);
|
||||
expect(pointOnPolyline(pointFrom(1, 2), polyline)).toBe(true);
|
||||
expect(pointOnPolyline(pointFrom(2, 2), polyline)).toBe(true);
|
||||
expect(pointOnPolyline(pointFrom(2, 1), polyline)).toBe(true);
|
||||
expect(pointOnPolyline(pointFrom(3, 1), polyline)).toBe(true);
|
||||
|
||||
expect(pointOnPolyline(pointFrom(1, 1), polyline)).toBe(true);
|
||||
expect(pointOnPolyline(pointFrom(2, 1.5), polyline)).toBe(true);
|
||||
expect(pointOnPolyline(pointFrom(2.5, 1), polyline)).toBe(true);
|
||||
|
||||
expect(pointOnPolyline(pointFrom(0, 1), polyline)).toBe(false);
|
||||
expect(pointOnPolyline(pointFrom(2.1, 1.5), polyline)).toBe(false);
|
||||
});
|
||||
|
||||
it("point on the line with rotation", () => {
|
||||
const truePoints = [
|
||||
pointFrom(1, 0),
|
||||
pointFrom(1, 2),
|
||||
pointFrom(2, 2),
|
||||
pointFrom(2, 1),
|
||||
pointFrom(3, 1),
|
||||
];
|
||||
|
||||
truePoints.forEach((p) => {
|
||||
const rotation = (Math.random() * 360) as Degrees;
|
||||
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
|
||||
const rotatedPolyline = polyline.map((line) =>
|
||||
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
|
||||
);
|
||||
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
|
||||
});
|
||||
|
||||
const falsePoints = [pointFrom(0, 1), pointFrom(2.1, 1.5)];
|
||||
|
||||
falsePoints.forEach((p) => {
|
||||
const rotation = (Math.random() * 360) as Degrees;
|
||||
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
|
||||
const rotatedPolyline = polyline.map((line) =>
|
||||
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
|
||||
);
|
||||
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user