diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
index 6c258c621..32e5f6d07 100644
--- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -1221,7 +1221,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"version": 3,
"versionNonce": 1150084233,
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
}
`;
@@ -1274,7 +1274,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
},
"inserted": {
@@ -2097,7 +2097,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"version": 3,
"versionNonce": 1150084233,
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
}
`;
@@ -2150,7 +2150,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
},
"inserted": {
@@ -2307,7 +2307,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"version": 4,
"versionNonce": 1014066025,
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
}
`;
@@ -2360,7 +2360,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
},
"inserted": {
@@ -2548,7 +2548,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"version": 3,
"versionNonce": 1150084233,
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
}
`;
@@ -2582,7 +2582,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"version": 5,
"versionNonce": 400692809,
"width": 20,
- "x": 0,
+ "x": 3,
"y": 10,
}
`;
@@ -2635,7 +2635,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
- "x": -10,
+ "x": -7,
"y": 0,
},
"inserted": {
@@ -2689,7 +2689,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
- "x": 0,
+ "x": 3,
"y": 10,
},
"inserted": {
@@ -7769,82 +7769,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"collaborators": Map {},
"contextMenu": {
"items": [
- "separator",
- {
- "icon": ,
- "keyTest": [Function],
- "label": "labels.cut",
- "name": "cut",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "icon": ,
- "keyTest": undefined,
- "label": "labels.copy",
- "name": "copy",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
{
"keyTest": undefined,
"label": "labels.paste",
@@ -7855,78 +7779,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
},
"separator",
- {
- "label": "labels.selectAllElementsInFrame",
- "name": "selectAllElementsInFrame",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "canvas",
- },
- },
- {
- "label": "labels.removeAllElementsFromFrame",
- "name": "removeAllElementsFromFrame",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "history",
- },
- },
- {
- "label": "labels.wrapSelectionInFrame",
- "name": "wrapSelectionInFrame",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "PanelComponent": [Function],
- "icon": ,
- "keywords": [
- "image",
- "crop",
- ],
- "label": "helpDialog.cropStart",
- "name": "cropEditor",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "menu",
- },
- "viewMode": true,
- },
- "separator",
{
"icon": ,
"keyTest": [Function],
- "label": "labels.copyStyles",
- "name": "copyStyles",
+ "label": "labels.selectAll",
+ "name": "selectAll",
"perform": [Function],
"trackEvent": {
- "category": "element",
+ "category": "canvas",
},
+ "viewMode": false,
},
{
- "icon": ,
- "keyTest": [Function],
- "label": "labels.pasteStyles",
- "name": "pasteStyles",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "PanelComponent": [Function],
- "icon": [Function],
- "keyTest": [Function],
- "label": "labels.group",
- "name": "group",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "icon": null,
- "label": "labels.autoResize",
- "name": "autoResize",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "label": "labels.unbindText",
- "name": "unbindText",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "label": "labels.bindText",
- "name": "bindText",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "label": "labels.createContainerFromText",
- "name": "wrapTextInContainer",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "PanelComponent": [Function],
- "icon": [Function],
- "keyTest": [Function],
- "label": "labels.ungroup",
- "name": "ungroup",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "label": "labels.addToLibrary",
- "name": "addToLibrary",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "PanelComponent": [Function],
- "icon": ,
- "keyPriority": 40,
- "keyTest": [Function],
- "keywords": [
- "move down",
- "zindex",
- "layer",
- ],
- "label": "labels.sendBackward",
- "name": "sendBackward",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "PanelComponent": [Function],
- "icon": ,
- "keyPriority": 40,
- "keyTest": [Function],
- "keywords": [
- "move up",
- "zindex",
- "layer",
- ],
- "label": "labels.bringForward",
- "name": "bringForward",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "PanelComponent": [Function],
- "icon": ,
- "keyTest": [Function],
- "keywords": [
- "move down",
- "zindex",
- "layer",
- ],
- "label": "labels.sendToBack",
- "name": "sendToBack",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "PanelComponent": [Function],
- "icon": ,
- "keyTest": [Function],
- "keywords": [
- "move up",
- "zindex",
- "layer",
- ],
- "label": "labels.bringToFront",
- "name": "bringToFront",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "icon": ,
- "keyTest": [Function],
- "label": "labels.flipHorizontal",
- "name": "flipHorizontal",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "icon": ,
- "keyTest": [Function],
- "label": "labels.flipVertical",
- "name": "flipVertical",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "PanelComponent": [Function],
- "category": "Elements",
- "keywords": [
- "line",
- ],
- "label": [Function],
- "name": "toggleLinearEditor",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "PanelComponent": [Function],
"icon": ,
- "keyTest": [Function],
- "label": [Function],
- "name": "hyperlink",
+ "label": "labels.elementLock.unlockAll",
+ "name": "unlockAllElements",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
- "action": "click",
- "category": "hyperlink",
+ "category": "canvas",
},
+ "viewMode": false,
},
+ "separator",
{
+ "checked": [Function],
"icon": ,
- "label": "labels.copyElementLink",
- "name": "copyElementLink",
- "perform": [Function],
- "predicate": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- "separator",
- {
- "PanelComponent": [Function],
- "icon": ,
"keyTest": [Function],
- "label": "labels.duplicateSelection",
- "name": "duplicateSelection",
- "perform": [Function],
- "trackEvent": {
- "category": "element",
- },
- },
- {
- "icon": [Function],
- "keyTest": [Function],
- "label": [Function],
- "name": "toggleElementLock",
+ "keywords": [
+ "snap",
+ ],
+ "label": "labels.toggleGrid",
+ "name": "gridMode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
- "category": "element",
+ "category": "canvas",
+ "predicate": [Function],
},
+ "viewMode": true,
},
- "separator",
{
- "PanelComponent": [Function],
+ "checked": [Function],
"icon": ,
"keyTest": [Function],
- "label": "labels.delete",
- "name": "deleteSelectedElements",
+ "label": "buttons.objectsSnapMode",
+ "name": "objectsSnapMode",
+ "perform": [Function],
+ "predicate": [Function],
+ "trackEvent": {
+ "category": "canvas",
+ "predicate": [Function],
+ },
+ "viewMode": false,
+ },
+ {
+ "checked": [Function],
+ "icon": ,
+ "keyTest": [Function],
+ "label": "buttons.zenMode",
+ "name": "zenMode",
+ "perform": [Function],
+ "predicate": [Function],
+ "trackEvent": {
+ "category": "canvas",
+ "predicate": [Function],
+ },
+ "viewMode": true,
+ },
+ {
+ "checked": [Function],
+ "icon": ,
+ "keyTest": [Function],
+ "label": "labels.viewMode",
+ "name": "viewMode",
+ "perform": [Function],
+ "predicate": [Function],
+ "trackEvent": {
+ "category": "canvas",
+ "predicate": [Function],
+ },
+ "viewMode": true,
+ },
+ {
+ "checked": [Function],
+ "icon": ,
+ "keyTest": [Function],
+ "keywords": [
+ "edit",
+ "attributes",
+ "customize",
+ ],
+ "label": "stats.fullTitle",
+ "name": "stats",
"perform": [Function],
"trackEvent": {
- "action": "delete",
- "category": "element",
+ "category": "menu",
},
+ "viewMode": true,
},
],
"left": -17,
diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
index 67d2c5de2..1de6f5db2 100644
--- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
@@ -2465,7 +2465,7 @@ exports[`regression tests > can drag element that covers another element, while
"scrolledOutside": false,
"searchMatches": null,
"selectedElementIds": {
- "id3": true,
+ "id0": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@@ -2667,7 +2667,7 @@ exports[`regression tests > can drag element that covers another element, while
"delta": Delta {
"deleted": {
"selectedElementIds": {
- "id3": true,
+ "id0": true,
},
},
"inserted": {
@@ -2681,7 +2681,7 @@ exports[`regression tests > can drag element that covers another element, while
"added": {},
"removed": {},
"updated": {
- "id3": {
+ "id0": {
"deleted": {
"x": 300,
"y": 300,
diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx
index 75de2717f..21f7a6a79 100644
--- a/packages/excalidraw/tests/contextmenu.test.tsx
+++ b/packages/excalidraw/tests/contextmenu.test.tsx
@@ -304,12 +304,12 @@ describe("contextMenu element", () => {
it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle");
- mouse.down(10, 10);
+ mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
- clientX: 3,
+ clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@@ -389,12 +389,12 @@ describe("contextMenu element", () => {
it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle");
- mouse.down(10, 10);
+ mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
- clientX: 3,
+ clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@@ -405,12 +405,12 @@ describe("contextMenu element", () => {
it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle");
- mouse.down(10, 10);
+ mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
- clientX: 3,
+ clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@@ -424,12 +424,12 @@ describe("contextMenu element", () => {
it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle");
- mouse.down(10, 10);
+ mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
- clientX: 3,
+ clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx
index 9deefb8c7..f11ea01e9 100644
--- a/packages/excalidraw/tests/regressionTests.test.tsx
+++ b/packages/excalidraw/tests/regressionTests.test.tsx
@@ -704,7 +704,7 @@ describe("regression tests", () => {
// pointer down on rectangle
mouse.reset();
- mouse.down(100, 100);
+ mouse.down(110, 100); // Rectangle is rounded, there is no selection at the corner
mouse.up(200, 200);
expect(API.getSelectedElement().type).toBe("rectangle");
@@ -989,6 +989,7 @@ describe("regression tests", () => {
// select rectangle
mouse.reset();
+ mouse.moveTo(30, 0); // Rectangle is rounded, there is no selection at the corner
mouse.click();
// click on intersection between ellipse and rectangle
@@ -1155,6 +1156,7 @@ it(
// Select first rectangle while keeping third one selected.
// Third rectangle is selected because it was the last element to be created.
mouse.reset();
+ mouse.moveTo(30, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
@@ -1176,6 +1178,7 @@ it(
// Pointer down o first rectangle that is part of the group
mouse.reset();
+ mouse.moveTo(30, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.down();
});
diff --git a/packages/utils/src/collision.ts b/packages/utils/src/collision.ts
index 6f49d4b26..ced2b54a0 100644
--- a/packages/utils/src/collision.ts
+++ b/packages/utils/src/collision.ts
@@ -6,6 +6,8 @@ import {
type GlobalPoint,
type LocalPoint,
type Polygon,
+ vectorCross,
+ vectorFromPoint,
} from "@excalidraw/math";
import { intersectElementWithLineSegment } from "@excalidraw/element/collision";
@@ -40,10 +42,19 @@ export const isPointInShape = (
point: GlobalPoint,
element: ExcalidrawElement,
) => {
- if (
- (isLinearElement(element) || isFreeDrawElement(element)) &&
- !isPathALoop(element.points)
- ) {
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ if (isPathALoop(element.points)) {
+ // for a closed path, we need to check if the point is inside the path
+ const r = isPointInClosedPath(
+ element.points.map((p) =>
+ pointFrom(element.x + p[0], element.y + p[1]),
+ ),
+ point,
+ );
+ //console.log(r);
+ return r;
+ }
+
// There isn't any "inside" for a non-looping path
return false;
}
@@ -56,6 +67,36 @@ export const isPointInShape = (
return intersections.length === 0;
};
+/**
+ * Determine if a closed path contains a point.
+ *
+ * Implementation notes: We'll use the fact that the path is a consecutive
+ * sequence of line segments, these line segments have a winding order and
+ * the fact that if a point is inside the closed path, the cross product of the
+ * start point of a line segment to the point p and the end point of the line
+ * segment will be negative for all segments.
+ *
+ * @param points
+ * @param p
+ */
+const isPointInClosedPath = (
+ points: readonly GlobalPoint[],
+ p: GlobalPoint,
+) => {
+ const segments = points.slice(1).map((point, i) => {
+ return lineSegment(points[i], point);
+ });
+
+ return segments.every((segment) => {
+ const c = vectorCross(
+ vectorFromPoint(segment[0], p),
+ vectorFromPoint(segment[0], segment[1]),
+ );
+
+ return c < 0;
+ });
+};
+
// check if the given element is in the given bounds
export const isPointInBounds = (
point: Point,