diff --git a/src/components/App.tsx b/src/components/App.tsx index a5a48bfb..997ad7dc 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -157,6 +157,7 @@ import { isElementInGroup, getSelectedGroupIdForElement, getElementsInGroup, + editGroupForSelectedElement, } from "../groups"; import { Library } from "../data/library"; import Scene from "../scene/Scene"; @@ -939,10 +940,9 @@ class App extends React.Component { private onTapEnd = (event: TouchEvent) => { event.preventDefault(); if (event.touches.length > 0) { - const { previousSelectedElementIds } = this.state; this.setState({ previousSelectedElementIds: {}, - selectedElementIds: previousSelectedElementIds, + selectedElementIds: this.state.previousSelectedElementIds, }); } }; @@ -1617,10 +1617,9 @@ class App extends React.Component { private onGestureEnd = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); - const { previousSelectedElementIds } = this.state; this.setState({ previousSelectedElementIds: {}, - selectedElementIds: previousSelectedElementIds, + selectedElementIds: this.state.previousSelectedElementIds, }); gesture.initialScale = null; }); @@ -1904,11 +1903,13 @@ class App extends React.Component { resetCursor(); - this.startTextEditing({ - sceneX, - sceneY, - insertAtParentCenter: !event.altKey, - }); + if (!event[KEYS.CTRL_OR_CMD]) { + this.startTextEditing({ + sceneX, + sceneY, + insertAtParentCenter: !event.altKey, + }); + } }; private handleCanvasPointerMove = ( @@ -2485,7 +2486,9 @@ class App extends React.Component { } }; - // Returns whether the pointer event has been completely handled + /** + * @returns whether the pointer event has been completely handled + */ private handleSelectionOnPointerDown = ( event: React.PointerEvent, pointerDownState: PointerDownState, @@ -2587,6 +2590,16 @@ class App extends React.Component { // If we click on something if (hitElement != null) { + // on CMD/CTRL, drill down to hit element regardless of groups etc. + if (event[KEYS.CTRL_OR_CMD]) { + this.setState((prevState) => ({ + ...editGroupForSelectedElement(prevState, hitElement), + previousSelectedElementIds: this.state.selectedElementIds, + })); + // mark as not completely handled so as to allow dragging etc. + return false; + } + // deselect if item is selected // if shift is not clicked, this will always return true // otherwise, it will trigger selection based on current @@ -2619,7 +2632,7 @@ class App extends React.Component { ...prevState, selectedElementIds: { ...prevState.selectedElementIds, - [hitElement!.id]: true, + [hitElement.id]: true, }, }, this.scene.getElements(), @@ -2630,9 +2643,8 @@ class App extends React.Component { } } - const { selectedElementIds } = this.state; this.setState({ - previousSelectedElementIds: selectedElementIds, + previousSelectedElementIds: this.state.selectedElementIds, }); } } @@ -3530,10 +3542,9 @@ class App extends React.Component { ? prevState.editingGroupId : null, })); - const { selectedElementIds } = this.state; this.setState({ selectedElementIds: {}, - previousSelectedElementIds: selectedElementIds, + previousSelectedElementIds: this.state.selectedElementIds, }); } diff --git a/src/groups.ts b/src/groups.ts index ab0ce2e5..2b0627d5 100644 --- a/src/groups.ts +++ b/src/groups.ts @@ -86,6 +86,20 @@ export function selectGroupsForSelectedElements( return nextAppState; } +export const editGroupForSelectedElement = ( + appState: AppState, + element: NonDeleted, +): AppState => { + return { + ...appState, + editingGroupId: element.groupIds.length ? element.groupIds[0] : null, + selectedGroupIds: {}, + selectedElementIds: { + [element.id]: true, + }, + }; +}; + export function isElementInGroup(element: ExcalidrawElement, groupId: string) { return element.groupIds.includes(groupId); } diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index a6f2a618..a59f63c7 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -878,6 +878,757 @@ exports[`given element A and group of elements B and given both are selected whe exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected: [end of test] number of renders 1`] = `22`; +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] appState 1`] = ` +Object { + "appearance": "light", + "collaborators": Map {}, + "currentItemBackgroundColor": "transparent", + "currentItemFillStyle": "hachure", + "currentItemFontFamily": 1, + "currentItemFontSize": 20, + "currentItemLinearStrokeSharpness": "round", + "currentItemOpacity": 100, + "currentItemRoughness": 1, + "currentItemStrokeColor": "#000000", + "currentItemStrokeSharpness": "sharp", + "currentItemStrokeStyle": "solid", + "currentItemStrokeWidth": 1, + "currentItemTextAlign": "left", + "cursorButton": "up", + "cursorX": 0, + "cursorY": 0, + "draggingElement": null, + "editingElement": null, + "editingGroupId": "id10", + "editingLinearElement": null, + "elementLocked": false, + "elementType": "selection", + "errorMessage": null, + "exportBackground": true, + "gridSize": null, + "height": 768, + "isBindingEnabled": true, + "isCollaborating": false, + "isLibraryOpen": false, + "isLoading": false, + "isResizing": false, + "isRotating": false, + "lastPointerDownWith": "mouse", + "multiElement": null, + "name": "Untitled-201933152653", + "offsetLeft": 0, + "offsetTop": 0, + "openMenu": null, + "previousSelectedElementIds": Object {}, + "resizingElement": null, + "scrollX": 0, + "scrollY": 0, + "scrolledOutside": false, + "selectedElementIds": Object { + "id12": true, + "id7": true, + }, + "selectedGroupIds": Object {}, + "selectionElement": null, + "shouldAddWatermark": false, + "shouldCacheIgnoreZoom": false, + "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], + "username": "", + "viewBackgroundColor": "#ffffff", + "width": 1024, + "zenModeEnabled": false, + "zoom": 1, +} +`; + +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] element 0 1`] = ` +Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 493213705, + "width": 10, + "x": 0, + "y": 0, +} +`; + +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] element 1 1`] = ` +Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 915032327, + "width": 10, + "x": 30, + "y": 30, +} +`; + +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] element 2 1`] = ` +Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id10", + ], + "height": 10, + "id": "id7", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 400692809, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 81784553, + "width": 10, + "x": 60, + "y": 60, +} +`; + +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] history 1`] = ` +Object { + "recording": false, + "redoStack": Array [], + "stateHistory": Array [ + Object { + "appState": Object { + "editingGroupId": null, + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id0": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 2, + "versionNonce": 1278240551, + "width": 10, + "x": 0, + "y": 0, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": null, + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id1": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 2, + "versionNonce": 1278240551, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 2, + "versionNonce": 453191, + "width": 10, + "x": 30, + "y": 30, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": null, + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id0": true, + "id1": true, + "id2": true, + "id3": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1150084233, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1116226695, + "width": 10, + "x": 30, + "y": 30, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": "id4", + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id0": true, + "id5": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1150084233, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1116226695, + "width": 10, + "x": 30, + "y": 30, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": null, + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id7": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1150084233, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1116226695, + "width": 10, + "x": 30, + "y": 30, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [], + "height": 10, + "id": "id7", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 400692809, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 2, + "versionNonce": 1604849351, + "width": 10, + "x": 60, + "y": 60, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": null, + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id0": true, + "id1": true, + "id7": true, + "id8": true, + "id9": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 493213705, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 915032327, + "width": 10, + "x": 30, + "y": 30, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id10", + ], + "height": 10, + "id": "id7", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 400692809, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 81784553, + "width": 10, + "x": 60, + "y": 60, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": "id4", + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id0": true, + "id11": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 493213705, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 915032327, + "width": 10, + "x": 30, + "y": 30, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id10", + ], + "height": 10, + "id": "id7", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 400692809, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 81784553, + "width": 10, + "x": 60, + "y": 60, + }, + ], + }, + Object { + "appState": Object { + "editingGroupId": "id10", + "editingLinearElement": null, + "name": "Untitled-201933152653", + "selectedElementIds": Object { + "id12": true, + "id7": true, + }, + "viewBackgroundColor": "#ffffff", + }, + "elements": Array [ + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 493213705, + "width": 10, + "x": 0, + "y": 0, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id4", + "id10", + ], + "height": 10, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 4, + "versionNonce": 915032327, + "width": 10, + "x": 30, + "y": 30, + }, + Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "fillStyle": "hachure", + "groupIds": Array [ + "id10", + ], + "height": 10, + "id": "id7", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 400692809, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 81784553, + "width": 10, + "x": 60, + "y": 60, + }, + ], + }, + ], +} +`; + +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] number of elements 1`] = `3`; + +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] number of renders 1`] = `41`; + exports[`regression tests Drags selected element when hitting only bounding box and keeps element selected: [end of test] appState 1`] = ` Object { "appearance": "light", diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index a1cd9806..2d6fe661 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -50,6 +50,20 @@ const group = (elements: ExcalidrawElement[]) => { }); }; +const assertSelectedElements = (...elements: ExcalidrawElement[]) => { + expect( + getSelectedElements().map((element) => { + return element.id; + }), + ).toEqual(expect.arrayContaining(elements.map((element) => element.id))); +}; + +const clearSelection = () => { + // @ts-ignore + h.app.clearSelection(null); + expect(getSelectedElements().length).toBe(0); +}; + let altKey = false; let shiftKey = false; let ctrlKey = false; @@ -191,8 +205,7 @@ class Pointer { /** if multiple elements supplied, they're shift-selected */ elements: ExcalidrawElement | ExcalidrawElement[], ) { - // @ts-ignore - h.app.clearSelection(null); + clearSelection(); withModifierKeys({ shift: true }, () => { elements = Array.isArray(elements) ? elements : [elements]; elements.forEach((element) => { @@ -1649,6 +1662,40 @@ describe("regression tests", () => { expect(h.state.selectedGroupIds).toEqual(selectedGroupIds_prev); expect(getSelectedElements()).toEqual(selectedElements_prev); }); + + it("Cmd/Ctrl-click exclusively select element under pointer", () => { + const rect1 = createElement("rectangle", { x: 0 }); + const rect2 = createElement("rectangle", { x: 30 }); + + group([rect1, rect2]); + assertSelectedElements(rect1, rect2); + + withModifierKeys({ ctrl: true }, () => { + mouse.clickOn(rect1); + }); + assertSelectedElements(rect1); + + clearSelection(); + withModifierKeys({ ctrl: true }, () => { + mouse.clickOn(rect1); + }); + assertSelectedElements(rect1); + + const rect3 = createElement("rectangle", { x: 60 }); + group([rect1, rect3]); + assertSelectedElements(rect1, rect2, rect3); + + withModifierKeys({ ctrl: true }, () => { + mouse.clickOn(rect1); + }); + assertSelectedElements(rect1); + + clearSelection(); + withModifierKeys({ ctrl: true }, () => { + mouse.clickOn(rect3); + }); + assertSelectedElements(rect3); + }); }); it(