From c09e170bdd50f9e08a82deba2c037043b8fce9e0 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:14:24 +0100 Subject: [PATCH 01/25] feat(editor): deselect on esc (#11035) Co-authored-by: Jawahar Co-authored-by: Andrew Aquino --- packages/excalidraw/actions/actionDeselect.ts | 145 ++++++ .../excalidraw/actions/actionFinalize.tsx | 4 +- packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/components/App.tsx | 12 +- .../tests/__snapshots__/history.test.tsx.snap | 483 ++++++++++++++++++ packages/excalidraw/tests/history.test.tsx | 76 +++ packages/excalidraw/tests/selection.test.tsx | 65 ++- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 83 +++ 9 files changed, 856 insertions(+), 14 deletions(-) create mode 100644 packages/excalidraw/actions/actionDeselect.ts diff --git a/packages/excalidraw/actions/actionDeselect.ts b/packages/excalidraw/actions/actionDeselect.ts new file mode 100644 index 0000000000..2b48eb825e --- /dev/null +++ b/packages/excalidraw/actions/actionDeselect.ts @@ -0,0 +1,145 @@ +import { + getElementsInGroup, + isSomeElementSelected, + makeNextSelectedElementIds, + selectGroupsForSelectedElements, +} from "@excalidraw/element"; +import { CaptureUpdateAction } from "@excalidraw/element"; +import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common"; + +import type { GroupId } from "@excalidraw/element/types"; + +import { register } from "./register"; + +import type { AppClassProperties, AppState } from "../types"; + +const getNextActiveTool = ( + appState: Readonly, + app: AppClassProperties, +) => { + if (appState.activeTool.type === "eraser") { + return updateActiveTool(appState, { + ...(appState.activeTool.lastActiveTool || { + type: app.state.preferredSelectionTool.type, + }), + lastActiveToolBeforeEraser: null, + }); + } + + return updateActiveTool(appState, { + type: app.state.preferredSelectionTool.type, + }); +}; + +const getParentEditingGroupId = ( + appState: Readonly, + app: AppClassProperties, + selectedElementIds: AppState["selectedElementIds"], +): GroupId | null => { + if (!appState.editingGroupId) { + return null; + } + + const nonDeletedElements = app.scene.getNonDeletedElements(); + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds, + elements: nonDeletedElements, + }); + const candidateElements = selectedElements.length + ? selectedElements + : getElementsInGroup(nonDeletedElements, appState.editingGroupId); + + for (const element of candidateElements) { + const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId); + if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) { + return element.groupIds[editingGroupIndex + 1] as GroupId; + } + } + + return null; +}; + +export const actionDeselect = register({ + name: "deselect", + label: "", + trackEvent: false, + perform: (_elements, appState, _, app) => { + const activeTool = getNextActiveTool(appState, app); + + if (appState.editingGroupId) { + const nonDeletedElements = app.scene.getNonDeletedElements(); + const selectedElementIds = + Object.keys(appState.selectedElementIds).length > 0 + ? appState.selectedElementIds + : getElementsInGroup( + nonDeletedElements, + appState.editingGroupId, + ).reduce((acc, element) => { + acc[element.id] = true; + return acc; + }, {} as Record); + + return { + appState: { + ...appState, + ...selectGroupsForSelectedElements( + { + editingGroupId: getParentEditingGroupId( + appState, + app, + selectedElementIds, + ), + selectedElementIds, + }, + nonDeletedElements, + appState, + app, + ), + activeEmbeddable: null, + activeTool, + selectedLinearElement: null, + selectionElement: null, + showHyperlinkPopup: false, + suggestedBinding: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + + return { + appState: { + ...appState, + activeEmbeddable: null, + activeTool, + editingGroupId: null, + selectedElementIds: makeNextSelectedElementIds({}, appState), + selectedGroupIds: {}, + selectedLinearElement: null, + selectionElement: null, + showHyperlinkPopup: false, + suggestedBinding: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event, appState, _, app) => { + if (event.key !== KEYS.ESCAPE) { + return false; + } + + if (isWritableElement(event.target)) { + return false; + } + + return ( + !appState.newElement && + appState.multiElement === null && + !appState.selectedLinearElement?.isEditing && + (appState.activeEmbeddable !== null || + appState.activeTool.type !== app.state.preferredSelectionTool.type || + !!appState.editingGroupId || + !!appState.selectedLinearElement || + isSomeElementSelected(app.scene.getNonDeletedElements(), appState)) + ); + }, +}); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9e529621a7..37332c4124 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -348,9 +348,7 @@ export const actionFinalize = register({ }; }, keyTest: (event, appState) => - (event.key === KEYS.ESCAPE && - (appState.selectedLinearElement?.isEditing || - (!appState.newElement && appState.multiElement === null))) || + (event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && appState.multiElement !== null), PanelComponent: ({ appState, updateData, data }) => ( diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index cc9ca1789c..d630c6ecaa 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -34,6 +34,7 @@ export { export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionFinalize } from "./actionFinalize"; +export { actionDeselect } from "./actionDeselect"; export { actionChangeProjectName, diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index ae80e4107c..02c67d34f3 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -114,6 +114,7 @@ export type ActionName = | "distributeVertically" | "flipHorizontal" | "flipVertical" + | "deselect" | "viewMode" | "exportWithDarkMode" | "toggleTheme" diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4a79e93baf..5561594cb0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5725,13 +5725,13 @@ class App extends React.Component { const isDeleted = !nextOriginalText.trim(); updateElement(nextOriginalText, isDeleted); - // select the created text element only if submitting via keyboard - // (when submitting via click it should act as signal to deselect) - if (!isDeleted && viaKeyboard) { - const elementIdToSelect = element.containerId - ? element.containerId - : element.id; + // keyboard-submit keeps focus on the edited object. For bound text, keep + // the container selected even if the text becomes empty and is deleted. + const elementIdToSelect = viaKeyboard + ? element.containerId || (!isDeleted ? element.id : null) + : null; + if (elementIdToSelect) { // needed to ensure state is updated before "finalize" action // that's invoked on keyboard-submit as well // TODO either move this into finalize as well, or handle all state diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index e3218fd575..3a400a0128 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -11222,6 +11222,489 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] undo stack 1`] = `[]`; +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] appState 1`] = ` +{ + "activeEmbeddable": null, + "activeLockedId": null, + "activeTool": { + "customType": null, + "fromSelection": false, + "lastActiveTool": null, + "locked": false, + "type": "selection", + }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "collaborators": Map {}, + "contextMenu": null, + "croppingElementId": null, + "currentHoveredFontFamily": null, + "currentItemArrowType": "round", + "currentItemBackgroundColor": "transparent", + "currentItemEndArrowhead": "arrow", + "currentItemFillStyle": "solid", + "currentItemFontFamily": 5, + "currentItemFontSize": 20, + "currentItemOpacity": 100, + "currentItemRoughness": 1, + "currentItemRoundness": "sharp", + "currentItemStartArrowhead": null, + "currentItemStrokeColor": "#1e1e1e", + "currentItemStrokeStyle": "solid", + "currentItemStrokeWidth": 2, + "currentItemTextAlign": "left", + "cursorButton": "up", + "defaultSidebarDockedPreference": false, + "editingFrame": null, + "editingGroupId": null, + "editingTextElement": null, + "elementsToHighlight": null, + "errorMessage": null, + "exportBackground": true, + "exportEmbedScene": false, + "exportScale": 1, + "exportWithDarkMode": false, + "fileHandle": null, + "followedBy": Set {}, + "frameRendering": { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, + "frameToHighlight": null, + "gridModeEnabled": false, + "gridSize": 20, + "gridStep": 5, + "height": 0, + "hoveredElementIds": {}, + "isBindingEnabled": true, + "isCropping": false, + "isLoading": false, + "isMidpointSnappingEnabled": true, + "isResizing": false, + "isRotating": false, + "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, + "multiElement": null, + "newElement": null, + "objectsSnapModeEnabled": false, + "offsetLeft": 0, + "offsetTop": 0, + "openDialog": null, + "openMenu": null, + "openPopup": null, + "openSidebar": null, + "originSnapOffset": null, + "penDetected": false, + "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, + "previousSelectedElementIds": {}, + "resizingElement": null, + "scrollX": 0, + "scrollY": 0, + "searchMatches": null, + "selectedElementIds": {}, + "selectedElementsAreBeingDragged": false, + "selectedGroupIds": {}, + "selectionElement": null, + "shouldCacheIgnoreZoom": false, + "showHyperlinkPopup": false, + "showWelcomeScreen": true, + "snapLines": [], + "startBoundElement": null, + "stats": { + "open": false, + "panels": 3, + }, + "suggestedBinding": null, + "theme": "light", + "toast": null, + "userToFollow": null, + "viewBackgroundColor": "#ffffff", + "viewModeEnabled": false, + "width": 0, + "zenModeEnabled": false, + "zoom": { + "value": 1, + }, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 0 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "width": 100, + "x": 0, + "y": 0, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "outer", + ], + "height": 100, + "id": "id1", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "width": 100, + "x": 100, + "y": 100, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 2 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "id": "id2", + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "width": 100, + "x": 200, + "y": 200, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] number of elements 1`] = `3`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] number of renders 1`] = `16`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] redo stack 1`] = `[]`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] undo stack 1`] = ` +[ + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedElementIds": { + "id0": true, + "id1": true, + "id2": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + "inserted": { + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + }, + }, + "elements": { + "added": {}, + "removed": { + "id0": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 2, + "width": 100, + "x": 0, + "y": 0, + }, + "inserted": { + "isDeleted": true, + "version": 1, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "outer", + ], + "height": 100, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 2, + "width": 100, + "x": 100, + "y": 100, + }, + "inserted": { + "isDeleted": true, + "version": 1, + }, + }, + "id2": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 2, + "width": 100, + "x": 200, + "y": 200, + }, + "inserted": { + "isDeleted": true, + "version": 1, + }, + }, + }, + "updated": {}, + }, + "id": "id5", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": "outer", + "selectedElementIds": {}, + "selectedGroupIds": { + "inner": true, + }, + }, + "inserted": { + "editingGroupId": null, + "selectedElementIds": { + "id1": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id7", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": "inner", + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + "inserted": { + "editingGroupId": "outer", + "selectedElementIds": { + "id2": true, + }, + "selectedGroupIds": { + "inner": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": "outer", + "selectedElementIds": { + "id2": true, + }, + "selectedGroupIds": { + "inner": true, + }, + }, + "inserted": { + "editingGroupId": "inner", + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id19", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": null, + "selectedElementIds": { + "id1": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + "inserted": { + "editingGroupId": "outer", + "selectedElementIds": {}, + "selectedGroupIds": { + "inner": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id20", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + "inserted": { + "selectedElementIds": { + "id0": true, + "id1": true, + "id2": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id21", + }, +] +`; + exports[`history > multiplayer undo/redo > should update history entries after remote changes on the same properties > [end of test] appState 1`] = ` { "activeEmbeddable": null, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 96e948958e..f6706a0f99 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -2971,6 +2971,82 @@ describe("history", () => { expect(h.state.editingGroupId).toBeNull(); }); + // TODO mark with "noncritical" tag once we migrate to vitest 4 + it.skip("should support undo and redo when escape unwinds nested group editing", async () => { + const rectA = API.createElement({ + type: "rectangle", + groupIds: ["inner", "outer"], + x: 0, + }); + const rectB = API.createElement({ + type: "rectangle", + groupIds: ["outer"], + x: 100, + }); + const rectC = API.createElement({ + type: "rectangle", + groupIds: ["inner", "outer"], + x: 200, + }); + + API.setElements([rectA, rectB, rectC]); + mouse.select(rectA); + mouse.doubleClickOn(rectA); + mouse.doubleClickOn(rectA); + + assertSelectedElements([rectA]); + expect(h.state.editingGroupId).toBe("inner"); + expect(API.getUndoStack().length).toBe(3); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements([rectA, rectC]); + expect(h.state.editingGroupId).toBe("outer"); + expect(API.getUndoStack().length).toBe(4); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements([rectA, rectB, rectC]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + expect(API.getUndoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.keyPress(KEYS.ESCAPE); + expect(API.getSelectedElements()).toEqual([]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({}); + expect(API.getUndoStack().length).toBe(6); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.undo(); + assertSelectedElements([rectA, rectB, rectC]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + + Keyboard.undo(); + assertSelectedElements([rectA, rectC]); + expect(h.state.editingGroupId).toBe("outer"); + + Keyboard.undo(); + assertSelectedElements([rectA]); + expect(h.state.editingGroupId).toBe("inner"); + + Keyboard.redo(); + assertSelectedElements([rectA, rectC]); + expect(h.state.editingGroupId).toBe("outer"); + + Keyboard.redo(); + assertSelectedElements([rectA, rectB, rectC]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + + Keyboard.redo(); + expect(API.getSelectedElements()).toEqual([]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({}); + }); + it("should iterate through the history when selected or editing linear element was remotely deleted", async () => { // create three point arrow UI.clickTool("arrow"); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 93135fb2e1..64204f1a00 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -326,7 +326,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -359,7 +359,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -392,7 +392,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -438,7 +438,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(10); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -483,7 +483,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(10); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -558,3 +558,58 @@ describe("selectedElementIds stability", () => { expect(h.state.selectedElementIds).toBe(selectedElementIds_2); }); }); + +describe("deselecting", () => { + beforeEach(async () => { + await render(); + }); + + it("esc unwinds nested group editing before deselecting", () => { + const rectA = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + groupIds: ["inner", "outer"], + }); + const rectB = API.createElement({ + type: "rectangle", + x: 100, + y: 0, + groupIds: ["outer"], + }); + const rectC = API.createElement({ + type: "rectangle", + x: 200, + y: 0, + groupIds: ["inner", "outer"], + }); + + API.setElements([rectA, rectB, rectC]); + + mouse.select(rectA); + assertSelectedElements(rectA, rectB, rectC); + expect(h.state.editingGroupId).toBeNull(); + + mouse.doubleClickOn(rectA); + assertSelectedElements(rectA, rectC); + expect(h.state.editingGroupId).toBe("outer"); + + mouse.doubleClickOn(rectA); + assertSelectedElements(rectA); + expect(h.state.editingGroupId).toBe("inner"); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements(rectA, rectC); + expect(h.state.editingGroupId).toBe("outer"); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements(rectA, rectB, rectC); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + + Keyboard.keyPress(KEYS.ESCAPE); + expect(API.getSelectedElements()).toEqual([]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({}); + }); +}); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 2a0e5b73c7..a092576de0 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -45,6 +45,28 @@ unmountComponent(); const tab = " "; const mouse = new Pointer("mouse"); +const exitTextEditorAndAssertSelection = async ({ + editor, + selectedIds, + nextText, +}: { + editor: HTMLTextAreaElement; + selectedIds: string[]; + nextText?: string; +}) => { + if (nextText !== undefined) { + updateTextEditor(editor, nextText); + } + + Keyboard.exitTextEditor(editor); + + expect(await getTextEditor({ waitForEditor: false })).toBe(null); + expect(window.h.state.editingTextElement).toBeNull(); + expect(API.getSelectedElements().map((element) => element.id)).toEqual( + selectedIds, + ); +}; + describe("textWysiwyg", () => { describe("start text editing", () => { const { h } = window; @@ -271,6 +293,33 @@ describe("textWysiwyg", () => { expect(h.elements.length).toBe(1); }); + it("should reselect text after exiting wysiwyg with escape", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + API.setSelectedElements([text]); + UI.clickTool("selection"); + + Keyboard.keyPress(KEYS.ENTER); + + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + expect(h.state.editingTextElement?.id).toBe(text.id); + + await exitTextEditorAndAssertSelection({ + editor, + selectedIds: [text.id], + }); + }); + it("should edit selected bound text on single click", async () => { const container = API.createElement({ type: "rectangle", @@ -1305,6 +1354,40 @@ describe("textWysiwyg", () => { ); }); + it.each([ + { + label: "container", + createElements: () => API.createTextContainer(), + }, + { + label: "arrow", + createElements: () => API.createLabeledArrow(), + }, + ])( + "should reselect $label after deleting bound text with escape", + async ({ createElements }) => { + const [selectedElement, text] = createElements(); + API.setElements([selectedElement, text]); + API.setSelectedElements([selectedElement]); + + Keyboard.keyPress(KEYS.ENTER); + const editor = await getTextEditor(); + + await exitTextEditorAndAssertSelection({ + editor, + nextText: "", + selectedIds: [selectedElement.id], + }); + + expect(selectedElement.boundElements).toStrictEqual([]); + expect(h.elements[1]).toEqual( + expect.objectContaining({ + isDeleted: true, + }), + ); + }, + ); + it("should restore original container height and clear cache once text is unbind", async () => { const container = API.createElement({ type: "rectangle", From 4a5c9e990ca2dca3106b7bbe674b21cf58acec7d Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:56:48 +0100 Subject: [PATCH 02/25] fix(editor): ensure font picker font names are not quoted (#11036) --- .../components/FontPicker/FontPickerList.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index e5e7b85aea..5d107cbeb1 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -46,6 +46,7 @@ import { import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import type { JSX } from "react"; +import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace"; export interface FontDescriptor { value: number; @@ -86,6 +87,15 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => { } }; +const getFontFamilyLabel = ( + fontFamily: FontFamilyValues, + fontFaces: ExcalidrawFontFace[], +) => + // prefer our config as the browser resolved names may be wrapped in quotes and such + Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ?? + fontFaces[0]?.fontFace?.family ?? + "Unknown"; + export const FontPickerList = React.memo( ({ selectedFontFamily, @@ -114,7 +124,7 @@ export const FontPickerList = React.memo( const fontDescriptor = { value: familyId, icon: getFontFamilyIcon(familyId), - text: fontFaces[0]?.fontFace?.family ?? "Unknown", + text: getFontFamilyLabel(familyId, fontFaces), }; if (metadata.deprecated) { From 4be4cc0ed03e8bd3af23bb07b7f4a04debdb7bdf Mon Sep 17 00:00:00 2001 From: dagecko Date: Mon, 30 Mar 2026 10:49:27 -0400 Subject: [PATCH 03/25] fix: pin 9 actions to commit SHA (#11075) --- .github/workflows/cancel.yml | 2 +- .github/workflows/locales-coverage.yml | 2 +- .github/workflows/publish-docker.yml | 8 ++++---- .github/workflows/semantic-pr-title.yml | 2 +- .github/workflows/size-limit.yml | 2 +- .github/workflows/test-coverage-pr.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml index e1ef216651..a572df9bed 100644 --- a/.github/workflows/cancel.yml +++ b/.github/workflows/cancel.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: - - uses: styfle/cancel-workflow-action@0.6.0 + - uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 with: workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 access_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/locales-coverage.yml b/.github/workflows/locales-coverage.yml index d69f1b1449..67a942438b 100644 --- a/.github/workflows/locales-coverage.yml +++ b/.github/workflows/locales-coverage.yml @@ -40,7 +40,7 @@ jobs: echo ::set-output name=body::$body - name: Update description with coverage - uses: kt3k/update-pr-description@v1.0.1 + uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 with: pr_body: ${{ steps.getCommentBody.outputs.body }} pr_title: "chore: Update translations from Crowdin" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 68eee27755..d0aedcb26b 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -13,16 +13,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . push: true diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index 34a6413fe2..8bfc1327b4 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -11,6 +11,6 @@ jobs: semantic: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index ded07f91fb..2a24507630 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -20,7 +20,7 @@ jobs: working-directory: packages/excalidraw env: CI: true - - uses: andresz1/size-limit-action@v1 + - uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} build_script: build:esm diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 227991a7bc..0cb6327564 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -21,6 +21,6 @@ jobs: run: yarn test:coverage - name: "Report Coverage" if: always() # Also generate the report if tests are failing - uses: davelosert/vitest-coverage-report-action@v2 + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} From d9e8a33aa4b48d920ecb9d3fde15cf0e8b72fd5c Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:41:11 +0200 Subject: [PATCH 04/25] feat(editor): implement overlap box selection (#11053) Co-authored-by: Mark Tolmacs --- packages/element/src/bounds.ts | 28 +- packages/element/src/collision.ts | 43 +- packages/element/src/frame.ts | 20 +- packages/element/src/selection.ts | 300 +++++++++-- packages/element/tests/collision.test.tsx | 4 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 48 +- .../excalidraw/components/RadioGroup.scss | 5 +- .../components/Stats/stats.test.tsx | 2 +- .../components/dropdownMenu/DropdownMenu.scss | 2 +- .../DropdownMenuItemContentRadio.tsx | 8 +- .../components/main-menu/DefaultItems.tsx | 43 +- packages/excalidraw/data/index.ts | 8 +- packages/excalidraw/data/restore.ts | 6 + packages/excalidraw/locales/en.json | 3 + packages/excalidraw/scene/export.ts | 6 +- .../__snapshots__/contextmenu.test.tsx.snap | 29 +- .../tests/__snapshots__/history.test.tsx.snap | 63 +++ .../regressionTests.test.tsx.snap | 52 ++ packages/excalidraw/tests/history.test.tsx | 2 +- .../excalidraw/tests/regressionTests.test.tsx | 6 +- packages/excalidraw/tests/selection.test.tsx | 509 +++++++++++++++++- packages/excalidraw/types.ts | 4 + packages/math/src/curve.ts | 44 +- .../tests/__snapshots__/export.test.ts.snap | 1 + scripts/woff2/woff2-vite-plugins.js | 7 + 26 files changed, 1141 insertions(+), 104 deletions(-) diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a73a7d28bb..a072b81a90 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = ( return [minX, minY, maxX, maxY]; }; -export const getBoundsFromPoints = ( - points: ExcalidrawFreeDrawElement["points"], +export const getBoundsFromPoints =

( + points: readonly P[], + padding: number = 0, ): Bounds => { let minX = Infinity; let minY = Infinity; @@ -695,7 +696,7 @@ export const getBoundsFromPoints = ( maxY = Math.max(maxY, y); } - return [minX, minY, maxX, maxY]; + return [minX - padding, minY - padding, maxX + padding, maxY + padding]; }; const getFreeDrawElementAbsoluteCoords = ( @@ -1261,6 +1262,17 @@ export const pointInsideBounds =

( ): boolean => p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; +// TODO make pointInsideBounds inclusive and remove this function once we +// test nothing is breaking +export const pointInsideBoundsInclusive =

( + p: P, + bounds: Bounds, +): boolean => + p[0] >= bounds[0] && + p[0] <= bounds[2] && + p[1] >= bounds[1] && + p[1] <= bounds[3]; + export const doBoundsIntersect = ( bounds1: Bounds | null, bounds2: Bounds | null, @@ -1275,13 +1287,21 @@ export const doBoundsIntersect = ( return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; }; +export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) => + [ + pointFrom(innerBounds[0], innerBounds[1]), + pointFrom(innerBounds[0], innerBounds[3]), + pointFrom(innerBounds[2], innerBounds[1]), + pointFrom(innerBounds[2], innerBounds[3]), + ].every((point) => pointInsideBoundsInclusive(point, outerBounds)); + export const elementCenterPoint = ( element: ExcalidrawElement, elementsMap: ElementsMap, xOffset: number = 0, yOffset: number = 0, ) => { - if (isLinearElement(element)) { + if (isLinearElement(element) || isFreeDrawElement(element)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x, y] = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index b17d563dbe..c260ae5267 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -154,14 +154,11 @@ export const hitElementItself = ({ // Hit test against the extended, rotated bounding box of the element first const bounds = getElementBounds(element, elementsMap, true); - const hitBounds = isPointWithinBounds( - pointFrom(bounds[0] - threshold, bounds[1] - threshold), - pointRotateRads( - point, - getCenterForBounds(bounds), - -element.angle as Radians, - ), - pointFrom(bounds[2] + threshold, bounds[3] + threshold), + const hitBounds = isPointInRotatedBounds( + point, + bounds, + element.angle, + threshold, ); // PERF: Bail out early if the point is not even in the @@ -192,18 +189,32 @@ export const hitElementItself = ({ return result; }; +const isPointInRotatedBounds = ( + point: GlobalPoint, + bounds: Bounds, + angle: Radians, + tolerance = 0, +) => { + const adjustedPoint = + angle === 0 + ? point + : pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians); + + return isPointWithinBounds( + pointFrom(bounds[0] - tolerance, bounds[1] - tolerance), + adjustedPoint, + pointFrom(bounds[2] + tolerance, bounds[3] + tolerance), + ); +}; + export const hitElementBoundingBox = ( point: GlobalPoint, element: ExcalidrawElement, elementsMap: ElementsMap, tolerance = 0, ) => { - let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); - x1 -= tolerance; - y1 -= tolerance; - x2 += tolerance; - y2 += tolerance; - return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); + const bounds = getElementBounds(element, elementsMap, true); + return isPointInRotatedBounds(point, bounds, element.angle, tolerance); }; export const hitElementBoundingBoxOnly = ( @@ -573,7 +584,9 @@ const intersectLinearOrFreeDrawWithLineSegment = ( continue; } - const hits = curveIntersectLineSegment(c, segment); + const hits = curveIntersectLineSegment(c, segment, { + iterLimit: 10, + }); if (hits.length > 0) { intersections.push(...hits); diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3c82099546..787c2692f9 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -1,7 +1,6 @@ import { arrayToMap } from "@excalidraw/common"; import { isPointWithinBounds, pointFrom } from "@excalidraw/math"; import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; -import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds"; import type { AppClassProperties, @@ -18,6 +17,8 @@ import { getElementLineSegments, getCommonBounds, getElementAbsoluteCoords, + doBoundsIntersect, + getElementBounds, } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, getContainerElement } from "./textElement"; @@ -920,16 +921,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => { export const getElementsOverlappingFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - return ( - elementsOverlappingBBox({ - elements, - bounds: frame, - type: "overlap", - }) - // removes elements who are overlapping, but are in a different frame, + return elements.filter( + (el) => + // exclude elements which are overlapping, but are in a different frame, // and thus invisible in target frame - .filter((el) => !el.frameId || el.frameId === frame.id) + (!el.frameId || el.frameId === frame.id) && + doBoundsIntersect( + getElementBounds(el, elementsMap), + getElementBounds(frame, elementsMap), + ), ); }; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index ea7fdb1b77..8e9c4e8086 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -1,15 +1,32 @@ -import { arrayToMap, isShallowEqual } from "@excalidraw/common"; +import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; +import { + lineSegment, + pointFrom, + pointRotateRads, + type GlobalPoint, +} from "@excalidraw/math"; import type { AppState, + BoxSelectionMode, InteractiveCanvasAppState, } from "@excalidraw/excalidraw/types"; -import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; +import { + boundsContainBounds, + doBoundsIntersect, + elementCenterPoint, + getElementAbsoluteCoords, + getElementBounds, + pointInsideBounds, +} from "./bounds"; +import { intersectElementWithLineSegment } from "./collision"; import { isElementInViewport } from "./sizeHelpers"; import { + isArrowElement, isBoundToContainer, isFrameLikeElement, + isFreeDrawElement, isLinearElement, isTextElement, } from "./typeChecks"; @@ -17,19 +34,38 @@ import { elementOverlapsWithFrame, getContainingFrame, getFrameChildren, + isElementIntersectingFrame, } from "./frame"; import { LinearElementEditor } from "./linearElementEditor"; import { selectGroupsForSelectedElements } from "./groups"; +import { getBoundTextElement } from "./textElement"; import type { ElementsMap, ElementsMapOrArray, ExcalidrawElement, + ExcalidrawFrameLikeElement, NonDeleted, NonDeletedExcalidrawElement, } from "./types"; +const shouldIgnoreElementFromSelection = ( + element: NonDeletedExcalidrawElement, +) => element.locked || isBoundToContainer(element); + +const excludeElementsFromFrames = ( + selectedElements: readonly T[], + framesInSelection: Set, +) => { + return selectedElements.filter((element) => { + if (element.frameId && framesInSelection.has(element.frameId)) { + return false; + } + return true; + }); +}; + /** * Frames and their containing elements are not to be selected at the same time. * Given an array of selected elements, if there are frames and their containing elements @@ -49,55 +85,243 @@ export const excludeElementsInFramesFromSelection = < } }); - return selectedElements.filter((element) => { - if (element.frameId && framesInSelection.has(element.frameId)) { - return false; - } - return true; - }); + return excludeElementsFromFrames(selectedElements, framesInSelection); }; export const getElementsWithinSelection = ( elements: readonly NonDeletedExcalidrawElement[], selection: NonDeletedExcalidrawElement, elementsMap: ElementsMap, + // TODO remove (this flag is effectively unused AFAIK) excludeElementsInFrames: boolean = true, -) => { - const [selectionX1, selectionY1, selectionX2, selectionY2] = + boxSelectionMode: BoxSelectionMode = "contain", +): NonDeletedExcalidrawElement[] => { + const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] = getElementAbsoluteCoords(selection, elementsMap); + const selectionX1 = Math.min(selectionStartX, selectionEndX); + const selectionY1 = Math.min(selectionStartY, selectionEndY); + const selectionX2 = Math.max(selectionStartX, selectionEndX); + const selectionY2 = Math.max(selectionStartY, selectionEndY); + const selectionBounds = [ + selectionX1, + selectionY1, + selectionX2, + selectionY2, + ] as Bounds; + const selectionEdges = [ + lineSegment( + pointFrom(selectionX1, selectionY1), + pointFrom(selectionX2, selectionY1), + ), + lineSegment( + pointFrom(selectionX2, selectionY1), + pointFrom(selectionX2, selectionY2), + ), + lineSegment( + pointFrom(selectionX2, selectionY2), + pointFrom(selectionX1, selectionY2), + ), + lineSegment( + pointFrom(selectionX1, selectionY2), + pointFrom(selectionX1, selectionY1), + ), + ]; - let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( - element, - elementsMap, - ); + const framesInSelection = excludeElementsInFrames + ? new Set() + : null; + let elementsInSelection: NonDeletedExcalidrawElement[] = []; - const containingFrame = getContainingFrame(element, elementsMap); - if (containingFrame) { - const [fx1, fy1, fx2, fy2] = getElementBounds( - containingFrame, - elementsMap, - ); - - elementX1 = Math.max(fx1, elementX1); - elementY1 = Math.max(fy1, elementY1); - elementX2 = Math.min(fx2, elementX2); - elementY2 = Math.min(fy2, elementY2); + for (const element of elements) { + if (shouldIgnoreElementFromSelection(element)) { + continue; } - return ( - element.locked === false && - element.type !== "selection" && - !isBoundToContainer(element) && - selectionX1 <= elementX1 && - selectionY1 <= elementY1 && - selectionX2 >= elementX2 && - selectionY2 >= elementY2 - ); - }); + const strokeWidth = element.strokeWidth; + let labelAABB: Bounds | null = null; + let elementAABB = getElementBounds(element, elementsMap); - elementsInSelection = excludeElementsInFrames - ? excludeElementsInFramesFromSelection(elementsInSelection) + elementAABB = [ + elementAABB[0] - strokeWidth / 2, + elementAABB[1] - strokeWidth / 2, + elementAABB[2] + strokeWidth / 2, + elementAABB[3] + strokeWidth / 2, + ] as Bounds; + + // Whether the element bounds should include the bound text element bounds + const boundTextElement = + isArrowElement(element) && getBoundTextElement(element, elementsMap); + if (boundTextElement) { + const { x, y } = LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ); + labelAABB = [ + x, + y, + x + boundTextElement.width, + y + boundTextElement.height, + ] as Bounds; + } + + // Clip element bounds by its containing frame (if any), since only the + // visible (frame-clipped) portion of the element is relevant for selection. + const associatedFrame = getContainingFrame(element, elementsMap); + if ( + associatedFrame && + isElementIntersectingFrame(element, associatedFrame, elementsMap) + ) { + const frameAABB = getElementBounds(associatedFrame, elementsMap); + elementAABB = [ + Math.max(elementAABB[0], frameAABB[0]), + Math.max(elementAABB[1], frameAABB[1]), + Math.min(elementAABB[2], frameAABB[2]), + Math.min(elementAABB[3], frameAABB[3]), + ] as Bounds; + + labelAABB = labelAABB + ? ([ + Math.max(labelAABB[0], frameAABB[0]), + Math.max(labelAABB[1], frameAABB[1]), + Math.min(labelAABB[2], frameAABB[2]), + Math.min(labelAABB[3], frameAABB[3]), + ] as Bounds) + : null; + } + + const commonAABB = labelAABB + ? ([ + Math.min(labelAABB[0], elementAABB[0]), + Math.min(labelAABB[1], elementAABB[1]), + Math.max(labelAABB[2], elementAABB[2]), + Math.max(labelAABB[3], elementAABB[3]), + ] as Bounds) + : elementAABB; + + // ============== Evaluation ============== + + // 1. If the selection box WRAPs the element's AABB, then add it to the + // selection and move on, regardless of the selection mode. + // + // PERF: This trick only works with axis-aligned box selection and the + // current convex element shapes! + if (boundsContainBounds(selectionBounds, commonAABB)) { + if (framesInSelection && isFrameLikeElement(element)) { + framesInSelection.add(element.id); + } else { + elementsInSelection.push(element); + continue; + } + } + + // 2. Handle the case where the label is overlapped by the selection box + if ( + boxSelectionMode === "overlap" && + labelAABB && + doBoundsIntersect(selectionBounds, labelAABB) + ) { + elementsInSelection.push(element); + continue; + } + + // 3. Handle the case where the selection is not wrapping the element, but + // it does intersect the element's outline (non-AABB). + if ( + boxSelectionMode === "overlap" && + doBoundsIntersect(selectionBounds, elementAABB) + ) { + let hasIntersection = false; + + // Preliminary check potential intersection imprecision + if (isLinearElement(element) || isFreeDrawElement(element)) { + const center = elementCenterPoint(element, elementsMap); + hasIntersection = element.points.some((point) => { + const rotatedPoint = pointRotateRads( + pointFrom(element.x + point[0], element.y + point[1]), + center, + element.angle, + ); + + return pointInsideBounds(rotatedPoint, selectionBounds); + }); + } else { + const nonRotatedElementBounds = getElementBounds( + element, + elementsMap, + true, + ); + const center = elementCenterPoint(element, elementsMap); + hasIntersection = [ + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[1], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[2], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[3], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[0], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + ].some((point) => { + return pointInsideBounds( + pointRotateRads(point, center, element.angle), + selectionBounds, + ); + }); + } + + if (!hasIntersection) { + hasIntersection = selectionEdges.some( + (selectionEdge) => + intersectElementWithLineSegment( + element, + elementsMap, + selectionEdge, + strokeWidth / 2, + true, // Stop at first hit for better performance + ).length > 0, + ); + } + + if (hasIntersection) { + if (framesInSelection && isFrameLikeElement(element)) { + framesInSelection.add(element.id); + } + + elementsInSelection.push(element); + continue; + } + } + + // 4. We don't need to handle when the selection is inside the element + // as it is separately handled in App. + } + + elementsInSelection = framesInSelection + ? excludeElementsFromFrames(elementsInSelection, framesInSelection) : elementsInSelection; elementsInSelection = elementsInSelection.filter((element) => { diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index 4061a16cb6..a44f1f7bb0 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -1,4 +1,4 @@ -import { arrayToMap } from "@excalidraw/common"; +import { arrayToMap, reseed } from "@excalidraw/common"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; @@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision"; describe("check rotated elements can be hit:", () => { beforeEach(async () => { localStorage.clear(); + reseed(7); await render(); }); @@ -56,6 +57,7 @@ describe("hitElementItself cache", () => { }); localStorage.clear(); + reseed(7); await render(); }); diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index e51865b2ea..93fe770286 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -128,6 +128,7 @@ export const getDefaultAppState = (): Omit< lockedMultiSelections: {}, activeLockedId: null, bindMode: "orbit", + boxSelectionMode: "contain", }; }; @@ -193,6 +194,7 @@ const APP_STATE_STORAGE_CONF = (< gridModeEnabled: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, isBindingEnabled: { browser: true, export: false, server: false }, + boxSelectionMode: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5561594cb0..4db823c2d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -27,6 +27,7 @@ import { KEYS, APP_NAME, CURSOR_TYPE, + DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, @@ -2524,6 +2525,7 @@ class App extends React.Component { const magicFrameChildren = getElementsOverlappingFrame( this.scene.getNonDeletedElements(), magicFrame, + this.scene.getNonDeletedElementsMap(), ).filter((el) => !isMagicFrameElement(el)); if (!magicFrameChildren.length) { @@ -7239,6 +7241,14 @@ class App extends React.Component { this.interactiveCanvas, isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, ); + } else if ( + !event[KEYS.CTRL_OR_CMD] && + this.isHittingCommonBoundingBoxOfSelectedElements( + scenePointer, + selectedElements, + ) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } else if (this.state.viewModeEnabled) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else if (this.state.openDialog?.name === "elementLinkSelector") { @@ -7730,17 +7740,24 @@ class App extends React.Component { const hitSelectedElement = pointerDownState.hit.element && this.isASelectedElement(pointerDownState.hit.element); + const shouldForceLassoReselect = + event.altKey && + event[KEYS.CTRL_OR_CMD] && + !pointerDownState.resize.handleType; + const shouldStartLassoSelection = + shouldForceLassoReselect || + (!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + !pointerDownState.resize.handleType && + !hitSelectedElement); - if ( - !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && - !pointerDownState.resize.handleType && - !hitSelectedElement - ) { - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); + if (shouldStartLassoSelection) { + if (!this.lassoTrail.hasCurrentTrail) { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + } // block dragging after lasso selection on PCs until the next pointer down // (on mobile or tablet, we want to allow user to drag immediately) @@ -8729,12 +8746,14 @@ class App extends React.Component { DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value, 1, ); + const boundsPadding = + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value; const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( - point.x > x1 - threshold && - point.x < x2 + threshold && - point.y > y1 - threshold && - point.y < y2 + threshold + point.x > x1 - boundsPadding - threshold && + point.x < x2 + boundsPadding + threshold && + point.y > y1 - boundsPadding - threshold && + point.y < y2 + boundsPadding + threshold ); } @@ -10267,6 +10286,7 @@ class App extends React.Component { this.state.selectionElement, this.scene.getNonDeletedElementsMap(), false, + this.state.boxSelectionMode, ) : []; diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index 28ddc8889b..d550d95b3b 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -26,13 +26,16 @@ background: var(--RadioGroup-background); border: 1px solid var(--RadioGroup-border); + gap: 2px; + &__choice { position: relative; display: flex; align-items: center; justify-content: center; - width: 32px; + min-width: 20px; height: 24px; + padding: 0 0.375rem; color: var(--RadioGroup-choice-color-off); background: var(--RadioGroup-choice-background-off); diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 283bdb40d3..24d739afa9 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -750,7 +750,7 @@ describe("frame resizing behavior", () => { x: 0, y: 0, width: 100, - height: 100, + height: 103, }); // Create a rectangle outside the frame diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e207203d60..3080314250 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -2,7 +2,7 @@ .excalidraw { .dropdown-menu { - max-width: 16rem; + max-width: 20rem; z-index: 1; &--placement-top { diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index 4f2986c30e..63646745fb 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -1,4 +1,5 @@ import { useEditorInterface } from "../App"; +import { Ellipsify } from "../Ellipsify"; import { RadioGroup } from "../RadioGroup"; type Props = { @@ -12,6 +13,7 @@ type Props = { onChange: (value: T) => void; children: React.ReactNode; name: string; + icon?: React.ReactNode; }; const DropdownMenuItemContentRadio = ({ @@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = ({ choices, children, name, + icon, }: Props) => { const editorInterface = useEditorInterface(); return ( <>

- + {icon &&
{icon}
} + { ); }; +const PreferencesBoxSelectionModeItem = () => { + const { t } = useI18n(); + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + return ( + + name="boxSelectionMode" + icon={emptyIcon} + value={appState.boxSelectionMode} + onChange={(value) => { + setAppState({ + boxSelectionMode: value, + }); + }} + choices={[ + { + value: "contain", + label: t("labels.boxSelectionContain"), + ariaLabel: t("labels.boxSelectionContain"), + }, + { + value: "overlap", + label: t("labels.boxSelectionOverlap"), + ariaLabel: t("labels.boxSelectionOverlap"), + }, + ]} + > + {t("labels.boxSelectionMode")} + + ); +}; + const PreferencesToggleSnapModeItem = () => { const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -568,6 +607,7 @@ export const Preferences = ({ {children || ( <> + @@ -585,6 +625,7 @@ export const Preferences = ({ }; Preferences.ToggleToolLock = PreferencesToggleToolLockItem; +Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 1083a47897..d31ab931fd 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -6,6 +6,7 @@ import { MIME_TYPES, cloneJSON, SVG_DOCUMENT_PREAMBLE, + arrayToMap, } from "@excalidraw/common"; import { getNonDeletedElements } from "@excalidraw/element"; @@ -49,6 +50,7 @@ export const prepareElementsForExport = ( exportSelectionOnly: boolean, ) => { elements = getNonDeletedElements(elements); + const elementsMap = arrayToMap(elements); const isExportingSelection = exportSelectionOnly && @@ -71,7 +73,11 @@ export const prepareElementsForExport = ( isFrameLikeElement(exportedElements[0]) ) { exportingFrame = exportedElements[0]; - exportedElements = getElementsOverlappingFrame(elements, exportingFrame); + exportedElements = getElementsOverlappingFrame( + elements, + exportingFrame, + elementsMap, + ); } else if (exportedElements.length > 1) { exportedElements = getSelectedElements( elements, diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 1ed068dc1c..7a8b9e586c 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -936,6 +936,12 @@ export const restoreAppState = ( : defaultValue; } + const boxSelectionMode = + appState.boxSelectionMode ?? localAppState?.boxSelectionMode; + if (boxSelectionMode !== undefined) { + nextAppState.boxSelectionMode = boxSelectionMode; + } + return { ...nextAppState, cursorButton: localAppState?.cursorButton || "up", diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 42e87bdf82..05d3bf702e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -185,6 +185,9 @@ "shapeSwitch": "Switch shape", "preferences": "Preferences", "preferences_toolLock": "Tool lock", + "boxSelectionMode": "Select on", + "boxSelectionContain": "Wrap", + "boxSelectionOverlap": "Overlap", "arrowBinding": "Arrow binding", "midpointSnapping": "Snap to midpoints" }, diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 7cf111f6ce..8d8c405e82 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -157,7 +157,11 @@ const prepareElementsForRender = ({ let nextElements: readonly ExcalidrawElement[]; if (exportingFrame) { - nextElements = getElementsOverlappingFrame(elements, exportingFrame); + nextElements = getElementsOverlappingFrame( + elements, + exportingFrame, + arrayToMap(elements), + ); } else if (frameRendering.enabled && frameRendering.name) { nextElements = addFrameLabelsAsTextElements(elements, { exportWithDarkMode, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 15458fa366..cb548f5b62 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -13,6 +13,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -1086,6 +1087,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1300,6 +1302,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1631,6 +1634,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1962,6 +1966,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2176,6 +2181,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2417,6 +2423,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2715,6 +2722,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3087,6 +3095,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3580,6 +3589,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3903,6 +3913,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4226,6 +4237,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4358,7 +4370,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 760410951, + "versionNonce": 1006504105, "width": 20, "x": -10, "y": 0, @@ -4383,14 +4395,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 1006504105, + "versionNonce": 289600103, "width": 20, "x": 20, "y": 30, @@ -4637,6 +4649,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5854,6 +5867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -6864,7 +6878,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 747212839, + "versionNonce": 1723083209, "width": 10, "x": -10, "y": 0, @@ -6891,14 +6905,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1723083209, + "versionNonce": 760410951, "width": 10, "x": 12, "y": 0, @@ -7122,6 +7136,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7811,6 +7826,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8802,6 +8818,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 3a400a0128..2923e9b321 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -13,6 +13,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -646,6 +647,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1207,6 +1209,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1567,6 +1570,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1929,6 +1933,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2192,6 +2197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2645,6 +2651,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2948,6 +2955,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3267,6 +3275,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3561,6 +3570,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3847,6 +3857,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4082,6 +4093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4339,6 +4351,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4610,6 +4623,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4839,6 +4853,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5068,6 +5083,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5315,6 +5331,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5571,6 +5588,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5829,6 +5847,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6158,6 +6177,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6585,6 +6605,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6959,6 +6980,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7271,6 +7293,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7563,6 +7586,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7793,6 +7817,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8145,6 +8170,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8497,6 +8523,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8903,6 +8930,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9182,6 +9210,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9446,6 +9475,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9711,6 +9741,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9943,6 +9974,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10240,6 +10272,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10557,6 +10590,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10793,6 +10827,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11718,6 +11753,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11978,6 +12014,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12213,6 +12250,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12450,6 +12488,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12841,6 +12880,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13051,6 +13091,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13258,6 +13299,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13559,6 +13601,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13857,6 +13900,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14102,6 +14146,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14339,6 +14384,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14576,6 +14622,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14823,6 +14870,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15154,6 +15202,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15324,6 +15373,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15608,6 +15658,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15871,6 +15922,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16024,6 +16076,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16306,6 +16359,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16468,6 +16522,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17216,6 +17271,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17862,6 +17918,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18508,6 +18565,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19257,6 +19315,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20025,6 +20084,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20505,6 +20565,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -21016,6 +21077,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -21475,6 +21537,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index e97991d96f..e502925b25 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -439,6 +440,7 @@ exports[`given element A and group of elements B and given both are selected whe }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -855,6 +857,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1421,6 +1424,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1628,6 +1632,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2012,6 +2017,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2257,6 +2263,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2437,6 +2444,7 @@ exports[`regression tests > can drag element that covers another element, while }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2762,6 +2770,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3017,6 +3026,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3258,6 +3268,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3494,6 +3505,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3752,6 +3764,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4066,6 +4079,7 @@ exports[`regression tests > deleting last but one element in editing group shoul }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4502,6 +4516,7 @@ exports[`regression tests > deselects group of selected elements on pointer down }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4785,6 +4800,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5061,6 +5077,7 @@ exports[`regression tests > deselects selected element on pointer down when poin }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5269,6 +5286,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5469,6 +5487,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5862,6 +5881,7 @@ exports[`regression tests > drags selected elements from point inside common bou }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6159,6 +6179,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6947,6 +6968,7 @@ exports[`regression tests > given a group of selected elements with an element t }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7281,6 +7303,7 @@ exports[`regression tests > given a selected element A and a not selected elemen }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7560,6 +7583,7 @@ exports[`regression tests > given selected element A with lower z-index than uns }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7795,6 +7819,7 @@ exports[`regression tests > given selected element A with lower z-index than uns }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8035,6 +8060,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8215,6 +8241,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8395,6 +8422,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8575,6 +8603,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8807,6 +8836,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9037,6 +9067,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9229,6 +9260,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9461,6 +9493,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9641,6 +9674,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9871,6 +9905,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10051,6 +10086,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10243,6 +10279,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10423,6 +10460,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10954,6 +10992,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11234,6 +11273,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11357,6 +11397,7 @@ exports[`regression tests > shift click on selected element should deselect it o }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11557,6 +11598,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11876,6 +11918,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12305,6 +12348,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12945,6 +12989,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13071,6 +13116,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13702,6 +13748,7 @@ exports[`regression tests > switches from group of selected elements to another }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14041,6 +14088,7 @@ exports[`regression tests > switches selected element on pointer down > [end of }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14305,6 +14353,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14428,6 +14477,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14792,6 +14842,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14915,6 +14966,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index f6706a0f99..78b431b773 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -314,7 +314,7 @@ describe("history", () => { expect.objectContaining({ id: rect2.id, isDeleted: true }), ]); - mouse.downAt(0, 0); + mouse.downAt(-10, -10); mouse.moveTo(25, 25); mouse.moveTo(50, 50); mouse.upAt(50, 50); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index d12a845d63..986c828f26 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -468,7 +468,7 @@ describe("regression tests", () => { mouse.reset(); mouse.down(); mouse.move(-1000, -1000); - mouse.restorePosition(...end); + mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.up(); expect(h.elements.length).toBe(3); @@ -519,7 +519,7 @@ describe("regression tests", () => { mouse.reset(); mouse.down(); mouse.move(-1000, -1000); - mouse.restorePosition(...end); + mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.up(); for (const element of h.elements) { @@ -537,7 +537,7 @@ describe("regression tests", () => { mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further mouse.down(); mouse.move(-1000, -1000); - mouse.restorePosition(...end); + mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.up(); Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 64204f1a00..ef0bd46b2c 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -1,7 +1,9 @@ import React from "react"; import { vi } from "vitest"; -import { KEYS, reseed } from "@excalidraw/common"; +import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; +import { getElementBounds, getElementLineSegments } from "@excalidraw/element"; +import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math"; import { SHAPES } from "../components/shapes"; @@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { + act, render, fireEvent, mockBoundingClientRect, @@ -39,6 +42,19 @@ const { h } = window; const mouse = new Pointer("mouse"); +const getOutlineBounds = (element: ReturnType) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const points = getElementLineSegments(sceneElement, elementsMap).flat(); + + return [ + Math.min(...points.map((point) => point[0])), + Math.min(...points.map((point) => point[1])), + Math.max(...points.map((point) => point[0])), + Math.max(...points.map((point) => point[1])), + ] as const; +}; + describe("box-selection", () => { beforeEach(async () => { await render(); @@ -108,6 +124,497 @@ describe("box-selection", () => { assertSelectedElements([]); }); + + it("should not select an element when the selection box only partially overlaps it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + mouse.downAt(25, -20); + mouse.move(-1000, -1000); + mouse.moveTo(75, 70); + mouse.up(); + + assertSelectedElements([]); + }); +}); + +describe("lasso reselection", () => { + beforeEach(async () => { + await render(); + }); + + it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => { + const rectA = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + const rectB = API.createElement({ + type: "rectangle", + x: 220, + y: 0, + width: 100, + height: 100, + backgroundColor: "blue", + fillStyle: "solid", + }); + + API.setElements([rectA, rectB]); + mouse.select([rectA, rectB]); + act(() => { + h.app.setActiveTool({ type: "lasso" }); + }); + + Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { + mouse.downAt(110, 50); + mouse.moveTo(50, -20); + + expect(h.app.lassoTrail.hasCurrentTrail).toBe(true); + + mouse.moveTo(-20, 50); + mouse.moveTo(50, 120); + mouse.moveTo(110, 50); + mouse.up(); + }); + + assertSelectedElements([rectA.id]); + }); +}); + +describe("box-selection overlap mode", () => { + const boxSelect = ( + startX: number, + startY: number, + endX: number, + endY: number, + ) => { + mouse.downAt(startX, startY); + mouse.move(-1000, -1000); + mouse.moveTo(endX, endY); + mouse.up(); + }; + + const boxSelectTopLeftAabbCorner = ( + element: ReturnType, + ) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneElement, elementsMap); + + boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12); + }; + + const boxSelectTopRightAabbCorner = ( + element: ReturnType, + ) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [, y1, x2] = getElementBounds(sceneElement, elementsMap); + + boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12); + }; + + const boxSelectTopLeftRotatedLocalBoundsCorner = ( + element: ReturnType, + ) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true); + const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); + const [cornerX, cornerY] = pointRotateRads( + pointFrom(x1, y1), + center, + sceneElement.angle, + ); + + boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4); + }; + + beforeEach(async () => { + await render( + , + ); + }); + + it("should select an element when the selection box partially overlaps it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, -20, 75, 70); + + assertSelectedElements([rect1.id]); + }); + + it("should not select a transparent rectangle when the selection box stays inside it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, 25, 75, 75); + + assertSelectedElements([]); + }); + + it("should select a transparent rectangle when the selection box crosses its outline", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, 25, 125, 75); + + assertSelectedElements([rect1.id]); + }); + + it("should not select a rotated transparent rectangle when the selection box stays inside it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(40, 40, 60, 60); + + assertSelectedElements([]); + }); + + it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 180, + angle: Math.PI / 6, + backgroundColor: "transparent", + fillStyle: "solid", + roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, + roughness: 0, + }); + + API.setElements([rect]); + + const sceneRect = API.getElement(rect); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds( + sceneRect, + elementsMap, + ); + const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect); + + expect(outlineX1).toBeGreaterThan(boundsX1 - 1); + expect(outlineY1).toBeGreaterThan(boundsY1 - 1); + expect(outlineX2).toBeLessThan(boundsX2 + 1); + expect(outlineY2).toBeLessThan(boundsY2 + 1); + + boxSelect( + outlineX1 - (outlineX1 - boundsX1) / 2, + outlineY1 - (outlineY1 - boundsY1) / 2, + outlineX2 + (boundsX2 - outlineX2) / 2, + outlineY2 + (boundsY2 - outlineY2) / 2, + ); + + assertSelectedElements([rect.id]); + }); + + it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect]); + + boxSelectTopLeftAabbCorner(rect); + + assertSelectedElements([]); + }); + + it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => { + const ellipse = API.createElement({ + type: "ellipse", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([ellipse]); + + boxSelectTopRightAabbCorner(ellipse); + + assertSelectedElements([]); + }); + + it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => { + const diamond = API.createElement({ + type: "diamond", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([diamond]); + + boxSelectTopRightAabbCorner(diamond); + + assertSelectedElements([]); + }); + + it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => { + const ellipse = API.createElement({ + type: "ellipse", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([ellipse]); + + boxSelectTopLeftRotatedLocalBoundsCorner(ellipse); + + assertSelectedElements([]); + }); + + it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => { + const diamond = API.createElement({ + type: "diamond", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([diamond]); + + boxSelectTopLeftRotatedLocalBoundsCorner(diamond); + + assertSelectedElements([]); + }); + + it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => { + const text = API.createElement({ + type: "text", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + text: "test", + }); + + API.setElements([text]); + + boxSelect(-18, -18, -8, -8); + + assertSelectedElements([]); + }); + + it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => { + const image = API.createElement({ + type: "image", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + fileId: "file_A", + status: "saved", + }); + + API.setElements([image]); + + boxSelect(-18, -18, -8, -8); + + assertSelectedElements([]); + }); + + it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect]); + + mouse.clickAt(50, 50); + assertSelectedElements([rect.id]); + + const sceneRect = API.getElement(rect); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneRect, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a line when the selection box only overlaps its bounds", () => { + const line = API.createElement({ + type: "line", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [pointFrom(0, 0), pointFrom(100, 100)], + }); + + API.setElements([line]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(100, 0), + pointFrom(100, 100), + pointFrom(0, 100), + pointFrom(0, 0), + ], + }); + + API.setElements([freedraw]); + + const sceneFreedraw = API.getElement(freedraw); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a freedraw when the selection box only overlaps its bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(50, 50), + pointFrom(100, 100), + ], + }); + + API.setElements([freedraw]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => { + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 10, + width: 100, + height: 80, + frameId: frame.id, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(60, 20, 90, 60); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cae4e7b93f..a41512fe64 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -269,6 +269,8 @@ export type ObservedElementsAppState = { activeLockedId: AppState["activeLockedId"]; }; +export type BoxSelectionMode = "contain" | "overlap"; + export interface AppState { contextMenu: { items: ContextMenuItems; @@ -307,6 +309,8 @@ export interface AppState { * `bindingPreference` and keyboard modifiers (ctrl/alt) */ isBindingEnabled: boolean; + /** user box selection preference; defaults to "contain" when unset */ + boxSelectionMode: BoxSelectionMode; /** user arrow binding preference */ bindingPreference: "enabled" | "disabled"; /** user preference whether arrow snap to midpoints while binding */ diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index 32f537f434..8e53727f84 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -137,8 +137,17 @@ const calculate = ( [t0, s0]: [number, number], l: LineSegment, c: Curve, + tolerance: number = 1e-2, + iterLimit: number = 4, ) => { - const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4); + const solution = solveWithAnalyticalJacobian( + c, + l, + t0, + s0, + tolerance, + iterLimit, + ); if (!solution) { return null; @@ -158,18 +167,43 @@ const calculate = ( */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, ->(c: Curve, l: LineSegment): Point[] { - let solution = calculate(initial_guesses[0], l, c); +>( + c: Curve, + l: LineSegment, + opts?: { + tolerance?: number; + iterLimit?: number; + }, +): Point[] { + let solution = calculate( + initial_guesses[0], + l, + c, + opts?.tolerance, + opts?.iterLimit, + ); if (solution) { return [solution]; } - solution = calculate(initial_guesses[1], l, c); + solution = calculate( + initial_guesses[1], + l, + c, + opts?.tolerance, + opts?.iterLimit, + ); if (solution) { return [solution]; } - solution = calculate(initial_guesses[2], l, c); + solution = calculate( + initial_guesses[2], + l, + c, + opts?.tolerance, + opts?.iterLimit, + ); if (solution) { return [solution]; } diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index affae46199..f59d0a9e84 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/scripts/woff2/woff2-vite-plugins.js b/scripts/woff2/woff2-vite-plugins.js index b62a76cffb..3870bbcdfc 100644 --- a/scripts/woff2/woff2-vite-plugins.js +++ b/scripts/woff2/woff2-vite-plugins.js @@ -90,6 +90,13 @@ module.exports.woff2BrowserPlugin = () => { type="font/woff2" crossorigin="anonymous" /> + Date: Thu, 2 Apr 2026 14:07:02 +0530 Subject: [PATCH 05/25] Fix typo in Discord badge URL parameter (#11096) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74fc2c084f..48b9d1de9f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ PRs welcome! - Chat on Discord + Chat on Discord Ask DeepWiki From 1caec99b290c75cda05385e637138998807a65ae Mon Sep 17 00:00:00 2001 From: Tom Louveau Date: Mon, 13 Apr 2026 10:30:58 +0200 Subject: [PATCH 06/25] docs: change twitter label by X (#11158) Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- dev-docs/docusaurus.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js index 4e8d75800a..d2b34843ee 100644 --- a/dev-docs/docusaurus.config.js +++ b/dev-docs/docusaurus.config.js @@ -97,8 +97,8 @@ const config = { href: "https://discord.gg/UexuTaE", }, { - label: "Twitter", - href: "https://twitter.com/excalidraw", + label: "𝕏", + href: "https://x.com/excalidraw", }, { label: "Linkedin", From b1c6bfcf40cba255d208d8d525060684b10cb3cc Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:07:00 +0200 Subject: [PATCH 07/25] chore(docker): bump node (#11208) --- .codesandbox/Dockerfile | 2 +- Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile index fd5b38d1e8..ce8c857650 100644 --- a/.codesandbox/Dockerfile +++ b/.codesandbox/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bullseye +FROM node:24-bullseye # Vite wants to open the browser using `open`, so we # need to install those utils. diff --git a/Dockerfile b/Dockerfile index c08385d654..e15b425704 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=${BUILDPLATFORM} node:18 AS build +FROM --platform=${BUILDPLATFORM} node:24 AS build WORKDIR /opt/node_app @@ -13,7 +13,7 @@ ARG NODE_ENV=production RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker -FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine +FROM nginx:1.27-alpine COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html From 2e1a529c6781b1534d27ca79198dccd40b97ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Sat, 25 Apr 2026 12:03:50 +0200 Subject: [PATCH 08/25] fix(editor): remove extremely large arrows on restore (#11235) * fix: Temp fix for elbow arrow at restore Co-authored-by: Copilot Signed-off-by: Mark Tolmacs * fix: Speculative fixes to avoid Infinity Co-authored-by: Copilot Signed-off-by: Mark Tolmacs * validate/remove arrow size after point normalization & move binding repairs back * validate even simple arrows * remove x/y check * remove duplicate constant --------- Signed-off-by: Mark Tolmacs Co-authored-by: Copilot Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/binding.ts | 10 +++--- packages/element/src/elbowArrow.ts | 4 +-- packages/excalidraw/data/restore.ts | 47 +++++++++++++++++++++++++---- packages/math/src/constants.ts | 2 -- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 566ef3c4e4..3f80ffbae2 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1943,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = ( return { fixedPoint: normalizeFixedPoint([ (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / - hoveredElement.width, + Math.max(hoveredElement.width, PRECISION), (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / - hoveredElement.height, + Math.max(hoveredElement.height, PRECISION), ]), }; }; @@ -1976,9 +1976,11 @@ export const calculateFixedPointForNonElbowArrowBinding = ( // Calculate the ratio relative to the element's bounds const fixedPointX = - (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width; + (nonRotatedPoint[0] - hoveredElement.x) / + Math.max(hoveredElement.width, PRECISION); const fixedPointY = - (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height; + (nonRotatedPoint[1] - hoveredElement.y) / + Math.max(hoveredElement.height, PRECISION); return { fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 9543b4182f..024b846b12 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = ( offsetY < -MAX_POS || offsetY > MAX_POS || offsetX + points[points.length - 1][0] < -MAX_POS || - offsetY + points[points.length - 1][0] > MAX_POS || - offsetX + points[points.length - 1][1] < -MAX_POS || + offsetX + points[points.length - 1][0] > MAX_POS || + offsetY + points[points.length - 1][1] < -MAX_POS || offsetY + points[points.length - 1][1] > MAX_POS ) { console.error( diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 7a8b9e586c..7ffe9b712f 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -96,6 +96,8 @@ type RestoredAppState = Omit< "offsetTop" | "offsetLeft" | "width" | "height" >; +const MAX_ARROW_PX = 75_000; + export const AllowedExcalidrawActiveTools: Record< AppState["activeTool"]["type"], boolean @@ -467,8 +469,8 @@ export const restoreElement = ( element.endArrowhead === undefined ? "arrow" : normalizeArrowhead(element.endArrowhead); - const x: number | undefined = element.x; - const y: number | undefined = element.y; + const x = element.x as number | undefined; + const y = element.y as number | undefined; const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 ? [pointFrom(0, 0), pointFrom(element.width, element.height)] @@ -493,8 +495,8 @@ export const restoreElement = ( startArrowhead, endArrowhead, points, - x, - y, + x: x ?? 0, + y: y ?? 0, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), }; @@ -513,12 +515,44 @@ export const restoreElement = ( }) : restoreElementWithProperties(element as ExcalidrawArrowElement, base); - return { + const normalizedRestoredElement = { ...restoredElement, ...LinearElementEditor.getNormalizeElementPointsAndCoords( restoredElement, ), }; + + // Last resort fix for extremely large arrows + if ( + normalizedRestoredElement.width > MAX_ARROW_PX || + normalizedRestoredElement.height > MAX_ARROW_PX + ) { + console.error( + `Removing extremely large arrow ${ + normalizedRestoredElement.id + } (type: ${ + isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple" + }, width: ${normalizedRestoredElement.width}, height: ${ + normalizedRestoredElement.height + }, x: ${normalizedRestoredElement.x}, y: ${ + normalizedRestoredElement.y + })`, + ); + return { + ...normalizedRestoredElement, + x: 0, + y: 0, + width: 100, + height: 100, + points: [ + pointFrom(0, 0), + pointFrom(100, 100), + ], + isDeleted: true, + }; + } + + return normalizedRestoredElement; } // generic elements @@ -666,6 +700,7 @@ export const restoreElements = ( const existingElementsMap = existingElements ? arrayToMap(existingElements) : null; + const restoredElements = syncInvalidIndices( (targetElements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, @@ -762,7 +797,7 @@ export const restoreElements = ( } } - // NOTE (mtolmacs): Temporary fix for extremely large arrows + // NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows // Need to iterate again so we have attached text nodes in elementsMap return restoredElements.map((element) => { if ( diff --git a/packages/math/src/constants.ts b/packages/math/src/constants.ts index ce39e42682..6ba5eb8f7f 100644 --- a/packages/math/src/constants.ts +++ b/packages/math/src/constants.ts @@ -1,5 +1,3 @@ -export const PRECISION = 10e-5; - // Legendre-Gauss abscissae (x values) and weights for n=24 // Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html export const LegendreGaussN24TValues = [ From 43fa4b56028478f53dbb65045df1f9f7737c4321 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:23:10 +0200 Subject: [PATCH 09/25] fix: frame selection and membership (#11250) fix: element fully overlapping frame --- packages/element/src/frame.ts | 6 ++- packages/element/src/selection.ts | 8 ++-- packages/element/tests/frame.test.tsx | 44 +++++++++++++++++++- packages/excalidraw/tests/selection.test.tsx | 26 ++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 787c2692f9..6138633ff4 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -19,6 +19,7 @@ import { getElementAbsoluteCoords, doBoundsIntersect, getElementBounds, + boundsContainBounds, } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, getContainerElement } from "./textElement"; @@ -101,8 +102,9 @@ export const isElementContainingFrame = ( frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, ) => { - return getElementsWithinSelection([frame], element, elementsMap).some( - (e) => e.id === frame.id, + return boundsContainBounds( + getElementBounds(element, elementsMap), + getElementBounds(frame, elementsMap), ); }; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index 8e9c4e8086..88bb1c2c80 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -34,7 +34,6 @@ import { elementOverlapsWithFrame, getContainingFrame, getFrameChildren, - isElementIntersectingFrame, } from "./frame"; import { LinearElementEditor } from "./linearElementEditor"; @@ -170,7 +169,7 @@ export const getElementsWithinSelection = ( const associatedFrame = getContainingFrame(element, elementsMap); if ( associatedFrame && - isElementIntersectingFrame(element, associatedFrame, elementsMap) + elementOverlapsWithFrame(element, associatedFrame, elementsMap) ) { const frameAABB = getElementBounds(associatedFrame, elementsMap); elementAABB = [ @@ -209,10 +208,9 @@ export const getElementsWithinSelection = ( if (boundsContainBounds(selectionBounds, commonAABB)) { if (framesInSelection && isFrameLikeElement(element)) { framesInSelection.add(element.id); - } else { - elementsInSelection.push(element); - continue; } + elementsInSelection.push(element); + continue; } // 2. Handle the case where the label is overlapped by the selection box diff --git a/packages/element/tests/frame.test.tsx b/packages/element/tests/frame.test.tsx index 47f2160ac3..e92267130a 100644 --- a/packages/element/tests/frame.test.tsx +++ b/packages/element/tests/frame.test.tsx @@ -2,6 +2,7 @@ import { convertToExcalidrawElements, Excalidraw, } from "@excalidraw/excalidraw"; +import { arrayToMap } from "@excalidraw/common"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; @@ -10,7 +11,12 @@ import { render, } from "@excalidraw/excalidraw/tests/test-utils"; -import type { ExcalidrawElement } from "../src/types"; +import { elementOverlapsWithFrame } from "../src/frame"; + +import type { + ExcalidrawElement, + ExcalidrawFrameLikeElement, +} from "../src/types"; const { h } = window; const mouse = new Pointer("mouse"); @@ -125,6 +131,26 @@ describe("adding elements to frames", () => { }); }); + it("should treat an element fully containing a frame as overlapping the frame", () => { + const containingRect = API.createElement({ + type: "rectangle", + x: -50, + y: -50, + width: 250, + height: 250, + }); + + API.setElements([containingRect, frame]); + + expect( + elementOverlapsWithFrame( + containingRect, + frame as ExcalidrawFrameLikeElement, + arrayToMap(h.elements), + ), + ).toBe(true); + }); + const commonTestCases = async ( func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, ) => { @@ -415,6 +441,22 @@ describe("adding elements to frames", () => { describe("dragging elements into the frame", async () => { await commonTestCases(dragElementIntoFrame); + it("should add a dragged element fully containing the frame", () => { + const containingRect = API.createElement({ + type: "rectangle", + x: 220, + y: 20, + width: 300, + height: 300, + }); + + API.setElements([frame, containingRect]); + + dragElementIntoFrame(frame, containingRect); + + expect(API.getElement(containingRect).frameId).toBe(frame.id); + }); + it.skip("should drag element inside, duplicate it and keep it in frame", () => { API.setElements([frame, rect2]); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index ef0bd46b2c..5fd7a303d7 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -615,6 +615,32 @@ describe("box-selection overlap mode", () => { assertSelectedElements([]); }); + + it("should not select a framed element when selection only overlaps its clipped-out outline", () => { + const frame = API.createElement({ + type: "frame", + x: 100, + y: 100, + width: 100, + height: 100, + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 50, + width: 200, + height: 200, + frameId: frame.id, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(40, 170, 70, 220); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { From 278cd357724b17e1119b6c76416520c42958d0e3 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:49:40 +0200 Subject: [PATCH 10/25] feat(editor): scale video embeddables based on zoom (#11251) --- packages/excalidraw/components/App.tsx | 75 +++++++++++++++++--------- packages/excalidraw/css/styles.scss | 8 +++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..299304eb0e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -603,6 +603,8 @@ const YOUTUBE_VIDEO_STATES = new Map< ValueOf >(); +const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4; + let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; @@ -1735,6 +1737,18 @@ class App extends React.Component { this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "hover"; + // scale video embeds based on zoom (capped) so that smaller embeds + // on canvas when zoomed are still of legible quality + // (note: for some embed types like gdrive, the quality is poor when + // scaling mid playback and works only when you initially start the + // playback at the higher zoom level) + const shouldScaleEmbeddableViewport = src?.type === "video"; + const embeddableViewportScale = clamp( + shouldScaleEmbeddableViewport ? scale : 1, + 0.75, + MAX_EMBEDDABLE_VIEWPORT_SCALE, + ); + return (
{ padding: `${el.strokeWidth}px`, }} > - {(isEmbeddableElement(el) - ? this.props.renderEmbeddable?.(el, this.state) - : null) ?? ( -