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",