Merge branch 'excalidraw:master' into snap-to-shape

This commit is contained in:
Mathias Krafft 2025-04-07 15:45:29 +02:00 committed by GitHub
commit ac37d0a5be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3386 additions and 498 deletions

View file

@ -48,3 +48,6 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
# set to true in .env.development.local to disable the prevent unload dialog
VITE_APP_DISABLE_PREVENT_UNLOAD=

View file

@ -15,7 +15,8 @@
"scripts": {
"start": "vite",
"build": "vite build",
"build:preview": "yarn build && vite preview --port 5002",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
}
}

View file

@ -608,7 +608,13 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
)
) {
preventUnload(event);
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);

View file

@ -301,7 +301,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements);
preventUnload(event);
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
}
});

View file

@ -25,7 +25,10 @@ export default defineConfig(({ mode }) => {
alias: [
{
find: /^@excalidraw\/common$/,
replacement: path.resolve(__dirname, "../packages/common/src/index.ts"),
replacement: path.resolve(
__dirname,
"../packages/common/src/index.ts",
),
},
{
find: /^@excalidraw\/common\/(.*?)/,
@ -33,7 +36,10 @@ export default defineConfig(({ mode }) => {
},
{
find: /^@excalidraw\/element$/,
replacement: path.resolve(__dirname, "../packages/element/src/index.ts"),
replacement: path.resolve(
__dirname,
"../packages/element/src/index.ts",
),
},
{
find: /^@excalidraw\/element\/(.*?)/,
@ -41,7 +47,10 @@ export default defineConfig(({ mode }) => {
},
{
find: /^@excalidraw\/excalidraw$/,
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
},
{
find: /^@excalidraw\/excalidraw\/(.*?)/,
@ -57,7 +66,10 @@ export default defineConfig(({ mode }) => {
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(__dirname, "../packages/utils/src/index.ts"),
replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
@ -213,7 +225,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id:"excalidraw",
id: "excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",

View file

@ -2,6 +2,8 @@ import oc from "open-color";
import type { Merge } from "./utility-types";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,

View file

@ -419,6 +419,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
lasso: "lasso",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",

View file

@ -385,7 +385,7 @@ export const updateActiveTool = (
type: ToolType;
}
| { type: "custom"; customType: string }
) & { locked?: boolean }) & {
) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null;
},
): AppState["activeTool"] => {
@ -407,6 +407,7 @@ export const updateActiveTool = (
type: data.type,
customType: null,
locked: data.locked ?? appState.activeTool.locked,
fromSelection: data.fromSelection ?? false,
};
};

View file

@ -55,6 +55,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
isBindableElement,
isBindingElement,
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
@ -1422,7 +1423,7 @@ const getLinearElementEdgeCoors = (
);
};
export const fixBindingsAfterDuplication = (
export const fixDuplicatedBindingsAfterDuplication = (
newElements: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
duplicatedElementsMap: NonDeletedSceneElementsMap,
@ -1493,6 +1494,196 @@ export const fixBindingsAfterDuplication = (
}
};
const fixReversedBindingsForBindables = (
original: ExcalidrawBindableElement,
duplicate: ExcalidrawBindableElement,
originalElements: Map<string, ExcalidrawElement>,
elementsWithClones: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
original.boundElements?.forEach((binding, idx) => {
if (binding.type !== "arrow") {
return;
}
const oldArrow = elementsWithClones.find((el) => el.id === binding.id);
if (!isBindingElement(oldArrow)) {
return;
}
if (originalElements.has(binding.id)) {
// Linked arrow is in the selection, so find the duplicate pair
const newArrowId = oldIdToDuplicatedId.get(binding.id) ?? binding.id;
const newArrow = elementsWithClones.find(
(el) => el.id === newArrowId,
)! as ExcalidrawArrowElement;
mutateElement(newArrow, {
startBinding:
oldArrow.startBinding?.elementId === binding.id
? {
...oldArrow.startBinding,
elementId: duplicate.id,
}
: newArrow.startBinding,
endBinding:
oldArrow.endBinding?.elementId === binding.id
? {
...oldArrow.endBinding,
elementId: duplicate.id,
}
: newArrow.endBinding,
});
mutateElement(duplicate, {
boundElements: [
...(duplicate.boundElements ?? []).filter(
(el) => el.id !== binding.id && el.id !== newArrowId,
),
{
type: "arrow",
id: newArrowId,
},
],
});
} else {
// Linked arrow is outside the selection,
// so we move the binding to the duplicate
mutateElement(oldArrow, {
startBinding:
oldArrow.startBinding?.elementId === original.id
? {
...oldArrow.startBinding,
elementId: duplicate.id,
}
: oldArrow.startBinding,
endBinding:
oldArrow.endBinding?.elementId === original.id
? {
...oldArrow.endBinding,
elementId: duplicate.id,
}
: oldArrow.endBinding,
});
mutateElement(duplicate, {
boundElements: [
...(duplicate.boundElements ?? []),
{
type: "arrow",
id: oldArrow.id,
},
],
});
mutateElement(original, {
boundElements:
original.boundElements?.filter((_, i) => i !== idx) ?? null,
});
}
});
};
const fixReversedBindingsForArrows = (
original: ExcalidrawArrowElement,
duplicate: ExcalidrawArrowElement,
originalElements: Map<string, ExcalidrawElement>,
bindingProp: "startBinding" | "endBinding",
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
elementsWithClones: ExcalidrawElement[],
) => {
const oldBindableId = original[bindingProp]?.elementId;
if (oldBindableId) {
if (originalElements.has(oldBindableId)) {
// Linked element is in the selection
const newBindableId =
oldIdToDuplicatedId.get(oldBindableId) ?? oldBindableId;
const newBindable = elementsWithClones.find(
(el) => el.id === newBindableId,
) as ExcalidrawBindableElement;
mutateElement(duplicate, {
[bindingProp]: {
...original[bindingProp],
elementId: newBindableId,
},
});
mutateElement(newBindable, {
boundElements: [
...(newBindable.boundElements ?? []).filter(
(el) => el.id !== original.id && el.id !== duplicate.id,
),
{
id: duplicate.id,
type: "arrow",
},
],
});
} else {
// Linked element is outside the selection
const originalBindable = elementsWithClones.find(
(el) => el.id === oldBindableId,
);
if (originalBindable) {
mutateElement(duplicate, {
[bindingProp]: original[bindingProp],
});
mutateElement(original, {
[bindingProp]: null,
});
mutateElement(originalBindable, {
boundElements: [
...(originalBindable.boundElements?.filter(
(el) => el.id !== original.id,
) ?? []),
{
id: duplicate.id,
type: "arrow",
},
],
});
}
}
}
};
export const fixReversedBindings = (
originalElements: Map<string, ExcalidrawElement>,
elementsWithClones: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
for (const original of originalElements.values()) {
const duplicate = elementsWithClones.find(
(el) => el.id === oldIdToDuplicatedId.get(original.id),
)!;
if (isBindableElement(original) && isBindableElement(duplicate)) {
fixReversedBindingsForBindables(
original,
duplicate,
originalElements,
elementsWithClones,
oldIdToDuplicatedId,
);
} else if (isArrowElement(original) && isArrowElement(duplicate)) {
fixReversedBindingsForArrows(
original,
duplicate,
originalElements,
"startBinding",
oldIdToDuplicatedId,
elementsWithClones,
);
fixReversedBindingsForArrows(
original,
duplicate,
originalElements,
"endBinding",
oldIdToDuplicatedId,
elementsWithClones,
);
}
}
};
export const fixBindingsAfterDeletion = (
sceneElements: readonly ExcalidrawElement[],
deletedElements: readonly ExcalidrawElement[],

View file

@ -13,7 +13,10 @@ import {
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
Curve,
Degrees,
GlobalPoint,
LineSegment,
@ -37,6 +40,13 @@ import {
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -45,6 +55,8 @@ import type {
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@ -254,50 +266,82 @@ export const getElementAbsoluteCoords = (
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
/**
* Given an element, return the line segments that make up the element.
*
* Uses helpers from /math
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center = pointFrom<GlobalPoint>(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = [];
if (shape.type === "polycurve") {
const curves = shape.data;
const points = curves
.map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0;
while (i < element.points.length - 1) {
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads(
pointFrom(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
return segments;
} else if (shape.type === "polyline") {
return shape.data as LineSegment<GlobalPoint>[];
} else if (_isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (element.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(element);
const cornerSegments = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (shape.type === "polygon") {
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (container && isLinearElement(container)) {
const segments: LineSegment<GlobalPoint>[] = [
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
];
return segments;
}
}
const points = shape.data as GlobalPoint[];
const segments: LineSegment<GlobalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
return segments;
} else if (shape.type === "ellipse") {
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
}
const [nw, ne, sw, se, n, s, w, e] = (
const [nw, ne, sw, se, , , w, e] = (
[
[x1, y1],
[x2, y1],
@ -310,28 +354,6 @@ export const getElementLineSegments = (
] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [
lineSegment(nw, ne),
lineSegment(sw, se),
@ -344,6 +366,94 @@ export const getElementLineSegments = (
];
};
const _isRectanguloidElement = (
element: ExcalidrawElement,
): element is ExcalidrawRectanguloidElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
const getRotatedSides = (
sides: LineSegment<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
const points: GlobalPoint[] = [];
const n = 90;
const deltaT = (Math.PI * 2) / n;
for (let i = 0; i < n; i++) {
const t = i * deltaT;
const x = center[0] + a * Math.cos(t);
const y = center[1] + b * Math.sin(t);
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
}
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
segments.push(lineSegment(points[points.length - 1], points[0]));
return segments;
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*

View file

@ -36,7 +36,10 @@ import {
import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixBindingsAfterDuplication } from "./binding";
import {
fixDuplicatedBindingsAfterDuplication,
fixReversedBindings,
} from "./binding";
import type {
ElementsMap,
@ -381,12 +384,20 @@ export const duplicateElements = (
// ---------------------------------------------------------------------------
fixBindingsAfterDuplication(
fixDuplicatedBindingsAfterDuplication(
newElements,
oldIdToDuplicatedId,
duplicatedElementsMap as NonDeletedSceneElementsMap,
);
if (reverseOrder) {
fixReversedBindings(
_idsOfElementsToDuplicate,
elementsWithClones,
oldIdToDuplicatedId,
);
}
bindElementsToFramesAfterDuplication(
elementsWithClones,
oldElements,

View file

@ -1,11 +1,15 @@
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
import {
normalizeRadians,
pointFrom,
pointFromVector,
pointRotateRads,
pointScaleFromOrigin,
radiansToDegrees,
pointsEqual,
triangleIncludesPoint,
vectorCross,
vectorFromPoint,
vectorScale,
} from "@excalidraw/math";
import type {
@ -13,7 +17,6 @@ import type {
GlobalPoint,
Triangle,
Vector,
Radians,
} from "@excalidraw/math";
import { getCenterForBounds, type Bounds } from "./bounds";
@ -26,24 +29,6 @@ export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
a: Point,
b: Point,
) => {
const angle = radiansToDegrees(
normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians),
);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
return HEADING_RIGHT;
} else if (angle >= 135 && angle < 225) {
return HEADING_DOWN;
}
return HEADING_LEFT;
};
export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec;
const absX = Math.abs(x);
@ -76,6 +61,165 @@ export const headingIsHorizontal = (a: Heading) =>
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
const headingForPointFromDiamondElement = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<GlobalPoint>,
): Heading => {
const midPoint = getCenterForBounds(aabb);
if (isDevEnv() || isTestEnv()) {
invariant(
element.width > 0 && element.height > 0,
"Diamond element has no width or height",
);
invariant(
!pointsEqual(midPoint, point),
"The point is too close to the element mid point to determine heading",
);
}
const SHRINK = 0.95; // Rounded elements tolerance
const top = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const right = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const bottom = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const left = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
// Corners
if (
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
0 &&
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
) {
return headingForPoint(top, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, right),
vectorFromPoint(right, bottom),
) <= 0 &&
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
) {
return headingForPoint(right, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, left),
) <= 0 &&
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, right),
) > 0
) {
return headingForPoint(bottom, midPoint);
} else if (
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
0 &&
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
) {
return headingForPoint(left, midPoint);
}
// Sides
if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(top, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) > 0
) {
const p = element.width > element.height ? top : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(left, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : left;
return headingForPoint(p, midPoint);
}
const p = element.width > element.height ? top : left;
return headingForPoint(p, midPoint);
};
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
@ -89,74 +233,7 @@ export const headingForPointFromElement = <Point extends GlobalPoint>(
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (p[0] < element.x) {
return HEADING_LEFT;
} else if (p[1] < element.y) {
return HEADING_UP;
} else if (p[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (p[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x + element.width, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x + element.width / 2, element.y + element.height),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
if (
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(top, right);
} else if (
triangleIncludesPoint<Point>(
[right, bottom, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(right, bottom);
} else if (
triangleIncludesPoint<Point>(
[bottom, left, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
return headingForPointFromDiamondElement(element, aabb, p);
}
const topLeft = pointScaleFromOrigin(

View file

@ -133,6 +133,7 @@ export class LinearElementEditor {
};
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element);
}
this.selectedPointsIndices = null;

View file

@ -14,6 +14,7 @@ export const showSelectedShapeActions = (
((appState.activeTool.type !== "custom" &&
(appState.editingTextElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "lasso" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||

View file

@ -14,7 +14,7 @@ import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
@ -699,4 +699,34 @@ describe("duplication z-order", () => {
{ id: text.id, containerId: arrow.id, selected: true },
]);
});
it("reverse-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => {
const rect = UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -100,
y: 50,
width: 95,
height: 0,
});
expect(arrow.endBinding?.elementId).toBe(rect.id);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(5, 5);
mouse.up(15, 15);
});
expect(window.h.elements).toHaveLength(3);
const newRect = window.h.elements[0];
expect(arrow.endBinding?.elementId).toBe(newRect.id);
expect(newRect.boundElements?.[0]?.id).toBe(arrow.id);
});
});

View file

@ -226,8 +226,8 @@ export const actionWrapTextInContainer = register({
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
const someTextElements = selectedElements.some((el) => isTextElement(el));
return selectedElements.length > 0 && someTextElements;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);

View file

@ -29,6 +29,7 @@ import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
import {
handIcon,
LassoIcon,
MoonIcon,
SunIcon,
TrashIcon,
@ -52,7 +53,6 @@ import type { AppState, Offsets } from "../types";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
paletteName: "Change canvas background color",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
@ -90,7 +90,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
label: "labels.clearCanvas",
paletteName: "Clear canvas",
icon: TrashIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
@ -525,10 +524,42 @@ export const actionToggleEraserTool = register({
keyTest: (event) => event.key === KEYS.E,
});
export const actionToggleLassoTool = register({
name: "toggleLassoTool",
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (appState.activeTool.type !== "lasso") {
activeTool = updateActiveTool(appState, {
type: "lasso",
fromSelection: false,
});
setCursor(app.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
});
export const actionToggleHandTool = register({
name: "toggleHandTool",
label: "toolBar.hand",
paletteName: "Toggle hand tool",
trackEvent: { category: "toolbar" },
icon: handIcon,
viewMode: false,

View file

@ -90,7 +90,6 @@ export const actionToggleElementLock = register({
export const actionUnlockAllElements = register({
name: "unlockAllElements",
paletteName: "Unlock all elements",
trackEvent: { category: "canvas" },
viewMode: false,
icon: UnlockedIcon,

View file

@ -9,7 +9,6 @@ export const actionToggleStats = register({
name: "stats",
label: "stats.fullTitle",
icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],

View file

@ -8,7 +8,6 @@ import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
label: "labels.viewMode",
paletteName: "Toggle view mode",
icon: eyeIcon,
viewMode: true,
trackEvent: {

View file

@ -9,7 +9,6 @@ export const actionToggleZenMode = register({
name: "zenMode",
label: "buttons.zenMode",
icon: coffeeIcon,
paletteName: "Toggle zen mode",
viewMode: true,
trackEvent: {
category: "canvas",

View file

@ -140,7 +140,8 @@ export type ActionName =
| "copyElementLink"
| "linkToElement"
| "cropEditor"
| "wrapSelectionInFrame";
| "wrapSelectionInFrame"
| "toggleLassoTool";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View file

@ -23,6 +23,8 @@ export interface Trail {
export interface AnimatedTrailOptions {
fill: (trail: AnimatedTrail) => string;
stroke?: (trail: AnimatedTrail) => string;
animateTrail?: boolean;
}
export class AnimatedTrail implements Trail {
@ -31,16 +33,28 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement;
private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
constructor(
private animationFrameHandler: AnimationFrameHandler,
private app: App,
protected app: App,
private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
// TODO: make this configurable
this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset");
this.trailElement.setAttribute("stroke-dasharray", "7 7");
this.trailElement.setAttribute("stroke-dashoffset", "10");
this.trailAnimation.setAttribute("from", "0");
this.trailAnimation.setAttribute("to", `-14`);
this.trailAnimation.setAttribute("dur", "0.3s");
this.trailElement.appendChild(this.trailAnimation);
}
}
get hasCurrentTrail() {
@ -104,8 +118,23 @@ export class AnimatedTrail implements Trail {
}
}
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() {
this.pastTrails = [];
this.start();
if (this.trailAnimation) {
this.trailAnimation.setAttribute("begin", "indefinite");
this.trailAnimation.setAttribute("repeatCount", "indefinite");
}
}
private onFrame() {
@ -132,14 +161,25 @@ export class AnimatedTrail implements Trail {
const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
if (this.trailAnimation) {
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
this.trailElement.setAttribute(
"stroke",
(this.options.stroke ?? (() => "black"))(this),
);
} else {
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
}
}
private drawTrail(trail: LaserPointer, state: AppState): string {
const stroke = trail
const _stroke = trail
.getStrokeOutline(trail.options.size / state.zoom.value)
.map(([x, y]) => {
const result = sceneCoordsToViewportCoords(
@ -150,6 +190,14 @@ export class AnimatedTrail implements Trail {
return [result.x, result.y];
});
const stroke = this.trailAnimation
? _stroke.slice(
// slicing from 6th point to get rid of the initial notch type of thing
Math.min(_stroke.length, 6),
_stroke.length / 2,
)
: _stroke;
return getSvgPathFromStroke(stroke, true);
}
}

View file

@ -52,6 +52,7 @@ export const getDefaultAppState = (): Omit<
type: "selection",
customType: null,
locked: DEFAULT_ELEMENT_PROPS.locked,
fromSelection: false,
lastActiveTool: null,
},
penMode: false,

View file

@ -62,6 +62,7 @@ import {
mermaidLogoIcon,
laserPointerToolIcon,
MagicIcon,
LassoIcon,
} from "./icons";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
@ -83,7 +84,6 @@ export const canChangeStrokeColor = (
return (
(hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
@ -298,6 +298,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
@ -319,6 +321,7 @@ export const ShapesSwitcher = ({
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
@ -336,6 +339,14 @@ export const ShapesSwitcher = ({
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
@ -361,6 +372,7 @@ export const ShapesSwitcher = ({
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
lassoToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
@ -369,7 +381,15 @@ export const ShapesSwitcher = ({
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
{frameToolSelected
? frameToolIcon
: embeddableToolSelected
? EmbedIcon
: laserToolSelected && !app.props.isCollaborating
? laserPointerToolIcon
: lassoToolSelected
? LassoIcon
: extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
@ -402,6 +422,14 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>

View file

@ -100,6 +100,7 @@ import {
isShallowEqual,
arrayToMap,
type EXPORT_IMAGE_TYPES,
randomInteger,
} from "@excalidraw/common";
import {
@ -462,6 +463,8 @@ import { isOverScrollBars } from "../scene/scrollbars";
import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@ -693,6 +696,7 @@ class App extends React.Component<AppProps, AppState> {
? "rgba(0, 0, 0, 0.2)"
: "rgba(255, 255, 255, 0.2)",
});
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter<
[
@ -1671,7 +1675,11 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[this.laserTrails, this.eraserTrail]}
trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
@ -4631,7 +4639,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.openDialog?.name === "elementLinkSelector"
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.activeTool.type === "selection") {
} else if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
resetCursor(this.interactiveCanvas);
} else {
setCursorForShape(this.interactiveCanvas, this.state);
@ -4739,7 +4750,8 @@ class App extends React.Component<AppProps, AppState> {
}
)
| { type: "custom"; customType: string }
) & { locked?: boolean },
) & { locked?: boolean; fromSelection?: boolean },
keepSelection = false,
) => {
if (!this.isToolSupported(tool.type)) {
console.warn(
@ -4781,7 +4793,21 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement();
}
if (nextActiveTool.type !== "selection") {
if (nextActiveTool.type === "lasso") {
return {
...prevState,
activeTool: nextActiveTool,
...(keepSelection
? {}
: {
selectedElementIds: makeNextSelectedElementIds({}, prevState),
selectedGroupIds: makeNextSelectedElementIds({}, prevState),
editingGroupId: null,
multiElement: null,
}),
...commonResets,
};
} else if (nextActiveTool.type !== "selection") {
return {
...prevState,
activeTool: nextActiveTool,
@ -6604,6 +6630,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.penMode ||
event.pointerType !== "touch" ||
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso" ||
this.state.activeTool.type === "text" ||
this.state.activeTool.type === "image";
@ -6611,7 +6638,13 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (this.state.activeTool.type === "text") {
if (this.state.activeTool.type === "lasso") {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
} else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState);
} else if (
this.state.activeTool.type === "arrow" ||
@ -7068,7 +7101,10 @@ class App extends React.Component<AppProps, AppState> {
}
private clearSelectionIfNotUsingSelection = (): void => {
if (this.state.activeTool.type !== "selection") {
if (
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso"
) {
this.setState({
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
@ -8268,7 +8304,8 @@ class App extends React.Component<AppProps, AppState> {
if (
(hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor
!isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso"
) {
const selectedElements = this.scene.getSelectedElements(this.state);
@ -8486,20 +8523,26 @@ class App extends React.Component<AppProps, AppState> {
});
if (
hitElement &&
// hit element may not end up being selected
// if we're alt-dragging a common bounding box
// over the hit element
pointerDownState.hit.wasAddedToSelection &&
!selectedElements.find((el) => el.id === hitElement.id)
) {
selectedElements.push(hitElement);
}
const idsOfElementsToDuplicate = new Map(
selectedElements.map((el) => [el.id, el]),
);
const { newElements: clonedElements, elementsWithClones } =
duplicateElements({
type: "in-place",
elements,
appState: this.state,
randomizeSeed: true,
idsOfElementsToDuplicate: new Map(
selectedElements.map((el) => [el.id, el]),
),
idsOfElementsToDuplicate,
overrides: (el) => {
const origEl = pointerDownState.originalElements.get(el.id);
@ -8507,6 +8550,7 @@ class App extends React.Component<AppProps, AppState> {
return {
x: origEl.x,
y: origEl.y,
seed: origEl.seed,
};
}
@ -8526,7 +8570,14 @@ class App extends React.Component<AppProps, AppState> {
const nextSceneElements = syncMovedIndices(
mappedNewSceneElements || elementsWithClones,
arrayToMap(clonedElements),
);
).map((el) => {
if (idsOfElementsToDuplicate.has(el.id)) {
return newElementWith(el, {
seed: randomInteger(),
});
}
return el;
});
this.scene.replaceAllElements(nextSceneElements);
this.maybeCacheVisibleGaps(event, selectedElements, true);
@ -8540,7 +8591,37 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectionElement) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event);
if (event.altKey) {
this.setActiveTool(
{ type: "lasso", fromSelection: true },
event.shiftKey,
);
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
this.setAppState({
selectionElement: null,
});
} else {
this.maybeDragNewGenericElement(pointerDownState, event);
}
} else if (this.state.activeTool.type === "lasso") {
if (!event.altKey && this.state.activeTool.fromSelection) {
this.setActiveTool({ type: "selection" });
this.createGenericElementOnPointerDown("selection", pointerDownState);
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event);
this.lassoTrail.endPath();
} else {
this.lassoTrail.addPointToPath(
pointerCoords.x,
pointerCoords.y,
event.shiftKey,
);
}
} else {
// It is very important to read this.state within each move event,
// otherwise we would read a stale one!
@ -8795,6 +8876,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null,
}));
// just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath();
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
@ -9539,6 +9622,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (
// do not clear selection if lasso is active
this.state.activeTool.type !== "lasso" &&
// not elbow midpoint dragged
!(hitElement && isElbowArrow(hitElement)) &&
// not dragged
@ -9637,7 +9722,13 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw") {
if (
!activeTool.locked &&
activeTool.type !== "freedraw" &&
(activeTool.type !== "lasso" ||
// if lasso is turned on but from selection => reset to selection
(activeTool.type === "lasso" && activeTool.fromSelection))
) {
resetCursor(this.interactiveCanvas);
this.setState({
newElement: null,
@ -10492,7 +10583,7 @@ class App extends React.Component<AppProps, AppState> {
width: distance(pointerDownState.origin.x, pointerCoords.x),
height: distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
shouldResizeFromCenter: false,
zoom: this.state.zoom.value,
informMutation,
});

View file

@ -27,16 +27,22 @@
.color-picker__top-picks {
display: flex;
justify-content: space-between;
align-items: center;
}
.color-picker__button {
--radius: 0.25rem;
--radius: 4px;
--size: 1.375rem;
&.has-outline {
box-shadow: inset 0 0 0 1px #d9d9d9;
}
padding: 0;
margin: 0;
width: 1.35rem;
height: 1.35rem;
border: 1px solid var(--color-gray-30);
width: var(--size);
height: var(--size);
border: 0;
border-radius: var(--radius);
filter: var(--theme-filter);
background-color: var(--swatch-color);
@ -45,16 +51,20 @@
font-family: inherit;
box-sizing: border-box;
&:hover {
&:hover:not(.active):not(.color-picker__button--large) {
transform: scale(1.075);
}
&:hover:not(.active).color-picker__button--large {
&::after {
content: "";
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
box-shadow: 0 0 0 1px var(--color-gray-30);
border-radius: calc(var(--radius) + 1px);
border-radius: var(--radius);
filter: var(--theme-filter);
}
}
@ -62,13 +72,14 @@
&.active {
.color-picker__button-outline {
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
--offset: -1px;
top: var(--offset);
left: var(--offset);
right: var(--offset);
bottom: var(--offset);
box-shadow: 0 0 0 1px var(--color-primary-darkest);
z-index: 1; // due hover state so this has preference
border-radius: calc(var(--radius) + 1px);
border-radius: var(--radius);
filter: var(--theme-filter);
}
}
@ -123,10 +134,11 @@
.color-picker__button__hotkey-label {
position: absolute;
right: 4px;
bottom: 4px;
right: 5px;
bottom: 3px;
filter: none;
font-size: 11px;
font-weight: 500;
}
.color-picker {

View file

@ -2,7 +2,11 @@ import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import { useRef } from "react";
import { COLOR_PALETTE, isTransparent } from "@excalidraw/common";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE,
isTransparent,
} from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
@ -19,7 +23,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils";
import "./ColorPicker.scss";
@ -190,6 +194,7 @@ const ColorPickerTrigger = ({
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
})}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}

View file

@ -40,7 +40,7 @@ export const CustomColorList = ({
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
"color-picker__button color-picker__button--large has-outline",
{
active: color === c,
"is-transparent": c === "transparent" || !c,
@ -56,7 +56,7 @@ export const CustomColorList = ({
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
<HotkeyLabel color={c} keyLabel={i + 1} />
</button>
);
})}

View file

@ -1,24 +1,22 @@
import React from "react";
import { getContrastYIQ } from "./colorPickerUtils";
import { isColorDark } from "./colorPickerUtils";
interface HotkeyLabelProps {
color: string;
keyLabel: string | number;
isCustomColor?: boolean;
isShade?: boolean;
}
const HotkeyLabel = ({
color,
keyLabel,
isCustomColor = false,
isShade = false,
}: HotkeyLabelProps) => {
return (
<div
className="color-picker__button__hotkey-label"
style={{
color: getContrastYIQ(color, isCustomColor),
color: isColorDark(color) ? "#fff" : "#000",
}}
>
{isShade && "⇧"}

View file

@ -65,7 +65,7 @@ const PickerColorList = ({
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
"color-picker__button color-picker__button--large has-outline",
{
active: colorObj?.colorName === key,
"is-transparent": color === "transparent" || !color,

View file

@ -55,7 +55,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
key={i}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
"color-picker__button color-picker__button--large has-outline",
{ active: i === shade },
)}
aria-label="Shade"

View file

@ -1,11 +1,14 @@
import clsx from "clsx";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
} from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps {
@ -51,6 +54,10 @@ export const TopPicks = ({
className={clsx("color-picker__button", {
active: color === activeColor,
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(
color,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
),
})}
style={{ "--swatch-color": color }}
key={color}

View file

@ -93,19 +93,42 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number) => {
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 160 ? "black" : "white";
return yiq;
};
// inspiration from https://stackoverflow.com/a/11868398
export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
if (isCustomColor) {
const style = new Option().style;
style.color = bgHex;
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (style.color) {
const rgb = style.color
if (color === "transparent") {
return false;
}
// a string color (white etc) or any other format -> convert to rgb by way
// of creating a DOM node and retrieving the computeStyle
if (!color.startsWith("#")) {
const node = document.createElement("div");
node.style.color = color;
if (node.style.color) {
// making invisible so document doesn't reflow (hopefully).
// display=none works too, but supposedly not in all browsers
node.style.position = "absolute";
node.style.visibility = "hidden";
node.style.width = "0";
node.style.height = "0";
// needs to be in DOM else browser won't compute the style
document.body.appendChild(node);
const computedColor = getComputedStyle(node).color;
document.body.removeChild(node);
// computed style is in rgb() format
const rgb = computedColor
.replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "")
.replace(/\s/g, "")
@ -114,20 +137,17 @@ export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]);
return calculateContrast(r, g, b);
return calculateContrast(r, g, b) < threshold;
}
// invalid color -> assume it default to black
return true;
}
// TODO: ? is this wanted?
if (bgHex === "transparent") {
return "black";
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const r = parseInt(bgHex.substring(1, 3), 16);
const g = parseInt(bgHex.substring(3, 5), 16);
const b = parseInt(bgHex.substring(5, 7), 16);
return calculateContrast(r, g, b);
return calculateContrast(r, g, b) < threshold;
};
export type ColorPickerType =

View file

@ -315,6 +315,7 @@ function CommandPaletteInner({
const toolCommands: CommandPaletteItem[] = [
actionManager.actions.toggleHandTool,
actionManager.actions.setFrameAsActiveTool,
actionManager.actions.toggleLassoTool,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
const editorCommands: CommandPaletteItem[] = [

View file

@ -120,7 +120,7 @@ const getHints = ({
!appState.editingTextElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
return [t("hints.deepBoxSelect")];
}
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
@ -128,7 +128,7 @@ const getHints = ({
}
if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
return [t("hints.canvasPanning")];
}
if (selectedElements.length === 1) {

View file

@ -6,7 +6,7 @@
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 30px;
padding-bottom: 25px;
}
.range-input {

View file

@ -2,10 +2,12 @@
.drag-input-container {
display: flex;
width: 100%;
border-radius: var(--border-radius-lg);
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-md);
background: transparent;
}
}
@ -16,24 +18,14 @@
.drag-input-label {
flex-shrink: 0;
border: 1px solid var(--default-border-color);
border-right: 0;
padding: 0 0.5rem 0 0.75rem;
border: 0;
padding: 0 0.5rem 0 0.25rem;
min-width: 1rem;
width: 1.5rem;
height: 2rem;
box-sizing: border-box;
box-sizing: content-box;
color: var(--popup-text-color);
:root[dir="ltr"] & {
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
display: flex;
align-items: center;
justify-content: center;
@ -51,20 +43,8 @@
border: 0;
outline: none;
height: 2rem;
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;

View file

@ -41,6 +41,10 @@
div + div {
text-align: right;
}
&:empty {
display: none;
}
}
&__row--heading {

View file

@ -289,7 +289,11 @@ export const StatsInner = memo(
</StatsRow>
)}
<StatsRow heading data-testid="stats-element-type">
<StatsRow
heading
data-testid="stats-element-type"
style={{ margin: "0.3125rem 0" }}
>
{appState.croppingElementId
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}

View file

@ -87,34 +87,36 @@ const StaticCanvas = (props: StaticCanvasProps) => {
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
};
const getRelevantAppStateProps = (
appState: AppState,
): StaticCanvasAppState => ({
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
});
const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
const relevantAppStateProps = {
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
};
return relevantAppStateProps;
};
const areEqual = (
prevProps: StaticCanvasProps,

View file

@ -274,6 +274,21 @@ export const SelectionIcon = createIcon(
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
export const LassoIcon = createIcon(
<g
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
>
<path d="M4.028 13.252c-.657 -.972 -1.028 -2.078 -1.028 -3.252c0 -3.866 4.03 -7 9 -7s9 3.134 9 7s-4.03 7 -9 7c-1.913 0 -3.686 -.464 -5.144 -1.255" />
<path d="M5 15m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 17c0 1.42 .316 2.805 1 4" />
</g>,
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
// tabler-icons: square
export const RectangleIcon = createIcon(
<g strokeWidth="1.5">
@ -406,7 +421,7 @@ export const TrashIcon = createIcon(
);
export const EmbedIcon = createIcon(
<g strokeWidth="1.25">
<g strokeWidth="1.5">
<polyline points="12 16 18 10 12 4" />
<polyline points="8 4 2 10 8 16" />
</g>,

View file

@ -148,7 +148,7 @@
--border-radius-lg: 0.5rem;
--color-surface-high: #f1f0ff;
--color-surface-mid: #f2f2f7;
--color-surface-mid: #f6f6f9;
--color-surface-low: #ececf4;
--color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f;
@ -252,7 +252,7 @@
--color-logo-text: #e2dfff;
--color-surface-high: hsl(245, 10%, 21%);
--color-surface-high: #2e2d39;
--color-surface-low: hsl(240, 8%, 15%);
--color-surface-mid: hsl(240 6% 10%);
--color-surface-lowest: hsl(0, 0%, 7%);

View file

@ -104,12 +104,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0.5,
0.5,
0,
0,
],
[
394.5,
34.5,
394,
34,
],
],
"roughness": 1,
@ -129,8 +129,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"version": 4,
"versionNonce": Any<Number>,
"width": 395,
"x": 247,
"y": 420,
"x": 247.5,
"y": 420.5,
}
`;
@ -160,11 +160,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
399.5,
399,
0,
],
],
@ -185,7 +185,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"version": 4,
"versionNonce": Any<Number>,
"width": 400,
"x": 227,
"x": 227.5,
"y": 450,
}
`;
@ -350,11 +350,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -375,7 +375,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
"x": 255.5,
"y": 239,
}
`;
@ -452,11 +452,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -477,7 +477,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
"x": 255.5,
"y": 239,
}
`;
@ -628,11 +628,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -653,7 +653,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
"x": 255.5,
"y": 239,
}
`;
@ -845,11 +845,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"x": 100.5,
"y": 20,
}
`;
@ -893,11 +893,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -914,7 +914,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 450,
"x": 450.5,
"y": 20,
}
`;
@ -1490,11 +1490,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
272.485,
271.985,
0,
],
],
@ -1517,7 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"version": 4,
"versionNonce": Any<Number>,
"width": 272.985,
"x": 111.262,
"x": 111.762,
"y": 57,
}
`;
@ -1862,11 +1862,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -1883,7 +1883,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"x": 100.5,
"y": 100,
}
`;
@ -1915,11 +1915,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -1936,7 +1936,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"x": 100.5,
"y": 200,
}
`;
@ -1968,11 +1968,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -1989,7 +1989,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"x": 100.5,
"y": 300,
}
`;
@ -2021,11 +2021,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
99,
0,
],
],
@ -2042,7 +2042,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"x": 100.5,
"y": 400,
}
`;

View file

@ -86,6 +86,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean
> = {
selection: true,
lasso: true,
text: true,
rectangle: true,
diamond: true,
@ -221,7 +222,7 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData;
}
return {
const ret = {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
@ -230,6 +231,12 @@ const restoreElementWithProperties = <
...getNormalizedDimensions(base),
...extra,
} as unknown as T;
// strip legacy props (migrated in previous steps)
delete ret.strokeSharpness;
delete ret.boundElementIds;
return ret;
};
const restoreElement = (

View file

@ -427,7 +427,7 @@ describe("Test Transform", () => {
const [arrow, text, rectangle, ellipse] = excalidrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
x: 255.5,
y: 239,
boundElements: [{ id: text.id, type: "text" }],
startBinding: {
@ -512,7 +512,7 @@ describe("Test Transform", () => {
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
x: 255.5,
y: 239,
boundElements: [{ id: text1.id, type: "text" }],
startBinding: {
@ -730,7 +730,7 @@ describe("Test Transform", () => {
const [, , arrow, text] = excalidrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
x: 255.5,
y: 239,
boundElements: [
{

View file

@ -36,6 +36,8 @@ import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
import { redrawTextBoundingBox } from "@excalidraw/element/textElement";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import type { ElementConstructorOpts } from "@excalidraw/element/newElement";
import type {
@ -463,7 +465,13 @@ const bindLinearElementToElement = (
newPoints[endPointIndex][1] += delta;
}
Object.assign(linearElement, { points: newPoints });
Object.assign(
linearElement,
LinearElementEditor.getNormalizedPoints({
...linearElement,
points: newPoints,
}),
);
return {
linearElement,

View file

@ -0,0 +1,201 @@
import {
type GlobalPoint,
type LineSegment,
pointFrom,
} from "@excalidraw/math";
import { getElementLineSegments } from "@excalidraw/element/bounds";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
import { getFrameChildren } from "@excalidraw/element/frame";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { getContainerElement } from "@excalidraw/element/textElement";
import { arrayToMap, easeOut } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { type AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils";
import type App from "../components/App";
export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
private elementsSegments: Map<string, LineSegment<GlobalPoint>[]> | null =
null;
private keepPreviousSelection: boolean = false;
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, {
animateTrail: true,
streamline: 0.4,
sizeMapping: (c) => {
const DECAY_TIME = Infinity;
const DECAY_LENGTH = 5000;
const t = Math.max(
0,
1 - (performance.now() - c.pressure) / DECAY_TIME,
);
const l =
(DECAY_LENGTH -
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
DECAY_LENGTH;
return Math.min(easeOut(l), easeOut(t));
},
fill: () => "rgba(105,101,219,0.05)",
stroke: () => "rgba(105,101,219)",
});
}
startPath(x: number, y: number, keepPreviousSelection = false) {
// clear any existing trails just in case
this.endPath();
super.startPath(x, y);
this.intersectedElements.clear();
this.enclosedElements.clear();
this.keepPreviousSelection = keepPreviousSelection;
if (!this.keepPreviousSelection) {
this.app.setState({
selectedElementIds: {},
selectedGroupIds: {},
selectedLinearElement: null,
});
}
}
selectElementsFromIds = (ids: string[]) => {
this.app.setState((prevState) => {
const nextSelectedElementIds = ids.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>);
if (this.keepPreviousSelection) {
for (const id of Object.keys(prevState.selectedElementIds)) {
nextSelectedElementIds[id] = true;
}
}
for (const [id] of Object.entries(nextSelectedElementIds)) {
const element = this.app.scene.getNonDeletedElement(id);
if (element && isTextElement(element)) {
const container = getContainerElement(
element,
this.app.scene.getNonDeletedElementsMap(),
);
if (container) {
nextSelectedElementIds[container.id] = true;
delete nextSelectedElementIds[element.id];
}
}
}
// remove all children of selected frames
for (const [id] of Object.entries(nextSelectedElementIds)) {
const element = this.app.scene.getNonDeletedElement(id);
if (element && isFrameLikeElement(element)) {
const elementsInFrame = getFrameChildren(
this.app.scene.getNonDeletedElementsMap(),
element.id,
);
for (const child of elementsInFrame) {
delete nextSelectedElementIds[child.id];
}
}
}
const nextSelection = selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.app.scene.getNonDeletedElements(),
prevState,
this.app,
);
const selectedIds = [...Object.keys(nextSelection.selectedElementIds)];
const selectedGroupIds = [...Object.keys(nextSelection.selectedGroupIds)];
return {
selectedElementIds: nextSelection.selectedElementIds,
selectedGroupIds: nextSelection.selectedGroupIds,
selectedLinearElement:
selectedIds.length === 1 &&
!selectedGroupIds.length &&
isLinearElement(this.app.scene.getNonDeletedElement(selectedIds[0]))
? new LinearElementEditor(
this.app.scene.getNonDeletedElement(
selectedIds[0],
) as NonDeleted<ExcalidrawLinearElement>,
)
: null,
};
});
};
addPointToPath = (x: number, y: number, keepPreviousSelection = false) => {
super.addPointToPath(x, y);
this.keepPreviousSelection = keepPreviousSelection;
this.updateSelection();
};
private updateSelection = () => {
const lassoPath = super
.getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
if (!this.elementsSegments) {
this.elementsSegments = new Map();
const visibleElementsMap = arrayToMap(this.app.visibleElements);
for (const element of this.app.visibleElements) {
const segments = getElementLineSegments(element, visibleElementsMap);
this.elementsSegments.set(element.id, segments);
}
}
if (lassoPath) {
const { selectedElementIds } = getLassoSelectedElementIds({
lassoPath,
elements: this.app.visibleElements,
elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements,
simplifyDistance: 5 / this.app.state.zoom.value,
});
this.selectElementsFromIds(selectedElementIds);
}
};
endPath(): void {
super.endPath();
super.clearTrails();
this.intersectedElements.clear();
this.enclosedElements.clear();
this.elementsSegments = null;
}
}

View file

@ -0,0 +1,111 @@
import { simplify } from "points-on-curve";
import {
polygonFromPoints,
polygonIncludesPoint,
lineSegment,
lineSegmentIntersectionPoints,
} from "@excalidraw/math";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];
elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
simplifyDistance?: number;
}): {
selectedElementIds: string[];
} => {
const {
lassoPath,
elements,
elementsSegments,
intersectedElements,
enclosedElements,
simplifyDistance,
} = input;
// simplify the path to reduce the number of points
let path: GlobalPoint[] = lassoPath;
if (simplifyDistance) {
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
}
// close the path to form a polygon for enclosure check
const closedPath = polygonFromPoints(path);
// as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear();
for (const element of elements) {
if (
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
const enclosed = enclosureTest(closedPath, element, elementsSegments);
if (enclosed) {
enclosedElements.add(element.id);
} else {
const intersects = intersectionTest(
closedPath,
element,
elementsSegments,
);
if (intersects) {
intersectedElements.add(element.id);
}
}
}
}
const results = [...intersectedElements, ...enclosedElements];
return {
selectedElementIds: results,
};
};
const enclosureTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
): boolean => {
const lassoPolygon = polygonFromPoints(lassoPath);
const segments = elementsSegments.get(element.id);
if (!segments) {
return false;
}
return segments.some((segment) => {
return segment.some((point) => polygonIncludesPoint(point, lassoPolygon));
});
};
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
): boolean => {
const elementSegments = elementsSegments.get(element.id);
if (!elementSegments) {
return false;
}
const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) =>
elementSegments.some(
(elementSegment) =>
// introduce a bit of tolerance to account for roughness and simplification of paths
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null,
),
);
};

View file

@ -279,6 +279,7 @@
},
"toolBar": {
"selection": "Selection",
"lasso": "Lasso selection",
"image": "Insert image",
"rectangle": "Rectangle",
"diamond": "Diamond",

View file

@ -76,7 +76,7 @@
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-tabs": "1.0.2",
"@radix-ui/react-tabs": "1.1.3",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",

View file

@ -5,6 +5,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1089,6 +1090,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1309,6 +1311,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1644,6 +1647,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1979,6 +1983,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2199,6 +2204,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2443,6 +2449,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2748,6 +2755,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3121,6 +3129,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3600,6 +3609,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3927,6 +3937,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4254,6 +4265,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4661,6 +4673,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5883,6 +5896,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7151,6 +7165,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7422,7 +7437,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
</svg>,
"label": "labels.elementLock.unlockAll",
"name": "unlockAllElements",
"paletteName": "Unlock all elements",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
@ -7573,7 +7587,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"keyTest": [Function],
"label": "buttons.zenMode",
"name": "zenMode",
"paletteName": "Toggle zen mode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
@ -7617,7 +7630,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"keyTest": [Function],
"label": "labels.viewMode",
"name": "viewMode",
"paletteName": "Toggle view mode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
@ -7691,7 +7703,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
],
"label": "stats.fullTitle",
"name": "stats",
"paletteName": "Toggle stats",
"perform": [Function],
"trackEvent": {
"category": "menu",
@ -7829,6 +7840,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8818,6 +8830,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -572,7 +572,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
class="color-picker__top-picks"
>
<button
class="color-picker__button active"
class="color-picker__button active has-outline"
data-testid="color-top-pick-#ffffff"
style="--swatch-color: #ffffff;"
title="#ffffff"
@ -583,7 +583,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
/>
</button>
<button
class="color-picker__button"
class="color-picker__button has-outline"
data-testid="color-top-pick-#f8f9fa"
style="--swatch-color: #f8f9fa;"
title="#f8f9fa"
@ -594,7 +594,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
/>
</button>
<button
class="color-picker__button"
class="color-picker__button has-outline"
data-testid="color-top-pick-#f5faff"
style="--swatch-color: #f5faff;"
title="#f5faff"
@ -605,7 +605,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
/>
</button>
<button
class="color-picker__button"
class="color-picker__button has-outline"
data-testid="color-top-pick-#fffce8"
style="--swatch-color: #fffce8;"
title="#fffce8"
@ -616,7 +616,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
/>
</button>
<button
class="color-picker__button"
class="color-picker__button has-outline"
data-testid="color-top-pick-#fdf8f6"
style="--swatch-color: #fdf8f6;"
title="#fdf8f6"
@ -635,7 +635,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color properties-trigger"
class="color-picker__button active-color properties-trigger has-outline"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"

View file

@ -5,6 +5,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -605,6 +606,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1113,6 +1115,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1485,6 +1488,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1858,6 +1862,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2129,6 +2134,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2569,6 +2575,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2872,6 +2879,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3160,6 +3168,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3458,6 +3467,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3748,6 +3758,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3987,6 +3998,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4250,6 +4262,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4527,6 +4540,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4762,6 +4776,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4997,6 +5012,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5230,6 +5246,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5463,6 +5480,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5726,6 +5744,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6061,6 +6080,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6490,6 +6510,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6872,6 +6893,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7195,6 +7217,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7497,6 +7520,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7730,6 +7754,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8089,6 +8114,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8448,6 +8474,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8856,6 +8883,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -9147,6 +9175,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9416,6 +9445,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9684,6 +9714,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9919,6 +9950,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10224,6 +10256,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10568,6 +10601,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10807,6 +10841,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11260,6 +11295,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11518,6 +11554,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11761,6 +11798,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12006,6 +12044,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -12411,6 +12450,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12662,6 +12702,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12907,6 +12948,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13152,6 +13194,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13403,6 +13446,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13739,6 +13783,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13915,6 +13960,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14207,6 +14253,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14478,6 +14525,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14757,6 +14805,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14922,6 +14971,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -15620,6 +15670,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -16240,6 +16291,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -16860,6 +16912,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -17571,6 +17624,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -18319,6 +18373,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -18797,6 +18852,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -19323,6 +19379,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -19783,6 +19840,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -20,7 +20,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"roundness": {
"type": 3,
},
"seed": 238820263,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@ -54,14 +54,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 1505387817,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 23633383,
"version": 6,
"versionNonce": 915032327,
"width": 30,
"x": -10,
"y": 60,

View file

@ -5,6 +5,7 @@ exports[`given element A and group of elements B and given both are selected whe
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -421,6 +422,7 @@ exports[`given element A and group of elements B and given both are selected whe
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -828,6 +830,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1374,6 +1377,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1579,6 +1583,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1955,6 +1960,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2194,6 +2200,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2375,6 +2382,7 @@ exports[`regression tests > can drag element that covers another element, while
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2696,6 +2704,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2943,6 +2952,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3187,6 +3197,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3418,6 +3429,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3675,6 +3687,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3987,6 +4000,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4410,6 +4424,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4694,6 +4709,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4948,6 +4964,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5159,6 +5176,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5359,6 +5377,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5742,6 +5761,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6033,6 +6053,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -6842,6 +6863,7 @@ exports[`regression tests > given a group of selected elements with an element t
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7173,6 +7195,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7450,6 +7473,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7685,6 +7709,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7923,6 +7948,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8104,6 +8130,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8285,6 +8312,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8466,6 +8494,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8690,6 +8719,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8913,6 +8943,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -9108,6 +9139,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9332,6 +9364,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9513,6 +9546,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9736,6 +9770,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9917,6 +9952,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -10112,6 +10148,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10293,6 +10330,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10802,6 +10840,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11080,6 +11119,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11207,6 +11247,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11407,6 +11448,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11719,6 +11761,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12132,6 +12175,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12746,6 +12790,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12876,6 +12921,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13461,6 +13507,7 @@ exports[`regression tests > switches from group of selected elements to another
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13800,6 +13847,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14066,6 +14114,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14193,6 +14242,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14573,6 +14623,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "text",
@ -14700,6 +14751,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -402,7 +402,10 @@ const proxy = <T extends ExcalidrawElement>(
};
/** Tools that can be used to draw shapes */
type DrawingToolName = Exclude<ToolType, "lock" | "selection" | "eraser">;
type DrawingToolName = Exclude<
ToolType,
"lock" | "selection" | "eraser" | "lasso"
>;
type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
import { newArrowElement } from "@excalidraw/element/newElement";
import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
@ -19,7 +21,7 @@ import {
import * as textElementUtils from "@excalidraw/element/textElement";
import { wrapText } from "@excalidraw/element/textWrapping";
import type { GlobalPoint } from "@excalidraw/math";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
@ -164,6 +166,24 @@ describe("Test Linear Elements", () => {
Keyboard.keyPress(KEYS.DELETE);
};
it("should normalize the element points at creation", () => {
const element = newArrowElement({
type: "arrow",
points: [pointFrom<LocalPoint>(0.5, 0), pointFrom<LocalPoint>(100, 100)],
x: 0,
y: 0,
});
expect(element.points).toEqual([
pointFrom<LocalPoint>(0.5, 0),
pointFrom<LocalPoint>(100, 100),
]);
new LinearElementEditor(element);
expect(element.points).toEqual([
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(99.5, 100),
]);
});
it("should not drag line and add midpoint until dragged beyond a threshold", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;

View file

@ -136,6 +136,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type ToolType =
| "selection"
| "lasso"
| "rectangle"
| "diamond"
| "ellipse"
@ -308,6 +309,8 @@ export interface AppState {
*/
lastActiveTool: ActiveTool | null;
locked: boolean;
// indicates if the current tool is temporarily switched on from the selection tool
fromSelection: boolean;
} & ActiveTool;
penMode: boolean;
penDetected: boolean;

View file

@ -160,13 +160,17 @@ export const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
*/
export function lineSegmentIntersectionPoints<
Point extends GlobalPoint | LocalPoint,
>(l: LineSegment<Point>, s: LineSegment<Point>): Point | null {
>(
l: LineSegment<Point>,
s: LineSegment<Point>,
threshold?: number,
): Point | null {
const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1]));
if (
!candidate ||
!pointOnLineSegment(candidate, s) ||
!pointOnLineSegment(candidate, l)
!pointOnLineSegment(candidate, s, threshold) ||
!pointOnLineSegment(candidate, l, threshold)
) {
return null;
}

View file

@ -5,6 +5,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

190
yarn.lock
View file

@ -1003,7 +1003,7 @@
"@babel/plugin-transform-modules-commonjs" "^7.25.9"
"@babel/plugin-transform-typescript" "^7.25.9"
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.6", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4":
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4":
version "7.26.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433"
integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==
@ -2220,13 +2220,6 @@
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
"@radix-ui/primitive@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.0.tgz#e1d8ef30b10ea10e69c76e896f608d9276352253"
integrity sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3"
@ -2239,47 +2232,30 @@
dependencies:
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-collection@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.1.tgz#259506f97c6703b36291826768d3c1337edd1de5"
integrity sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==
"@radix-ui/react-collection@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.2.tgz#b45eccca1cb902fd078b237316bd9fa81e621e15"
integrity sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-slot" "1.0.1"
"@radix-ui/react-compose-refs@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae"
integrity sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-compose-refs@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec"
integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==
"@radix-ui/react-context@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.0.tgz#f38e30c5859a9fb5e9aa9a9da452ee3ed9e0aee0"
integrity sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a"
integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==
"@radix-ui/react-direction@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.0.tgz#a2e0b552352459ecf96342c79949dd833c1e6e45"
integrity sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-direction@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc"
integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==
"@radix-ui/react-dismissable-layer@1.1.5":
version "1.1.5"
@ -2306,14 +2282,6 @@
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-id@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e"
integrity sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-id@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed"
@ -2366,15 +2334,6 @@
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-presence@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.0.tgz#814fe46df11f9a468808a6010e3f3ca7e0b2e84a"
integrity sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-presence@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.2.tgz#bb764ed8a9118b7ec4512da5ece306ded8703cdc"
@ -2383,14 +2342,6 @@
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-primitive@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz#c1ebcce283dd2f02e4fbefdaa49d1cb13dbc990a"
integrity sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.1"
"@radix-ui/react-primitive@2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz#ac8b7854d87b0d7af388d058268d9a7eb64ca8ef"
@ -2398,29 +2349,20 @@
dependencies:
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-roving-focus@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz#d8ac2e3b8006697bdfc2b0eb06bef7e15b6245de"
integrity sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==
"@radix-ui/react-roving-focus@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz#815d051a54299114a68db6eb8d34c41a3c0a646f"
integrity sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.0"
"@radix-ui/react-collection" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-direction" "1.0.0"
"@radix-ui/react-id" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-controllable-state" "1.0.0"
"@radix-ui/react-slot@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.1.tgz#e7868c669c974d649070e9ecbec0b367ee0b4d81"
integrity sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-collection" "1.1.2"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-slot@1.1.2":
version "1.1.2"
@ -2429,41 +2371,25 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-tabs@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz#8f5ec73ca41b151a413bdd6e00553408ff34ce07"
integrity sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==
"@radix-ui/react-tabs@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz#c47c8202dc676dea47676215863d2ef9b141c17a"
integrity sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-direction" "1.0.0"
"@radix-ui/react-id" "1.0.0"
"@radix-ui/react-presence" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-roving-focus" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.0"
"@radix-ui/react-use-callback-ref@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz#9e7b8b6b4946fe3cbe8f748c82a2cce54e7b6a90"
integrity sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-presence" "1.1.2"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-roving-focus" "1.1.2"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-use-callback-ref@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==
"@radix-ui/react-use-controllable-state@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz#a64deaafbbc52d5d407afaa22d493d687c538b7f"
integrity sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-controllable-state@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0"
@ -2478,13 +2404,6 @@
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz#2fc19e97223a81de64cd3ba1dc42ceffd82374dc"
integrity sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
@ -8851,16 +8770,8 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8962,14 +8873,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -10102,7 +10006,8 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
"@types/trusted-types" "^2.0.2"
workbox-core "7.3.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -10120,15 +10025,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"